mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
65be8db122
The domain arrived with 557 lines, a service test, and nothing else. No controller test, no e2e, and no MCP test — so the guard chain, the trip scoping on every route, the cross-trip track check and the day_edit parity were all unasserted, and the whole domain sat in the 80 catch-all where it could shed twenty points against the headroom of the domains beside it. The e2e is the one that earns its place. `@RequireAddon` is metadata and does nothing on its own, and AddonGuard is not global, so the ordering the controller's own comment calls load-bearing is exactly the kind of thing a later change removes without noticing — it had already happened once. Deleting the guard now turns three cases red, which I checked by deleting it. The MCP tools had never executed at all: the shared harness seeds the addon disabled, so `when: roadtripAddonOn` kept all five from attaching. They now run against the same checks the REST routes make, including the demo account and a member without day_edit, because the repo requires the two surfaces to move together. Two client modules that had no test get one: the overlay builder, where two decisions live that are not cosmetic — which offered road is drawn as the one you are on, and that a label hangs where its own route is unlike every other, rather than at a midpoint that can fall on a stretch two routes share — and the service marker, where the thing worth pinning is that an ordinary place gets nothing back, and that the anchor arithmetic includes the border. The domain carries its own coverage floor now instead of hiding in the catch-all. Pinned under the measured figure on purpose; the exact ratchet gets regenerated with the repo's own script after a full run. Along the way, roadtrip_vias and roadtrip_day_tracks join the reset list every other domain's tables are on. Without that a via written by one case was still there for the next one, which is how the first version of the MCP suite passed an assertion it should have failed.
325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
/**
|
|
* 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/common/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',
|
|
// Road trip (#1797). Both hang off days, so they go before it like the rest
|
|
// of the day content; a new domain's tables belong here or its rows leak from
|
|
// one case into the next.
|
|
'roadtrip_day_tracks',
|
|
'roadtrip_vias',
|
|
// 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_invite_tokens',
|
|
'trip_members',
|
|
'trips',
|
|
// Journey
|
|
'journey_books',
|
|
'journey_share_tokens',
|
|
'journey_photos',
|
|
'journey_entries',
|
|
'journey_contributors',
|
|
'journey_trips',
|
|
'journeys',
|
|
// Vacay
|
|
'vacay_user_settings',
|
|
'vacay_shares',
|
|
'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',
|
|
'webauthn_challenges',
|
|
'webauthn_credentials',
|
|
'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 },
|
|
];
|
|
|
|
/**
|
|
* Flip an addon in the test DB.
|
|
*
|
|
* The MCP `when:` gates used to be mocked at the module boundary
|
|
* (`vi.mock('addons.bridge')`), which worked only because the gate closed over
|
|
* a module-level singleton. They read their controller's injected AddonsService
|
|
* now, so a test toggles the same row the admin panel writes — which also means
|
|
* these cases exercise the real read instead of a stub of it.
|
|
*
|
|
* `resetTestDb` deliberately leaves the addons table alone, so a toggle
|
|
* survives into the next case: set what a case needs rather than assuming the
|
|
* seeded default.
|
|
*/
|
|
export function setAddonEnabled(db: Database.Database, addonId: string, enabled: boolean): void {
|
|
db.prepare(
|
|
'INSERT INTO addons (id, name, type, enabled) VALUES (?, ?, ?, ?) ' +
|
|
'ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled',
|
|
).run(addonId, addonId, 'global', enabled ? 1 : 0);
|
|
}
|
|
|
|
/** Collab's sub-feature flags are opt-out app_settings, not addon rows. */
|
|
export function setCollabFeature(
|
|
db: Database.Database,
|
|
feature: 'chat' | 'notes' | 'polls' | 'whatsnext',
|
|
enabled: boolean,
|
|
): void {
|
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(
|
|
`collab_${feature}_enabled`,
|
|
enabled ? 'true' : 'false',
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Byte-for-byte the statement in src/db/database.ts.
|
|
*
|
|
* Exported because the same query is copied into ~90 test files, and every one
|
|
* of those copies had dropped `t.currency` — so the budget domain, which reads
|
|
* exactly that column off the access row, was only ever exercising its 'EUR'
|
|
* fallback. Import this instead of retyping it.
|
|
*/
|
|
export const CAN_ACCESS_TRIP_SQL = `
|
|
SELECT t.id, t.user_id, t.currency 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)
|
|
`;
|
|
|
|
/**
|
|
* 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(CAN_ACCESS_TRIP_SQL).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,
|
|
SESSION_DURATION_REMEMBER: '30d',
|
|
SESSION_DURATION_REMEMBER_MS: 2592000000,
|
|
SESSION_DURATION_REMEMBER_SECONDS: 2592000,
|
|
};
|