Spaces:
Sleeping
Sleeping
| /** | |
| * In-memory SQLite test database helper. | |
| * | |
| * Usage in an integration test file: | |
| * | |
| * import { createTestDb, resetTestDb } from '../helpers/test-db'; | |
| * import { buildDbMock } from '../helpers/test-db'; | |
| * | |
| * // Declare at module scope (before vi.mock so it's available in factory) | |
| * const testDb = createTestDb(); | |
| * | |
| * vi.mock('../../src/db/database', () => buildDbMock(testDb)); | |
| * vi.mock('../../src/config', () => TEST_CONFIG); | |
| * | |
| * beforeEach(() => resetTestDb(testDb)); | |
| * afterAll(() => testDb.close()); | |
| */ | |
| import Database from 'better-sqlite3'; | |
| import type { INestApplication } from '@nestjs/common'; | |
| import { createTables } from '../../src/db/schema'; | |
| import { runMigrations } from '../../src/db/migrations'; | |
| import { AuthPublicController } from '../../src/nest/auth/auth-public.controller'; | |
| import type { RateLimitService } from '../../src/nest/auth/rate-limit.service'; | |
| // Tables to clear on reset, child-before-parent to be safe (FK checks are OFF during reset). | |
| // Keep in sync with schema.ts + migrations.ts. Intentionally excluded: categories, addons, | |
| // photo_providers, photo_provider_fields, schema_version (seed/config data, not user data). | |
| const RESET_TABLES = [ | |
| // Collab | |
| 'file_links', | |
| 'collab_message_reactions', | |
| 'collab_poll_votes', | |
| 'collab_messages', | |
| 'collab_polls', | |
| 'collab_notes', | |
| // Day content | |
| 'day_notes', | |
| 'todo_category_assignees', | |
| 'todo_items', | |
| 'assignment_participants', | |
| 'day_assignments', | |
| // Places | |
| 'place_regions', | |
| 'place_tags', | |
| 'places', | |
| // Packing | |
| 'packing_category_assignees', | |
| 'packing_bag_members', | |
| 'packing_bags', | |
| 'packing_template_items', | |
| 'packing_template_categories', | |
| 'packing_templates', | |
| 'packing_items', | |
| // Budget | |
| 'budget_item_members', | |
| 'budget_items', | |
| // Photos & files | |
| 'trip_photos', | |
| 'trip_album_links', | |
| 'trip_files', | |
| 'photos', | |
| // Reservations | |
| 'reservation_day_positions', | |
| 'reservations', | |
| // Accommodations & days | |
| 'day_accommodations', | |
| 'days', | |
| // Trip | |
| 'share_tokens', | |
| 'trip_members', | |
| 'trips', | |
| // Journey | |
| 'journey_share_tokens', | |
| 'journey_photos', | |
| 'journey_entries', | |
| 'journey_contributors', | |
| 'journey_trips', | |
| 'journeys', | |
| // Vacay | |
| 'vacay_entries', | |
| 'vacay_company_holidays', | |
| 'vacay_holiday_calendars', | |
| 'vacay_plan_members', | |
| 'vacay_user_colors', | |
| 'vacay_user_years', | |
| 'vacay_years', | |
| 'vacay_plans', | |
| // Atlas | |
| 'visited_regions', | |
| 'visited_countries', | |
| 'bucket_list', | |
| // Notifications & audit | |
| 'notification_channel_preferences', | |
| 'notifications', | |
| 'audit_log', | |
| // System notices | |
| 'user_notice_dismissals', | |
| // User data | |
| 'settings', | |
| 'mcp_tokens', | |
| 'invite_tokens', | |
| 'tags', | |
| 'app_settings', | |
| 'users', | |
| ]; | |
| const DEFAULT_CATEGORIES = [ | |
| { name: 'Hotel', color: '#3b82f6', icon: '🏨' }, | |
| { name: 'Restaurant', color: '#ef4444', icon: '🍽️' }, | |
| { name: 'Attraction', color: '#8b5cf6', icon: '🏛️' }, | |
| { name: 'Shopping', color: '#f59e0b', icon: '🛍️' }, | |
| { name: 'Transport', color: '#6b7280', icon: '🚌' }, | |
| { name: 'Activity', color: '#10b981', icon: '🎯' }, | |
| { name: 'Bar/Cafe', color: '#f97316', icon: '☕' }, | |
| { name: 'Beach', color: '#06b6d4', icon: '🏖️' }, | |
| { name: 'Nature', color: '#84cc16', icon: '🌿' }, | |
| { name: 'Other', color: '#6366f1', icon: '📍' }, | |
| ]; | |
| const DEFAULT_ADDONS = [ | |
| { id: 'packing', name: 'Packing List', description: 'Pack your bags', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 }, | |
| { id: 'budget', name: 'Costs', description: 'Track and split trip expenses', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 }, | |
| { id: 'documents', name: 'Documents', description: 'Manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 }, | |
| { id: 'vacay', name: 'Vacay', description: 'Vacation day planner', type: 'global', icon: 'CalendarDays',enabled: 1, sort_order: 10 }, | |
| { id: 'atlas', name: 'Atlas', description: 'Visited countries map', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 }, | |
| { id: 'mcp', name: 'MCP', description: 'AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 }, | |
| { id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from shared Naver Maps lists', type: 'trip', icon: 'Link2', enabled: 0, sort_order: 13 }, | |
| { id: 'collab', name: 'Collab', description: 'Notes, polls, live chat', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 }, | |
| ]; | |
| const DEFAULT_PHOTO_PROVIDERS = [ | |
| { id: 'immich', name: 'Immich', enabled: 1 }, | |
| { id: 'synologyphotos', name: 'Synology Photos', enabled: 1 }, | |
| ]; | |
| function seedDefaults(db: Database.Database): void { | |
| const insertCat = db.prepare('INSERT OR IGNORE INTO categories (name, color, icon) VALUES (?, ?, ?)'); | |
| for (const cat of DEFAULT_CATEGORIES) insertCat.run(cat.name, cat.color, cat.icon); | |
| const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); | |
| for (const a of DEFAULT_ADDONS) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order); | |
| try { | |
| const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)'); | |
| for (const p of DEFAULT_PHOTO_PROVIDERS) insertProvider.run(p.id, p.name, p.id, 'Image', p.enabled, 0); | |
| } catch { /* table may not exist in very old schemas */ } | |
| } | |
| /** | |
| * Creates a fresh in-memory SQLite database with the full schema and migrations applied. | |
| * Default categories and addons are seeded. No users are created. | |
| */ | |
| export function createTestDb(): Database.Database { | |
| const db = new Database(':memory:'); | |
| db.exec('PRAGMA journal_mode = WAL'); | |
| db.exec('PRAGMA busy_timeout = 5000'); | |
| db.exec('PRAGMA foreign_keys = ON'); | |
| createTables(db); | |
| runMigrations(db); | |
| seedDefaults(db); | |
| return db; | |
| } | |
| /** | |
| * Clears all user-generated data from the test DB and re-seeds defaults. | |
| * Call in beforeEach() for test isolation within a file. | |
| */ | |
| export function resetTestDb(db: Database.Database): void { | |
| db.exec('PRAGMA foreign_keys = OFF'); | |
| const existingTables = new Set( | |
| (db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[]).map(r => r.name) | |
| ); | |
| for (const table of RESET_TABLES) { | |
| if (existingTables.has(table)) { | |
| db.exec(`DELETE FROM "${table}"`); | |
| } | |
| } | |
| db.exec('PRAGMA foreign_keys = ON'); | |
| seedDefaults(db); | |
| } | |
| /** | |
| * Returns the mock factory for vi.mock('../../src/db/database', ...). | |
| * The returned object mirrors the shape of database.ts exports. | |
| * | |
| * @example | |
| * const testDb = createTestDb(); | |
| * vi.mock('../../src/db/database', () => buildDbMock(testDb)); | |
| */ | |
| export function buildDbMock(testDb: Database.Database) { | |
| return { | |
| db: testDb, | |
| closeDb: () => {}, | |
| reinitialize: () => {}, | |
| getPlaceWithTags: (placeId: number | string) => { | |
| interface PlaceRow { | |
| id: number; | |
| category_id: number | null; | |
| category_name: string | null; | |
| category_color: string | null; | |
| category_icon: string | null; | |
| [key: string]: unknown; | |
| } | |
| const place = testDb.prepare(` | |
| SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon | |
| FROM places p | |
| LEFT JOIN categories c ON p.category_id = c.id | |
| WHERE p.id = ? | |
| `).get(placeId) as PlaceRow | undefined; | |
| if (!place) return null; | |
| const tags = testDb.prepare(` | |
| SELECT t.* FROM tags t | |
| JOIN place_tags pt ON t.id = pt.tag_id | |
| WHERE pt.place_id = ? | |
| `).all(placeId); | |
| return { | |
| ...place, | |
| category: place.category_id ? { | |
| id: place.category_id, | |
| name: place.category_name, | |
| color: place.category_color, | |
| icon: place.category_icon, | |
| } : null, | |
| tags, | |
| }; | |
| }, | |
| canAccessTrip: (tripId: number | string, userId: number) => { | |
| return testDb.prepare(` | |
| SELECT t.id, t.user_id FROM trips t | |
| LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? | |
| WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) | |
| `).get(userId, tripId, userId); | |
| }, | |
| isOwner: (tripId: number | string, userId: number) => { | |
| return !!testDb.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId); | |
| }, | |
| }; | |
| } | |
| /** | |
| * Resets the Nest per-IP rate-limit buckets between tests — the buildApp() drop-in | |
| * for the legacy `loginAttempts.clear(); mfaAttempts.clear()`. | |
| * | |
| * The Nest auth path keeps its rate-limit state in a RateLimitService instance that | |
| * lives inside the AuthModule injector (shared by AuthPublicController/AuthController | |
| * for the login/mfa/forgot buckets). The same class is ALSO provided separately in | |
| * OauthModule (its own instance, distinct oauth_* buckets), so a plain | |
| * app.get(RateLimitService) is ambiguous and may hand back the wrong instance — we | |
| * resolve the auth controller and clear the limiter it actually uses. | |
| */ | |
| export function resetRateLimits(app: INestApplication): void { | |
| const ctrl = app.get(AuthPublicController, { strict: false }) as unknown as { rl: RateLimitService }; | |
| ctrl.rl.reset(); | |
| } | |
| /** Fixed config mock — use with vi.mock('../../src/config', () => TEST_CONFIG) */ | |
| export const TEST_CONFIG = { | |
| JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', | |
| ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', | |
| updateJwtSecret: () => {}, | |
| SESSION_DURATION: '24h', | |
| SESSION_DURATION_MS: 86400000, | |
| SESSION_DURATION_SECONDS: 86400, | |
| }; | |