From 915ab84faf954f583b6005bece1ac8eced89b04b Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 24 Sep 2026 02:04:32 +0200 Subject: [PATCH] refactor(demo,backup): seed and reset through DemoRepository, live-connection backup statements through MaintenanceRepository, isDemoUserId through an injected DemoService --- server/eslint.config.mjs | 10 +- server/src/db/database.ts | 14 +- server/src/db/orm.ts | 29 +- server/src/db/repositories/DemoRepository.ts | 288 ++++++++++++++++++ .../db/repositories/MaintenanceRepository.ts | 70 +++++ server/src/demo/demo-reset.ts | 128 +++++--- server/src/demo/demo-seed.ts | 247 +++++++-------- server/src/nest/app.module.ts | 2 + server/src/nest/backup/backup.impl.ts | 75 +++-- server/src/nest/budget/budget.mcp.ts | 7 +- server/src/nest/categories/categories.mcp.ts | 22 +- server/src/nest/common/demo-write.ts | 17 +- server/src/nest/common/demo.module.ts | 22 ++ server/src/nest/common/demo.service.ts | 48 +++ server/src/nest/feeds/feeds.mcp.ts | 7 +- .../contributions/plugin-mcp-tools.service.ts | 7 +- .../src/nest/trip-invite/trip-invite.mcp.ts | 7 +- server/tests/helpers/mcp-test-controllers.ts | 13 +- .../demo-seed-request-context.test.ts | 4 +- .../db/repositories/DemoRepository.test.ts | 154 ++++++++++ .../MaintenanceRepository.test.ts | 88 ++++++ .../tests/unit/demo/demo-reset-path.test.ts | 35 ++- server/tests/unit/demo/demo-seed.test.ts | 81 ++++- .../tests/unit/mcp/tools-categories.test.ts | 5 +- server/tests/unit/mcp/tools-prompts.test.ts | 2 + server/tests/unit/nest/backup.impl.test.ts | 62 +++- .../nest/demo-service-survivor-sites.test.ts | 56 ++++ server/tests/unit/nest/demo.service.test.ts | 57 ++++ .../unit/plugins/plugin-mcp-tools.test.ts | 10 +- 29 files changed, 1285 insertions(+), 282 deletions(-) create mode 100644 server/src/db/repositories/DemoRepository.ts create mode 100644 server/src/db/repositories/MaintenanceRepository.ts create mode 100644 server/src/nest/common/demo.module.ts create mode 100644 server/src/nest/common/demo.service.ts create mode 100644 server/tests/unit/db/repositories/DemoRepository.test.ts create mode 100644 server/tests/unit/db/repositories/MaintenanceRepository.test.ts create mode 100644 server/tests/unit/nest/demo-service-survivor-sites.test.ts create mode 100644 server/tests/unit/nest/demo.service.test.ts diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index 0a69623b4..61c960345 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -176,7 +176,15 @@ export default tseslint.config( // The plugin sandbox opens its own per-plugin database files, deliberately // outside the app's ORM and connection. 'src/nest/plugins/host/plugin-data.service.ts', - // Backup/restore operates on database FILES, not on rows. + // R1 (Plan 3i Task 3): backup.impl.ts's live-connection statements + // (a WAL checkpoint, a VACUUM INTO snapshot) convert onto + // MaintenanceRepository — this allow-list entry now covers ONLY the + // untrusted-upload-file probe in restoreFromZip (an integrity check + + // a required-table check run against a SEPARATE, freshly-opened, + // read-only connection over an uploaded file, never the app's own + // connection or ORM). A permanent, intentional exception — see + // task-3-report.md / the plan's R1 ruling — not debt for a later plan + // to close. 'src/nest/backup/backup.impl.ts', // Demo-mode seeding, and the legacy schema/migration/seed scripts the test // suite still builds its throwaway databases from (guarded by diff --git a/server/src/db/database.ts b/server/src/db/database.ts index 0a751b81c..a476ca619 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -81,11 +81,21 @@ const db = new Proxy({} as Database.Database, { * resolve once the category seeder has run. Called from the schema bootstrap in * `db/orm.ts`, immediately after the seeders. */ -function runDemoSeed(): void { +// Plan 3i Task 3: `seedDemoData` converted onto `DemoRepository` (an +// EntityManager-backed repository), so it is `async` now and no longer takes +// the raw `db` handle — it resolves its EntityManager itself via +// `RequestContext.getEntityManager()`, which `runSchemaBootstrap`'s existing +// `withRequestContext(orm, () => runDemoSeed())` wrap (`db/orm.ts:59`) +// already populates by the time this runs. `runDemoSeed` awaits it now too: +// previously both were fully synchronous, so the try/catch below already +// caught everything before this function returned; without the `await` a +// rejected promise from `seedDemoData` would become an unhandled rejection +// instead of the `[Demo] Seed error:` log line this catch has always produced. +async function runDemoSeed(): Promise { if (!readEnv().demo.enabled) return; try { const { seedDemoData } = require('../demo/demo-seed'); - seedDemoData(_db); + await seedDemoData(); } catch (err: unknown) { console.error('[Demo] Seed error:', err instanceof Error ? err.message : err); } diff --git a/server/src/db/orm.ts b/server/src/db/orm.ts index 436b613ab..f95812e46 100644 --- a/server/src/db/orm.ts +++ b/server/src/db/orm.ts @@ -43,20 +43,21 @@ export async function runSchemaBootstrap(orm: AnyOrm): Promise { // Demo data depends on the seeded categories (hard-coded ids under an FK), so // it can only run once the seeders above have. // - // Plan 3c Task 0b (R9): `runDemoSeed` itself stays raw better-sqlite3 today - // (`demo-seed.ts`'s own docstring: "the domain phase that gives demo - // seeding a repository read must wrap it in withRequestContext then") — but - // wrapping the call HERE, the one place `runSchemaBootstrap` already has - // `orm` in scope, covers BOTH of its real call sites at once - // (`bootstrap.ts`'s initial boot and `attachOrm`'s restore hook below, both - // of which only ever reach `runDemoSeed` through this function) with no - // second wrapper to keep in sync. A synchronous `fn` stays synchronous - // through `withRequestContext`/`RequestContext.create`, so this changes - // nothing about what `runDemoSeed` does today — only that a repository read - // added to it later already has a valid context, so Plan 3i's demo - // conversion never meets a second C1 (task-6-review-parity.md's boot-sweep - // finding). - withRequestContext(orm, () => runDemoSeed()); + // Plan 3c Task 0b (R9): wrapping the call HERE, the one place + // `runSchemaBootstrap` already has `orm` in scope, covers BOTH of + // `runDemoSeed`'s real call sites at once (`bootstrap.ts`'s initial boot + // and `attachOrm`'s restore hook below, both of which only ever reach + // `runDemoSeed` through this function) with no second wrapper to keep in + // sync. + // + // Plan 3i Task 3: `runDemoSeed` (and `seedDemoData` beneath it, converted + // onto `DemoRepository`) is `async` now — this call is awaited so a + // rejection surfaces here rather than becoming an unhandled promise + // rejection, and so the seed genuinely completes before + // `runSchemaBootstrap` (and therefore `buildApp()`/this hook's own + // restore path) returns to its caller, the same completion guarantee the + // fully-synchronous version had by construction. + await withRequestContext(orm, () => runDemoSeed()); } /** diff --git a/server/src/db/repositories/DemoRepository.ts b/server/src/db/repositories/DemoRepository.ts new file mode 100644 index 000000000..6190a99ff --- /dev/null +++ b/server/src/db/repositories/DemoRepository.ts @@ -0,0 +1,288 @@ +import type { EntityManager } from '@mikro-orm/core'; +import { Users } from '../entities/Users.entity'; +import type { NewAdminUserRow } from './Users.repository'; +import { AppSettings } from '../entities/AppSettings.entity'; +import { Trips } from '../entities/Trips.entity'; + +/** DMR1's projection (`resetDemoUser`'s pre-close credential read). */ +export interface DemoAdminCredentialsRow { + password_hash: string; + maps_api_key: string | null; + openweather_api_key: string | null; + unsplash_api_key: string | null; + avatar: string | null; +} + +/** DMR2's projection (`resetDemoUser`'s pre-close instance-API-key read) — a row per matched key, `value` preserved even when it is SQL NULL (unlike `AppSettingsRepository#getValues`, which drops a NULL-valued row from its Map; DMR6 re-writes exactly what DMR2 read, so a NULL must round-trip, not disappear). */ +export interface DemoInstanceKeyRow { + key: string; + value: string | null; +} + +/** + * DMS9's 13-column `places` insert tuple — kept as a tuple, not an object, + * so `demo-seed.ts`'s three ~30-row example-trip data arrays (Tokyo/Kyoto, + * Barcelona, New York) pass through unchanged: `trip_id, name, lat, lng, + * address, category_id, place_time, duration_minutes, notes, image_url, + * google_place_id, website, phone`. `category_id` is HARD-CODED 1-9 in the + * caller's data, coupled to `CategorySeeder`'s row order — a documented + * fragility (`db/orm.ts`), not something this repository fixes. + */ +export type NewDemoPlaceRow = readonly [ + tripId: number, + name: string, + lat: number, + lng: number, + address: string, + categoryId: number, + placeTime: string, + durationMinutes: number, + notes: string, + imageUrl: string | null, + googlePlaceId: string | null, + website: string | null, + phone: string | null, +]; + +/** + * Plan 3i Task 3 — `demo-seed.ts`'s 14 distinct statement texts (17 call + * sites) and `demo-reset.ts`'s reset-side statements (per R3's SAFE spike + * verdict, `task-0-report.md`), named ahead of this plan by the program + * brief's own rule 4. + * + * Not a `TrekRepository` — it backs no table and no entity of its + * own (it reaches `users`/`app_settings`/`trips` and, for the bulk + * example-trip seed, `days`/`places`/`day_assignments`/`packing_items`/ + * `budget_items`/`reservations`/`day_notes`/`trip_members`, none of which it + * "owns" the way `Users.repository.ts` owns `users`). Where an existing + * repository already has the exact shape (`UsersRepository#findByEmailExact`/ + * `#insertAdminCreatedUser`, `AppSettingsRepository#upsertOrReplace`) this + * repository composes it via `this.em.getRepository(Entity)` rather than + * duplicating the statement — `Users.repository.ts`/`AppSettings.repository.ts` + * are Plan 3i Task 1's territory this task and never edits. The remaining + * shapes (a `trips` count/list, the shared `trip_members` "insert or ignore" + * membership row, and the seven bulk INSERT loops for the three example + * trips) go through `connection.execute()` — rule 4's last-tier escape + * hatch, reserved for exactly this repository — because a prepared + * statement run in a loop over hand-written seed data is the natural shape + * here, and forcing ~90 rows of literal data through per-entity `insert()` + * calls (importing eight more entity classes this task does not own) buys + * nothing a raw, parameterised statement doesn't already give. + * + * Constructed directly (`new DemoRepository(em)`), never through Nest DI: + * its only two callers — `demo/demo-seed.ts` and `demo/demo-reset.ts` — are + * plain function modules, not Nest providers (the first Nest-DI-*shaped* + * home either has ever had, not a Nest-DI-*resolved* one), and obtain the + * `EntityManager` via `RequestContext.getEntityManager()` (the same static + * accessor MikroORM's own per-request middleware and + * `CronRegistrarService`'s `wrappedTick` populate) rather than constructor + * injection. No module registers this class as a provider. + */ +export class DemoRepository { + constructor(private readonly em: EntityManager) {} + + /** See `MaintenanceRepository`'s twin — throws outside a request context. */ + private validateRequestContext(): void { + this.em.getContext(); + } + + /** `this.em.getConnection().execute(sql, params, 'run')`, validated first — the one place every raw statement below funnels through. */ + private async run(sql: string, params: readonly unknown[]): Promise<{ insertId: number }> { + this.validateRequestContext(); + const result = await this.em.getConnection().execute<{ id: number }>(sql, [...params], 'run'); + return { insertId: Number(result.insertId) }; + } + + // --------------------------------------------------------------------- + // demo-seed.ts + // --------------------------------------------------------------------- + + /** + * DMS1 — `SELECT id FROM users WHERE email = ?`. Two call sites in + * `seedDemoData` (the admin lookup, the demo-user lookup) share this one + * method. Composes `UsersRepository#findByEmailExact` (case-sensitive, no + * guest filter, full row) rather than a new statement — exact shape match. + */ + async findUserByEmail(email: string): Promise<{ id: number } | null> { + const row = await this.em.getRepository(Users).findByEmailExact(email); + return row ? { id: row.id } : null; + } + + /** + * DMS2 — `INSERT INTO users (username, email, password_hash, role) VALUES + * (?, ?, ?, ?)`. Two call sites in `seedDemoData` (admin creation, demo-user + * creation) share this one method. Composes + * `UsersRepository#insertAdminCreatedUser` — byte-identical legacy text + * (AD4's own statement), which already leaves every other column + * (`first_seen_version`, `login_count`, …) to the entity's default, the + * same as the legacy INSERT's omitted columns fell back to the schema's + * `DEFAULT`. + */ + async createUser(row: NewAdminUserRow): Promise { + return await this.em.getRepository(Users).insertAdminCreatedUser(row); + } + + /** DMS3 — `INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')`. Composes `AppSettingsRepository#upsertOrReplace`, the same dialect the legacy statement used. */ + async setAllowRegistrationFalse(): Promise { + await this.em.getRepository(AppSettings).upsertOrReplace('allow_registration', 'false'); + } + + /** DMS4 — `SELECT COUNT(*) as count FROM trips WHERE user_id = ?` (the "already seeded" idempotency check). */ + async countTripsByUser(userId: number): Promise { + this.validateRequestContext(); + return await this.em.getRepository(Trips).count({ user_id: userId }); + } + + /** DMS5 — `SELECT id FROM trips WHERE user_id = ?` (`ensureDemoMembership`'s trip list). */ + async listTripIdsByUser(userId: number): Promise { + this.validateRequestContext(); + const rows = await this.em.getRepository(Trips).find({ user_id: userId }, { fields: ['id'] }); + return rows.map((r) => r.id); + } + + /** + * DMS6 — `INSERT OR IGNORE INTO trip_members (trip_id, user_id, + * invited_by) VALUES (?, ?, ?)`. Shared between `ensureDemoMembership` + * (looped over an admin's existing trips) and `seedExampleTrips` (one call + * per newly seeded trip) — one repository method, called from both, per + * the legacy code's own dup-text shape. + */ + async addTripMember(tripId: number, userId: number, invitedBy: number): Promise { + await this.run('INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)', [tripId, userId, invitedBy]); + } + + /** DMS7 — `INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)`. Returns the generated trip id. */ + async insertTrip(userId: number, title: string, description: string, startDate: string, endDate: string, currency: string): Promise { + const { insertId } = await this.run( + 'INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)', + [userId, title, description, startDate, endDate, currency], + ); + return insertId; + } + + /** DMS8 — `INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)`. Returns the generated day id. */ + async insertDay(tripId: number, dayNumber: number, date: string): Promise { + const { insertId } = await this.run('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)', [tripId, dayNumber, date]); + return insertId; + } + + /** DMS9 — the 13-column `places` insert (see {@link NewDemoPlaceRow}). Returns the generated place id. */ + async insertPlace(row: NewDemoPlaceRow): Promise { + const { insertId } = await this.run( + 'INSERT INTO places (trip_id, name, lat, lng, address, category_id, place_time, duration_minutes, notes, image_url, google_place_id, website, phone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + row, + ); + return insertId; + } + + /** DMS10 — `INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, ?)`. */ + async insertDayAssignment(dayId: number, placeId: number, orderIndex: number): Promise { + await this.run('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, ?)', [dayId, placeId, orderIndex]); + } + + /** DMS11 — `INSERT INTO packing_items (trip_id, name, checked, category, sort_order, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`. */ + async insertPackingItem(tripId: number, name: string, checked: number, category: string, sortOrder: number): Promise { + await this.run('INSERT INTO packing_items (trip_id, name, checked, category, sort_order, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)', [tripId, name, checked, category, sortOrder]); + } + + /** DMS12 — `INSERT INTO budget_items (trip_id, category, name, total_price, persons, note) VALUES (?, ?, ?, ?, ?, ?)`. */ + async insertBudgetItem(tripId: number, category: string, name: string, totalPrice: number, persons: number, note: string | null): Promise { + await this.run('INSERT INTO budget_items (trip_id, category, name, total_price, persons, note) VALUES (?, ?, ?, ?, ?, ?)', [tripId, category, name, totalPrice, persons, note]); + } + + /** + * DMS13 — `INSERT INTO reservations (trip_id, day_id, title, + * reservation_time, confirmation_number, status, type, location) VALUES + * (?, ?, ?, ?, ?, ?, ?, ?)`. `reservationTime` carries the full date + * (`'2026-04-15T15:00'`), never a bare clock time — pinned parity, #1934. + */ + async insertReservation( + tripId: number, + dayId: number, + title: string, + reservationTime: string, + confirmationNumber: string, + status: string, + type: string, + location: string, + ): Promise { + await this.run( + 'INSERT INTO reservations (trip_id, day_id, title, reservation_time, confirmation_number, status, type, location) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [tripId, dayId, title, reservationTime, confirmationNumber, status, type, location], + ); + } + + /** DMS14 — `INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)`. */ + async insertDayNote(dayId: number, tripId: number, text: string, time: string, icon: string, sortOrder: number): Promise { + await this.run('INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)', [dayId, tripId, text, time, icon, sortOrder]); + } + + // --------------------------------------------------------------------- + // demo-reset.ts + // --------------------------------------------------------------------- + + /** + * DMR1 — `SELECT password_hash, maps_api_key, openweather_api_key, + * unsplash_api_key, avatar FROM users WHERE email = ?` (the pre-close + * credential read). Generic inherited `findOne` with a narrower `fields` + * projection — no new method on `Users.repository.ts` (Task 1's + * territory), same reasoning as `findUserByEmail` above. + */ + async getAdminCredentials(email: string): Promise { + const row = await this.em + .getRepository(Users) + .findOne({ email }, { fields: ['password_hash', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'avatar'] }); + return row + ? { + password_hash: row.password_hash, + maps_api_key: row.maps_api_key ?? null, + openweather_api_key: row.openweather_api_key ?? null, + unsplash_api_key: row.unsplash_api_key ?? null, + avatar: row.avatar ?? null, + } + : null; + } + + /** + * DMR2 — `SELECT key, value FROM app_settings WHERE key IN + * ('maps_api_key', 'unsplash_api_key')` (the pre-close instance-API-key + * read). Generic inherited `find` — deliberately NOT + * `AppSettingsRepository#getValues`, which drops a NULL-valued row; this + * caller's own DMR6 counterpart re-writes exactly what it read. + */ + async getInstanceApiKeys(): Promise { + const rows = await this.em.getRepository(AppSettings).find({ key: { $in: ['maps_api_key', 'unsplash_api_key'] } }); + return rows.map((r) => ({ key: r.key ?? '', value: r.value ?? null })); + } + + /** + * DMR5 — `UPDATE users SET password_hash = ?, maps_api_key = ?, + * openweather_api_key = ?, unsplash_api_key = ?, avatar = ? WHERE email = + * ?` (the post-reopen credential restore). Generic inherited + * `nativeUpdate`, writing exactly these five columns — no `updated_at` + * bump, matching the legacy statement (unlike `UsersRepository`'s own + * `updateApiKeys`/`updateMapsKey`, which do touch it for a different + * caller). + */ + async restoreAdminCredentials(email: string, patch: DemoAdminCredentialsRow): Promise { + this.validateRequestContext(); + await this.em.getRepository(Users).nativeUpdate({ email }, { ...patch }); + } + + /** + * DMR6 — `INSERT INTO app_settings (key, value) VALUES (?, ?) ON + * CONFLICT(key) DO UPDATE SET value = excluded.value`, looped over the + * rows DMR2 read (the post-reopen instance-API-key restore). Generic + * inherited `upsert` (the SAME statement `AppSettingsRepository#setValue` + * issues) rather than that method directly — `value` may be SQL NULL here + * (preserved by DMR2 above), and `setValue`'s own signature types `value` + * as a non-nullable `string` for its own callers. + */ + async restoreInstanceApiKeys(rows: readonly DemoInstanceKeyRow[]): Promise { + this.validateRequestContext(); + const repo = this.em.getRepository(AppSettings); + for (const row of rows) { + await repo.upsert({ key: row.key, value: row.value }, { onConflictFields: ['key'], onConflictAction: 'merge' }); + } + } +} diff --git a/server/src/db/repositories/MaintenanceRepository.ts b/server/src/db/repositories/MaintenanceRepository.ts new file mode 100644 index 000000000..e20eb34be --- /dev/null +++ b/server/src/db/repositories/MaintenanceRepository.ts @@ -0,0 +1,70 @@ +import type { EntityManager } from '@mikro-orm/core'; + +/** + * Plan 3i Task 3 — R1's raw-connection exception, named ahead of this plan by + * the program brief's own rule 4 ("connection.execute() (only in + * MaintenanceRepository/DemoRepository, with a comment)"). + * + * Not a `TrekRepository` — it backs no table and no entity of its + * own. It exists purely as the one place two whole-database-file statements + * (a `PRAGMA` and a `VACUUM INTO`) run against the ORM's own bound driver + * connection instead of scattering raw `better-sqlite3` calls across + * `backup.impl.ts`/`demo-reset.ts`. `find/findOne → create/assign/flush → + * nativeUpdate/nativeDelete → em.upsert → qb() → em.getKysely()` all fail + * for these two statements — neither is expressible against an entity at + * all — so `connection.execute()` (the last tier in the program's query-API + * order) is the correct tool, not a shortcut around it. + * + * Constructed directly (`new MaintenanceRepository(em)`), never through Nest + * DI: its only two callers — `backup.impl.ts#createBackup` and + * `demo/demo-reset.ts` (`resetDemoUser`/`saveBaseline`) — are plain function + * modules, not Nest providers, and obtain the `EntityManager` via + * `RequestContext.getEntityManager()` (the same static accessor MikroORM's + * own per-request middleware and `CronRegistrarService`'s `wrappedTick` + * populate) rather than constructor injection. No module registers this + * class as a provider — nothing today asks Nest's DI container to resolve + * it. + */ +export class MaintenanceRepository { + constructor(private readonly em: EntityManager) {} + + /** + * MikroORM's own validating `getContext()` — throws + * `ValidationError.cannotUseGlobalContext()` outside a request context (the + * production default), the same fail-closed ratchet `TrekRepository` + * applies to every entity-bound repository. Called first by both methods + * below, since raw `connection.execute()` has no such guard of its own. + */ + private validateRequestContext(): void { + this.em.getContext(); + } + + /** + * `PRAGMA wal_checkpoint(TRUNCATE)` — rendered text pinned, identical to + * the legacy `db.exec('PRAGMA wal_checkpoint(TRUNCATE)')` it replaces at + * three call sites: `backup.impl.ts#createBackup` (BK1), `demo-reset.ts`'s + * `resetDemoUser` (DMR3) and `saveBaseline` (DMR3's dup) — one shared + * method, three callers, not three near-duplicate raw statements. Flushes + * the WAL into the main db file before a snapshot/copy; every call site + * treats it as best-effort (wrapped in a swallowing try/catch by the + * caller), matching the legacy shape exactly. + */ + async walCheckpoint(): Promise { + this.validateRequestContext(); + await this.em.getConnection().execute('PRAGMA wal_checkpoint(TRUNCATE)'); + } + + /** + * `` VACUUM INTO '' `` — rendered text pinned, identical to the + * legacy `` db.exec(`VACUUM INTO '${dbSnap.replaceAll("'", "''")}'`) `` + * (BK2, `backup.impl.ts#createBackup`): single quotes in `path` are + * escaped by doubling, the same escaping the legacy statement used — a + * consistent point-in-time snapshot under concurrent writers, with no + * MikroORM/Kysely equivalent. + */ + async vacuumInto(path: string): Promise { + this.validateRequestContext(); + const escaped = path.replaceAll("'", "''"); + await this.em.getConnection().execute(`VACUUM INTO '${escaped}'`); + } +} diff --git a/server/src/demo/demo-reset.ts b/server/src/demo/demo-reset.ts index 881bd2edd..aa097f903 100644 --- a/server/src/demo/demo-reset.ts +++ b/server/src/demo/demo-reset.ts @@ -1,6 +1,9 @@ import fs from 'fs'; import path from 'path'; +import { RequestContext, type EntityManager } from '@mikro-orm/core'; import { readEnv } from '../app-config'; +import { DemoRepository, type DemoAdminCredentialsRow, type DemoInstanceKeyRow } from '../db/repositories/DemoRepository'; +import { MaintenanceRepository } from '../db/repositories/MaintenanceRepository'; const dataDir = path.join(__dirname, '../../data'); const baselinePath = path.join(dataDir, 'travel-baseline.db'); @@ -12,6 +15,28 @@ function liveDbPath(db: { name: string }): string { return db.name; } +/** + * The active fork's `EntityManager`, resolved via `RequestContext.getEntityManager()` + * — the same static accessor MikroORM's own per-request middleware and + * `CronRegistrarService`'s `wrappedTick` populate. `demo-seed.ts` and this + * file are plain function modules, not Nest providers, so neither can take + * an `EntityManager` by constructor injection the way `DatabaseService`/ + * `CronRegistrarService` do — this is their equivalent choke point. Throws + * its own clear error — never MikroORM's generic "global EntityManager" + * wording — matching `CronRegistrarService.runOnBoot`'s own fail-closed + * shape: a direct call to `resetDemoUser`/`saveBaseline`/`seedDemoData` + * outside a request context fails loudly and distinctly. + */ +export function requireEntityManager(): EntityManager { + const em = RequestContext.getEntityManager(); + if (!em) { + throw new Error( + "demo/demo-reset.ts: no EntityManager available — must run inside a request context (CronRegistrarService wraps demo-reset.job.ts's tick; runSchemaBootstrap wraps seedDemoData; a direct call — including a test — needs its own withRequestContext)", + ); + } + return em; +} + async function resetDemoUser(): Promise { if (!fs.existsSync(baselinePath)) { console.log('[Demo Reset] No baseline found, skipping. Admin must save baseline first.'); @@ -25,15 +50,20 @@ async function resetDemoUser(): Promise { return; } + // Plan 3i Task 3 / R3 (SAFE spike verdict, task-0-report.md §7): the + // pre-close reads (DMR1/DMR2) run through THIS fork — the one + // `CronRegistrarService.register()`'s `wrappedTick` already opened for the + // whole tick — since no swap has happened yet at this point. + const preSwapEm = requireEntityManager(); + const demo = new DemoRepository(preSwapEm); + const maintenance = new MaintenanceRepository(preSwapEm); + // Save admin's current credentials and API keys (these should survive the reset) // NOTE: different default than demo-seed (admin@trek.app) — pinned legacy quirk. const adminEmail = readEnv().demo.adminEmailRaw || 'admin@nomad.app'; - interface AdminData { password_hash: string; maps_api_key: string | null; openweather_api_key: string | null; unsplash_api_key: string | null; avatar: string | null; } - let adminData: AdminData | undefined = undefined; + let adminData: DemoAdminCredentialsRow | undefined = undefined; try { - adminData = db.prepare( - 'SELECT password_hash, maps_api_key, openweather_api_key, unsplash_api_key, avatar FROM users WHERE email = ?' - ).get(adminEmail) as AdminData | undefined; + adminData = (await demo.getAdminCredentials(adminEmail)) ?? undefined; } catch (e: unknown) { console.error('[Demo Reset] Failed to read admin data:', e instanceof Error ? e.message : e); } @@ -41,18 +71,20 @@ async function resetDemoUser(): Promise { // The Places/Unsplash keys the searches actually use live in app_settings // since #1939, so they have to survive the restore alongside the columns — // otherwise the demo instance loses map search on every reset. - interface InstanceKeyRow { key: string; value: string | null } - let instanceKeys: InstanceKeyRow[] = []; + let instanceKeys: DemoInstanceKeyRow[] = []; try { - instanceKeys = db.prepare( - "SELECT key, value FROM app_settings WHERE key IN ('maps_api_key', 'unsplash_api_key')" - ).all() as InstanceKeyRow[]; + instanceKeys = await demo.getInstanceApiKeys(); } catch (e: unknown) { console.error('[Demo Reset] Failed to read instance API keys:', e instanceof Error ? e.message : e); } - // Flush WAL to main DB file - try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {} + // Flush WAL to main DB file. DMR3 — raw-by-nature exactly like backup's + // BK1, converted onto the SAME shared `MaintenanceRepository.walCheckpoint()` + // backup's own createBackup uses — it runs BEFORE the close/reopen below, + // so it never waits on R3's spike at all. + try { + await maintenance.walCheckpoint(); + } catch (e) {} // Close DB connection closeDb(); @@ -72,42 +104,43 @@ async function resetDemoUser(): Promise { // Reinitialize DB connection with restored baseline await reinitialize(); - // Restore admin's latest credentials (in case admin changed password/API keys after baseline was saved) - if (adminData) { - try { - const { db: freshDb } = require('../db/database'); - freshDb.prepare( - 'UPDATE users SET password_hash = ?, maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, avatar = ? WHERE email = ?' - ).run( - adminData.password_hash, - adminData.maps_api_key, - adminData.openweather_api_key, - adminData.unsplash_api_key, - adminData.avatar, - adminEmail - ); - } catch (e: unknown) { - console.error('[Demo Reset] Failed to restore admin credentials:', e instanceof Error ? e.message : e); - } - } + // R3's ruling, applied regardless of the spike's specific finding: the + // post-reopen writes (DMR5/DMR6) run inside a FRESH request context, + // opened AFTER reinitialize() resolves — never the pre-swap fork above, + // even though the spike found the pre-swap fork would also still resolve + // correctly post-swap (task-0-report.md §7 — "appears to work in the + // spike's specific test shape" is not the same guarantee as "correct by + // construction"). `RequestContext.create` forks a genuinely NEW + // EntityManager off `preSwapEm` — a different object from the one the + // pre-close reads used — while resolving the SAME shared Connection/Driver + // singleton either way (the spike's own mechanism finding: a fork never + // forks the Connection, only the EntityManager). + await RequestContext.create(preSwapEm, async () => { + const freshEm = requireEntityManager(); + const demoFresh = new DemoRepository(freshEm); - if (instanceKeys.length) { - try { - const { db: freshDb } = require('../db/database'); - const upsert = freshDb.prepare( - `INSERT INTO app_settings (key, value) VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value` - ); - for (const row of instanceKeys) upsert.run(row.key, row.value); - } catch (e: unknown) { - console.error('[Demo Reset] Failed to restore instance API keys:', e instanceof Error ? e.message : e); + // Restore admin's latest credentials (in case admin changed password/API keys after baseline was saved) + if (adminData) { + try { + await demoFresh.restoreAdminCredentials(adminEmail, adminData); + } catch (e: unknown) { + console.error('[Demo Reset] Failed to restore admin credentials:', e instanceof Error ? e.message : e); + } } - } + + if (instanceKeys.length) { + try { + await demoFresh.restoreInstanceApiKeys(instanceKeys); + } catch (e: unknown) { + console.error('[Demo Reset] Failed to restore instance API keys:', e instanceof Error ? e.message : e); + } + } + }); console.log('[Demo Reset] Database restored from baseline'); } -function saveBaseline(): void { +async function saveBaseline(): Promise { const { db } = require('../db/database'); const dbPath = liveDbPath(db); if (dbPath === ':memory:') { @@ -115,8 +148,15 @@ function saveBaseline(): void { return; } - // Flush WAL so baseline file is self-contained - try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {} + const em = requireEntityManager(); + const maintenance = new MaintenanceRepository(em); + + // Flush WAL so baseline file is self-contained. DMR3's dup — the SAME + // shared `MaintenanceRepository.walCheckpoint()` `resetDemoUser`/backup's + // `createBackup` use, not a second near-duplicate raw statement. + try { + await maintenance.walCheckpoint(); + } catch (e) {} fs.copyFileSync(dbPath, baselinePath); console.log('[Demo] Baseline saved'); diff --git a/server/src/demo/demo-seed.ts b/server/src/demo/demo-seed.ts index dad7d4365..ab5b70a4f 100644 --- a/server/src/demo/demo-seed.ts +++ b/server/src/demo/demo-seed.ts @@ -1,26 +1,27 @@ import bcrypt from 'bcryptjs'; -import Database from 'better-sqlite3'; import { readEnv } from '../app-config'; import { DEMO_PASS } from '../nest/common/demo'; +import { DemoRepository, type NewDemoPlaceRow } from '../db/repositories/DemoRepository'; // Static like in demo-reset.job.ts: the module top is inert, everything that // touches the database happens inside the functions. -import { saveBaseline, hasBaseline } from './demo-reset'; +import { saveBaseline, hasBaseline, requireEntityManager } from './demo-reset'; // D6 (task-2-review.md's controller ruling / non-HTTP caller table): this runs // from `runSchemaBootstrap` before `app.init()`, outside any request context, but -// it is all raw `better-sqlite3` through the `db` param passed in — no -// InjectRepository/global-EM read — so there is nothing to wrap yet. The domain -// phase that gives demo seeding a repository read must wrap it in -// withRequestContext then. -function seedDemoData(db: Database.Database): { adminId: number; demoId: number } { +// `runSchemaBootstrap` already wraps the whole call in `withRequestContext` +// (`db/orm.ts:59`) — `requireEntityManager()` below resolves that same fork via +// `RequestContext.getEntityManager()`, the static accessor the wrap populates. +async function seedDemoData(): Promise<{ adminId: number; demoId: number }> { + const demo = new DemoRepository(requireEntityManager()); const ADMIN_USER = readEnv().demo.adminUser; const ADMIN_EMAIL = readEnv().demo.adminEmailRaw || 'admin@trek.app'; const ADMIN_PASS = readEnv().demo.adminPass; const DEMO_EMAIL = 'demo@trek.app'; // Create admin user if not exists - let admin = db.prepare('SELECT id FROM users WHERE email = ?').get(ADMIN_EMAIL) as { id: number } | undefined; - if (!admin) { + const existingAdmin = await demo.findUserByEmail(ADMIN_EMAIL); + let adminId: number; + if (!existingAdmin) { if (!readEnv().demo.adminPassSet) { // The default is published in the docs and in this file, so an operator who // never set DEMO_ADMIN_PASS is handing out an admin account. Say so loudly; @@ -31,78 +32,64 @@ function seedDemoData(db: Database.Database): { adminId: number; demoId: number ); } const hash = bcrypt.hashSync(ADMIN_PASS, 10); - const r = db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)').run(ADMIN_USER, ADMIN_EMAIL, hash, 'admin'); - admin = { id: Number(r.lastInsertRowid) }; + adminId = await demo.createUser({ username: ADMIN_USER, email: ADMIN_EMAIL, password_hash: hash, role: 'admin' }); console.log('[Demo] Admin user created'); } else { - admin.id = Number(admin.id); + adminId = existingAdmin.id; } // Create demo user if not exists - let demo = db.prepare('SELECT id FROM users WHERE email = ?').get(DEMO_EMAIL) as { id: number } | undefined; - if (!demo) { + const existingDemo = await demo.findUserByEmail(DEMO_EMAIL); + let demoId: number; + if (!existingDemo) { const hash = bcrypt.hashSync(DEMO_PASS, 10); - const r = db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)').run('demo', DEMO_EMAIL, hash, 'user'); - demo = { id: Number(r.lastInsertRowid) }; + demoId = await demo.createUser({ username: 'demo', email: DEMO_EMAIL, password_hash: hash, role: 'user' }); console.log('[Demo] Demo user created'); } else { - demo.id = Number(demo.id); + demoId = existingDemo.id; } // Disable registration in demo mode - db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); + await demo.setAllowRegistrationFalse(); // Check if admin already has example trips - const adminTrips = db.prepare('SELECT COUNT(*) as count FROM trips WHERE user_id = ?').get(admin.id) as { count: number }; - if (adminTrips.count > 0) { + const adminTripCount = await demo.countTripsByUser(adminId); + if (adminTripCount > 0) { console.log('[Demo] Example trips already exist, ensuring demo membership'); - ensureDemoMembership(db, admin.id, demo.id); - return { adminId: admin.id, demoId: demo.id }; + await ensureDemoMembership(demo, adminId, demoId); + return { adminId, demoId }; } console.log('[Demo] Seeding example trips...'); - seedExampleTrips(db, admin.id, demo.id); + await seedExampleTrips(demo, adminId, demoId); // Auto-save baseline after first seed if (!hasBaseline()) { - saveBaseline(); + await saveBaseline(); } - return { adminId: admin.id, demoId: demo.id }; + return { adminId, demoId }; } -function ensureDemoMembership(db: Database.Database, adminId: number, demoId: number): void { - const trips = db.prepare('SELECT id FROM trips WHERE user_id = ?').all(adminId) as { id: number }[]; - const insertMember = db.prepare('INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)'); - for (const trip of trips) { - insertMember.run(trip.id, demoId, adminId); +async function ensureDemoMembership(demo: DemoRepository, adminId: number, demoId: number): Promise { + const tripIds = await demo.listTripIdsByUser(adminId); + for (const tripId of tripIds) { + await demo.addTripMember(tripId, demoId, adminId); } } -function seedExampleTrips(db: Database.Database, adminId: number, demoId: number): void { - const insertTrip = db.prepare('INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)'); - const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)'); - const insertPlace = db.prepare('INSERT INTO places (trip_id, name, lat, lng, address, category_id, place_time, duration_minutes, notes, image_url, google_place_id, website, phone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); - const insertAssignment = db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, ?)'); - const insertPacking = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, sort_order, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)'); - const insertBudget = db.prepare('INSERT INTO budget_items (trip_id, category, name, total_price, persons, note) VALUES (?, ?, ?, ?, ?, ?)'); - const insertReservation = db.prepare('INSERT INTO reservations (trip_id, day_id, title, reservation_time, confirmation_number, status, type, location) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); - const insertMember = db.prepare('INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)'); - const insertNote = db.prepare('INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)'); - +async function seedExampleTrips(demo: DemoRepository, adminId: number, demoId: number): Promise { // Category IDs: 1=Hotel, 2=Restaurant, 3=Attraction, 5=Transport, 7=Bar/Cafe, 8=Beach, 9=Nature, 6=Entertainment // --- Trip 1: Tokyo & Kyoto --- - const trip1 = insertTrip.run(adminId, 'Tokyo & Kyoto', 'Two weeks in Japan — from the neon-lit streets of Tokyo to the serene temples of Kyoto.', '2026-04-15', '2026-04-21', 'JPY'); - const t1 = Number(trip1.lastInsertRowid); + const t1 = await demo.insertTrip(adminId, 'Tokyo & Kyoto', 'Two weeks in Japan — from the neon-lit streets of Tokyo to the serene temples of Kyoto.', '2026-04-15', '2026-04-21', 'JPY'); const t1days: number[] = []; for (let i = 0; i < 7; i++) { - const d = insertDay.run(t1, i + 1, `2026-04-${15 + i}`); - t1days.push(Number(d.lastInsertRowid)); + t1days.push(await demo.insertDay(t1, i + 1, `2026-04-${15 + i}`)); } - const t1places: [number, string, number, number, string, number, string, number, string, string | null, string | null, string | null, string | null][] = [ + const t1places: NewDemoPlaceRow[] = [ [t1, 'Hotel Shinjuku Granbell', 35.6938, 139.7035, '2-14-5 Kabukicho, Shinjuku City, Tokyo 160-0021, Japan', 1, '15:00', 60, 'Check-in from 3 PM. Steps from Shinjuku Station.', null, 'ChIJdaGEJBeMGGARYgt8sLBv6lM', 'https://www.grfranbellhotel.jp/shinjuku/', '+81 3-5155-2666'], [t1, 'Senso-ji Temple', 35.7148, 139.7967, '2 Chome-3-1 Asakusa, Taito City, Tokyo 111-0032, Japan', 3, '09:00', 90, 'Oldest temple in Tokyo. Fewer tourists in the early morning.', null, 'ChIJ8T1GpMGOGGARDYGSgpoOdfg', 'https://www.senso-ji.jp/', '+81 3-3842-0181'], [t1, 'Shibuya Crossing', 35.6595, 139.7004, '2 Chome-2-1 Dogenzaka, Shibuya City, Tokyo 150-0043, Japan', 3, '18:00', 45, 'World\'s busiest pedestrian crossing. Most impressive at night.', null, 'ChIJLyzOhmyLGGARMKWbl5z6wGg', null, null], @@ -118,32 +105,33 @@ function seedExampleTrips(db: Database.Database, adminId: number, demoId: number [t1, 'Gion District', 35.0037, 135.7755, 'Gionmachi Minamigawa, Higashiyama Ward, Kyoto 605-0074, Japan', 3, '17:00', 120, 'Historic geisha district. Best chance of spotting a maiko in the evening.', null, 'ChIJ7WWWjfYJAWARGqEHAfXIzgQ', null, null], ]; - const t1pIds = t1places.map(p => Number(insertPlace.run(...p).lastInsertRowid)); + const t1pIds: number[] = []; + for (const p of t1places) t1pIds.push(await demo.insertPlace(p)); // Day 1: Hotel Check-in, Shibuya - insertAssignment.run(t1days[0], t1pIds[0], 0); - insertAssignment.run(t1days[0], t1pIds[2], 1); - insertNote.run(t1days[0], t1, 'Pick up Pocket WiFi at airport', '13:00', 'Info', 0.5); + await demo.insertDayAssignment(t1days[0], t1pIds[0], 0); + await demo.insertDayAssignment(t1days[0], t1pIds[2], 1); + await demo.insertDayNote(t1days[0], t1, 'Pick up Pocket WiFi at airport', '13:00', 'Info', 0.5); // Day 2: Tsukiji, Senso-ji, Akihabara - insertAssignment.run(t1days[1], t1pIds[3], 0); - insertAssignment.run(t1days[1], t1pIds[1], 1); - insertAssignment.run(t1days[1], t1pIds[5], 2); + await demo.insertDayAssignment(t1days[1], t1pIds[3], 0); + await demo.insertDayAssignment(t1days[1], t1pIds[1], 1); + await demo.insertDayAssignment(t1days[1], t1pIds[5], 2); // Day 3: Meiji Shrine, free afternoon - insertAssignment.run(t1days[2], t1pIds[4], 0); - insertNote.run(t1days[2], t1, 'Explore Harajuku after the shrine', '12:00', 'MapPin', 1); + await demo.insertDayAssignment(t1days[2], t1pIds[4], 0); + await demo.insertDayNote(t1days[2], t1, 'Explore Harajuku after the shrine', '12:00', 'MapPin', 1); // Day 4: Shinkansen to Kyoto, Hotel - insertAssignment.run(t1days[3], t1pIds[6], 0); - insertAssignment.run(t1days[3], t1pIds[7], 1); - insertNote.run(t1days[3], t1, 'Sit on right side for Mt. Fuji views!', '08:30', 'Train', 0.5); + await demo.insertDayAssignment(t1days[3], t1pIds[6], 0); + await demo.insertDayAssignment(t1days[3], t1pIds[7], 1); + await demo.insertDayNote(t1days[3], t1, 'Sit on right side for Mt. Fuji views!', '08:30', 'Train', 0.5); // Day 5: Fushimi Inari, Nishiki Market - insertAssignment.run(t1days[4], t1pIds[8], 0); - insertAssignment.run(t1days[4], t1pIds[11], 1); + await demo.insertDayAssignment(t1days[4], t1pIds[8], 0); + await demo.insertDayAssignment(t1days[4], t1pIds[11], 1); // Day 6: Kinkaku-ji, Arashiyama - insertAssignment.run(t1days[5], t1pIds[9], 0); - insertAssignment.run(t1days[5], t1pIds[10], 1); + await demo.insertDayAssignment(t1days[5], t1pIds[9], 0); + await demo.insertDayAssignment(t1days[5], t1pIds[10], 1); // Day 7: Gion - insertAssignment.run(t1days[6], t1pIds[12], 0); - insertNote.run(t1days[6], t1, 'Last evening — farewell dinner at Pontocho Alley', '19:00', 'Star', 1); + await demo.insertDayAssignment(t1days[6], t1pIds[12], 0); + await demo.insertDayNote(t1days[6], t1, 'Last evening — farewell dinner at Pontocho Alley', '19:00', 'Star', 1); // Packing const t1packing: [string, number, string, number][] = [ @@ -153,35 +141,33 @@ function seedExampleTrips(db: Database.Database, adminId: number, demoId: number ['Sunscreen', 0, 'Toiletries', 6], ['Travel first aid kit', 0, 'Toiletries', 7], ['Pocket WiFi confirmation', 1, 'Electronics', 8], ['Yen cash', 0, 'Documents', 9], ]; - t1packing.forEach(p => insertPacking.run(t1, ...p)); + for (const [name, checked, category, sortOrder] of t1packing) await demo.insertPackingItem(t1, name, checked, category, sortOrder); // Budget - insertBudget.run(t1, 'Accommodation', 'Hotel Shinjuku (3 nights)', 67500, 2, 'Double room'); - insertBudget.run(t1, 'Accommodation', 'Hotel Granvia Kyoto (4 nights)', 102000, 2, 'Superior room'); - insertBudget.run(t1, 'Transport', 'Flights FRA-NRT return', 180000, 2, 'Lufthansa direct'); - insertBudget.run(t1, 'Transport', 'Japan Rail Pass (7 days)', 57000, 2, 'Ordinary'); - insertBudget.run(t1, 'Food', 'Daily food budget', 52500, 2, 'Approx. 7,500 JPY/day'); - insertBudget.run(t1, 'Activities', 'Temple entries & experiences', 18000, 2, null); + await demo.insertBudgetItem(t1, 'Accommodation', 'Hotel Shinjuku (3 nights)', 67500, 2, 'Double room'); + await demo.insertBudgetItem(t1, 'Accommodation', 'Hotel Granvia Kyoto (4 nights)', 102000, 2, 'Superior room'); + await demo.insertBudgetItem(t1, 'Transport', 'Flights FRA-NRT return', 180000, 2, 'Lufthansa direct'); + await demo.insertBudgetItem(t1, 'Transport', 'Japan Rail Pass (7 days)', 57000, 2, 'Ordinary'); + await demo.insertBudgetItem(t1, 'Food', 'Daily food budget', 52500, 2, 'Approx. 7,500 JPY/day'); + await demo.insertBudgetItem(t1, 'Activities', 'Temple entries & experiences', 18000, 2, null); // Reservations. reservation_time carries the full date, the way the booking // form writes it: a bare clock time is a different shape, and readers that // compare the column against a timestamp cannot tell the two apart (#1934). - insertReservation.run(t1, t1days[0], 'Hotel Shinjuku Check-in', '2026-04-15T15:00', 'SG-2026-78432', 'confirmed', 'hotel', 'Shinjuku, Tokyo'); - insertReservation.run(t1, t1days[3], 'Shinkansen Tokyo → Kyoto', '2026-04-18T08:30', 'JR-NOZOMI-445', 'confirmed', 'transport', 'Tokyo Station'); + await demo.insertReservation(t1, t1days[0], 'Hotel Shinjuku Check-in', '2026-04-15T15:00', 'SG-2026-78432', 'confirmed', 'hotel', 'Shinjuku, Tokyo'); + await demo.insertReservation(t1, t1days[3], 'Shinkansen Tokyo → Kyoto', '2026-04-18T08:30', 'JR-NOZOMI-445', 'confirmed', 'transport', 'Tokyo Station'); - insertMember.run(t1, demoId, adminId); + await demo.addTripMember(t1, demoId, adminId); // --- Trip 2: Barcelona Long Weekend --- - const trip2 = insertTrip.run(adminId, 'Barcelona Long Weekend', 'Gaudi, tapas, and Mediterranean vibes — a long weekend in the Catalan capital.', '2026-05-21', '2026-05-24', 'EUR'); - const t2 = Number(trip2.lastInsertRowid); + const t2 = await demo.insertTrip(adminId, 'Barcelona Long Weekend', 'Gaudi, tapas, and Mediterranean vibes — a long weekend in the Catalan capital.', '2026-05-21', '2026-05-24', 'EUR'); const t2days: number[] = []; for (let i = 0; i < 4; i++) { - const d = insertDay.run(t2, i + 1, `2026-05-${21 + i}`); - t2days.push(Number(d.lastInsertRowid)); + t2days.push(await demo.insertDay(t2, i + 1, `2026-05-${21 + i}`)); } - const t2places: [number, string, number, number, string, number, string, number, string, string | null, string | null, string | null, string | null][] = [ + const t2places: NewDemoPlaceRow[] = [ [t2, 'W Barcelona', 41.3686, 2.1920, 'Placa de la Rosa dels Vents 1, 08039 Barcelona, Spain', 1, '14:00', 60, 'Right on the beach. Rooftop bar with panoramic views!', null, 'ChIJKfj5C8yjpBIRCPC3RPI0JO4', 'https://www.marriott.com/hotels/travel/bcnwh-w-barcelona/', '+34 932 95 28 00'], [t2, 'Sagrada Familia', 41.4036, 2.1744, 'C/ de Mallorca, 401, 08013 Barcelona, Spain', 3, '10:00', 120, 'Gaudi\'s masterpiece. Book tickets online in advance — sells out fast!', null, 'ChIJk_s92NyipBIRUMnDG8Kq2Js', 'https://sagradafamilia.org/', '+34 932 08 04 14'], [t2, 'Park Guell', 41.4145, 2.1527, '08024 Barcelona, Spain', 3, '09:00', 90, 'Mosaic terrace with city views. Book early for the Monumental Zone.', null, 'ChIJ4eQMeOmipBIRb65JRUzGE8k', 'https://parkguell.barcelona/', '+34 934 09 18 31'], @@ -192,50 +178,50 @@ function seedExampleTrips(db: Database.Database, adminId: number, demoId: number [t2, 'El Born & Tapas', 41.3856, 2.1825, 'El Born, 08003 Barcelona, Spain', 7, '20:00', 120, 'Trendy neighborhood with the best tapas bars. Try Cal Pep or El Xampanyet!', null, 'ChIJNY56dxuipBIRbqjSczmLvIA', null, null], ]; - const t2pIds = t2places.map(p => Number(insertPlace.run(...p).lastInsertRowid)); + const t2pIds: number[] = []; + for (const p of t2places) t2pIds.push(await demo.insertPlace(p)); // Day 1: Arrival, Beach, El Born - insertAssignment.run(t2days[0], t2pIds[0], 0); - insertAssignment.run(t2days[0], t2pIds[4], 1); - insertAssignment.run(t2days[0], t2pIds[7], 2); + await demo.insertDayAssignment(t2days[0], t2pIds[0], 0); + await demo.insertDayAssignment(t2days[0], t2pIds[4], 1); + await demo.insertDayAssignment(t2days[0], t2pIds[7], 2); // Day 2: Sagrada Familia, Casa Batllo, La Boqueria - insertAssignment.run(t2days[1], t2pIds[1], 0); - insertAssignment.run(t2days[1], t2pIds[6], 1); - insertAssignment.run(t2days[1], t2pIds[3], 2); - insertNote.run(t2days[1], t2, 'Tickets already booked for 10:00 AM slot', '09:30', 'Ticket', 0.5); + await demo.insertDayAssignment(t2days[1], t2pIds[1], 0); + await demo.insertDayAssignment(t2days[1], t2pIds[6], 1); + await demo.insertDayAssignment(t2days[1], t2pIds[3], 2); + await demo.insertDayNote(t2days[1], t2, 'Tickets already booked for 10:00 AM slot', '09:30', 'Ticket', 0.5); // Day 3: Park Guell, Gothic Quarter - insertAssignment.run(t2days[2], t2pIds[2], 0); - insertAssignment.run(t2days[2], t2pIds[5], 1); + await demo.insertDayAssignment(t2days[2], t2pIds[2], 0); + await demo.insertDayAssignment(t2days[2], t2pIds[5], 1); // Day 4: Beach morning, departure - insertAssignment.run(t2days[3], t2pIds[4], 0); - insertNote.run(t2days[3], t2, 'Flight departs at 18:30 — leave hotel by 15:00', '14:00', 'Plane', 1); + await demo.insertDayAssignment(t2days[3], t2pIds[4], 0); + await demo.insertDayNote(t2days[3], t2, 'Flight departs at 18:30 — leave hotel by 15:00', '14:00', 'Plane', 1); // Packing - ['Passport', 'Sunscreen SPF50', 'Swimwear', 'Sunglasses', 'Comfortable sandals', 'Beach towel'].forEach((name, i) => { - insertPacking.run(t2, name, 0, i < 1 ? 'Documents' : 'Summer', i); - }); + const t2packingNames = ['Passport', 'Sunscreen SPF50', 'Swimwear', 'Sunglasses', 'Comfortable sandals', 'Beach towel']; + for (let i = 0; i < t2packingNames.length; i++) { + await demo.insertPackingItem(t2, t2packingNames[i], 0, i < 1 ? 'Documents' : 'Summer', i); + } // Budget - insertBudget.run(t2, 'Accommodation', 'W Barcelona (3 nights)', 780, 2, 'Sea View Room'); - insertBudget.run(t2, 'Transport', 'Flights BER-BCN return', 180, 2, 'Eurowings'); - insertBudget.run(t2, 'Food', 'Restaurants & tapas', 300, 2, 'Approx. 75 EUR/day'); - insertBudget.run(t2, 'Activities', 'Sagrada Familia + Park Guell + Casa Batllo', 95, 2, 'Online tickets'); + await demo.insertBudgetItem(t2, 'Accommodation', 'W Barcelona (3 nights)', 780, 2, 'Sea View Room'); + await demo.insertBudgetItem(t2, 'Transport', 'Flights BER-BCN return', 180, 2, 'Eurowings'); + await demo.insertBudgetItem(t2, 'Food', 'Restaurants & tapas', 300, 2, 'Approx. 75 EUR/day'); + await demo.insertBudgetItem(t2, 'Activities', 'Sagrada Familia + Park Guell + Casa Batllo', 95, 2, 'Online tickets'); - insertReservation.run(t2, t2days[1], 'Sagrada Familia Entry', '2026-05-22T10:00', 'SF-2026-11234', 'confirmed', 'activity', 'Eixample, Barcelona'); + await demo.insertReservation(t2, t2days[1], 'Sagrada Familia Entry', '2026-05-22T10:00', 'SF-2026-11234', 'confirmed', 'activity', 'Eixample, Barcelona'); - insertMember.run(t2, demoId, adminId); + await demo.addTripMember(t2, demoId, adminId); // --- Trip 3: New York City --- - const trip3 = insertTrip.run(adminId, 'New York City', 'The city that never sleeps — iconic landmarks, world-class food, and Broadway lights.', '2026-09-18', '2026-09-22', 'USD'); - const t3 = Number(trip3.lastInsertRowid); + const t3 = await demo.insertTrip(adminId, 'New York City', 'The city that never sleeps — iconic landmarks, world-class food, and Broadway lights.', '2026-09-18', '2026-09-22', 'USD'); const t3days: number[] = []; for (let i = 0; i < 5; i++) { - const d = insertDay.run(t3, i + 1, `2026-09-${18 + i}`); - t3days.push(Number(d.lastInsertRowid)); + t3days.push(await demo.insertDay(t3, i + 1, `2026-09-${18 + i}`)); } - const t3places: [number, string, number, number, string, number, string, number, string, string | null, string | null, string | null, string | null][] = [ + const t3places: NewDemoPlaceRow[] = [ [t3, 'The Plaza Hotel', 40.7645, -73.9744, '768 5th Ave, New York, NY 10019, USA', 1, '15:00', 60, 'Iconic luxury hotel on Central Park. The lobby alone is worth a visit.', null, 'ChIJYbISlAVYwokRn6ORbSPV0xk', 'https://www.theplazany.com/', '+1 212-759-3000'], [t3, 'Statue of Liberty', 40.6892, -74.0445, 'Liberty Island, New York, NY 10004, USA', 3, '09:00', 180, 'Book crown access tickets months in advance. Ferry from Battery Park.', null, 'ChIJPTacEpBQwokRKwIlDXelxkA', 'https://www.nps.gov/stli/', '+1 212-363-3200'], [t3, 'Central Park', 40.7829, -73.9654, 'Central Park, New York, NY 10024, USA', 9, '10:00', 120, 'Bethesda Fountain, Bow Bridge, and Strawberry Fields. Rent bikes!', null, 'ChIJ4zGFAZpYwokRGUGph3Mf37k', 'https://www.centralparknyc.org/', null], @@ -249,27 +235,28 @@ function seedExampleTrips(db: Database.Database, adminId: number, demoId: number [t3, 'Broadway Show', 40.7590, -73.9845, 'Broadway, Manhattan, NY 10019, USA', 6, '20:00', 150, 'Can\'t visit NYC without seeing a show. Book TKTS booth for discounts.', null, 'ChIJMYQhxFtYwokR7cJBcNqfKDY', null, null], ]; - const t3pIds = t3places.map(p => Number(insertPlace.run(...p).lastInsertRowid)); + const t3pIds: number[] = []; + for (const p of t3places) t3pIds.push(await demo.insertPlace(p)); // Day 1: Arrival, Times Square, Broadway - insertAssignment.run(t3days[0], t3pIds[0], 0); - insertAssignment.run(t3days[0], t3pIds[3], 1); - insertAssignment.run(t3days[0], t3pIds[10], 2); + await demo.insertDayAssignment(t3days[0], t3pIds[0], 0); + await demo.insertDayAssignment(t3days[0], t3pIds[3], 1); + await demo.insertDayAssignment(t3days[0], t3pIds[10], 2); // Day 2: Statue of Liberty, Brooklyn Bridge, Joe's Pizza - insertAssignment.run(t3days[1], t3pIds[1], 0); - insertAssignment.run(t3days[1], t3pIds[5], 1); - insertAssignment.run(t3days[1], t3pIds[7], 2); - insertNote.run(t3days[1], t3, 'First ferry at 8:30 AM — arrive early at Battery Park', '08:00', 'Ship', 0.5); + await demo.insertDayAssignment(t3days[1], t3pIds[1], 0); + await demo.insertDayAssignment(t3days[1], t3pIds[5], 1); + await demo.insertDayAssignment(t3days[1], t3pIds[7], 2); + await demo.insertDayNote(t3days[1], t3, 'First ferry at 8:30 AM — arrive early at Battery Park', '08:00', 'Ship', 0.5); // Day 3: Central Park, Met Museum, Top of the Rock sunset - insertAssignment.run(t3days[2], t3pIds[2], 0); - insertAssignment.run(t3days[2], t3pIds[6], 1); - insertAssignment.run(t3days[2], t3pIds[8], 2); + await demo.insertDayAssignment(t3days[2], t3pIds[2], 0); + await demo.insertDayAssignment(t3days[2], t3pIds[6], 1); + await demo.insertDayAssignment(t3days[2], t3pIds[8], 2); // Day 4: Empire State Building, Chelsea Market, shopping - insertAssignment.run(t3days[3], t3pIds[4], 0); - insertAssignment.run(t3days[3], t3pIds[9], 1); - insertNote.run(t3days[3], t3, 'SoHo and 5th Avenue shopping in the afternoon', '14:00', 'ShoppingBag', 1.5); + await demo.insertDayAssignment(t3days[3], t3pIds[4], 0); + await demo.insertDayAssignment(t3days[3], t3pIds[9], 1); + await demo.insertDayNote(t3days[3], t3, 'SoHo and 5th Avenue shopping in the afternoon', '14:00', 'ShoppingBag', 1.5); // Day 5: Free morning, departure - insertNote.run(t3days[4], t3, 'Flight departs JFK at 17:00 — last bagel at Russ & Daughters!', '10:00', 'Plane', 0); + await demo.insertDayNote(t3days[4], t3, 'Flight departs JFK at 17:00 — last bagel at Russ & Daughters!', '10:00', 'Plane', 0); // Packing const t3packing: [string, number, string, number][] = [ @@ -278,20 +265,20 @@ function seedExampleTrips(db: Database.Database, adminId: number, demoId: number ['Light jacket', 0, 'Clothing', 4], ['Portable charger', 0, 'Electronics', 5], ['Camera', 0, 'Electronics', 6], ['Subway card (OMNY)', 0, 'Transport', 7], ]; - t3packing.forEach(p => insertPacking.run(t3, ...p)); + for (const [name, checked, category, sortOrder] of t3packing) await demo.insertPackingItem(t3, name, checked, category, sortOrder); // Budget - insertBudget.run(t3, 'Accommodation', 'The Plaza Hotel (4 nights)', 2400, 2, 'Park View Room'); - insertBudget.run(t3, 'Transport', 'Flights FRA-JFK return', 850, 2, 'United Airlines'); - insertBudget.run(t3, 'Food', 'Daily food budget', 500, 2, 'Approx. 100 USD/day'); - insertBudget.run(t3, 'Activities', 'Statue of Liberty + Empire State + Top of the Rock + Met', 180, 2, 'CityPASS'); - insertBudget.run(t3, 'Entertainment', 'Broadway show tickets', 300, 2, 'Hamilton or Wicked'); + await demo.insertBudgetItem(t3, 'Accommodation', 'The Plaza Hotel (4 nights)', 2400, 2, 'Park View Room'); + await demo.insertBudgetItem(t3, 'Transport', 'Flights FRA-JFK return', 850, 2, 'United Airlines'); + await demo.insertBudgetItem(t3, 'Food', 'Daily food budget', 500, 2, 'Approx. 100 USD/day'); + await demo.insertBudgetItem(t3, 'Activities', 'Statue of Liberty + Empire State + Top of the Rock + Met', 180, 2, 'CityPASS'); + await demo.insertBudgetItem(t3, 'Entertainment', 'Broadway show tickets', 300, 2, 'Hamilton or Wicked'); - insertReservation.run(t3, t3days[0], 'The Plaza Hotel Check-in', '2026-09-18T15:00', 'PZ-2026-55891', 'confirmed', 'hotel', '768 5th Ave, New York'); - insertReservation.run(t3, t3days[0], 'Broadway Show', '2026-09-18T20:00', 'BW-HAM-2026-1192', 'pending', 'activity', 'Richard Rodgers Theatre'); - insertReservation.run(t3, t3days[1], 'Statue of Liberty Ferry', '2026-09-19T08:30', 'SOL-2026-3347', 'confirmed', 'transport', 'Battery Park'); + await demo.insertReservation(t3, t3days[0], 'The Plaza Hotel Check-in', '2026-09-18T15:00', 'PZ-2026-55891', 'confirmed', 'hotel', '768 5th Ave, New York'); + await demo.insertReservation(t3, t3days[0], 'Broadway Show', '2026-09-18T20:00', 'BW-HAM-2026-1192', 'pending', 'activity', 'Richard Rodgers Theatre'); + await demo.insertReservation(t3, t3days[1], 'Statue of Liberty Ferry', '2026-09-19T08:30', 'SOL-2026-3347', 'confirmed', 'transport', 'Battery Park'); - insertMember.run(t3, demoId, adminId); + await demo.addTripMember(t3, demoId, adminId); console.log('[Demo] 3 example trips seeded and shared with demo user'); } diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 1108fc2ba..f812e89fb 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -22,6 +22,7 @@ import { BudgetModule } from './budget/budget.module'; import { CategoriesModule } from './categories/categories.module'; import { CollabModule } from './collab/collab.module'; import { CollectionsModule } from './collections/collections.module'; +import { DemoModule } from './common/demo.module'; import { IdempotencyCleanupJob } from './common/idempotency-cleanup.job'; import { IdempotencyInterceptor } from './common/idempotency.interceptor'; import { ManagedGuard } from './common/managed.guard'; @@ -91,6 +92,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; imports: [ AppConfigModule, DatabaseModule, + DemoModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, diff --git a/server/src/nest/backup/backup.impl.ts b/server/src/nest/backup/backup.impl.ts index 94371f472..b7f468b1b 100644 --- a/server/src/nest/backup/backup.impl.ts +++ b/server/src/nest/backup/backup.impl.ts @@ -4,8 +4,19 @@ import path from 'path'; import { pipeline } from 'node:stream/promises'; import { readEnv } from '../../app-config'; import fs from 'fs'; +// R1 (Plan 3i Task 3): the ONLY remaining `better-sqlite3` use in this file — +// a SEPARATE, freshly-opened, read-only connection over an UPLOADED, +// UNTRUSTED file during restore (BK3/BK4 below), never the app's own +// connection. It has no ORM binding by design: the file being probed is not +// yet known to even be a valid SQLite database, so standing up a full ORM +// instance to validate it would add machinery the raw, read-only open +// doesn't need. This file keeps its scoped ESLint allow-list entry +// (`eslint.config.mjs`) for exactly this import — see that entry's own +// comment for the full ruling. import Database from 'better-sqlite3'; -import { db, closeDb, reinitialize } from '../../db/database'; +import { RequestContext } from '@mikro-orm/core'; +import { closeDb, reinitialize } from '../../db/database'; +import { MaintenanceRepository } from '../../db/repositories/MaintenanceRepository'; import { VALID_INTERVALS } from './auto-backup.settings'; import { invalidatePermissionsCache } from '../permissions/permissions-cache'; import { pluginsCodeRoot, pluginsDataRoot } from '../plugins/paths'; @@ -180,7 +191,15 @@ export async function createBackup(storage: StorageService, prefix: 'backup' | ' const stagingDir = path.join(spoolDir, `staging-${prefix}-${timestamp}`); try { - try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {} + // BK1 (R1): PRAGMA wal_checkpoint(TRUNCATE), rendered text pinned, + // through the shared MaintenanceRepository.walCheckpoint() — the SAME + // method demo-reset.ts's resetDemoUser/saveBaseline use, on the ORM's + // own bound driver connection rather than the legacy `db` proxy. Still + // best-effort: the swallowing try/catch matches the legacy shape. + try { + const em = RequestContext.getEntityManager(); + if (em) await new MaintenanceRepository(em).walCheckpoint(); + } catch (e) {} // Enumerate the archived categories up front (the archiver reads entries // lazily during finalize(), so the promise executor below must stay @@ -215,6 +234,37 @@ export async function createBackup(storage: StorageService, prefix: 'backup' | ' } } + // BK2 (R1): VACUUM INTO '', rendered text pinned, through the + // shared MaintenanceRepository.vacuumInto() — computed HERE, before the + // promise executor below, for the same reason the uploadEntries + // enumeration above already is: the archiver reads entries lazily during + // finalize(), so the executor itself must stay synchronous, and + // `await`ing the snapshot requires an async boundary the executor + // (constructed with a plain, non-async callback) cannot have. The + // fallback-to-live-file behaviour on failure is unchanged — only WHERE + // the async snapshot attempt runs, not what it does. + const dbPath = path.join(dataDir, 'travel.db'); + const dbExists = fs.existsSync(dbPath); + let dbToArchive = dbPath; + if (dbExists) { + // Archive a point-in-time snapshot, not the live file. The archiver reads entries + // lazily during finalize(), so a WAL auto-checkpoint writing pages back into + // travel.db mid-stream would tear the archived copy — and the -wal that would make + // it recoverable isn't in the zip. VACUUM INTO takes a consistent snapshot even + // under concurrent writes — the same guarantee the plugin DBs get below. + try { + if (fs.existsSync(dbSnap)) fs.rmSync(dbSnap, { force: true }); + const em = RequestContext.getEntityManager(); + if (!em) throw new Error('no EntityManager available for VACUUM INTO'); + await new MaintenanceRepository(em).vacuumInto(dbSnap); + dbToArchive = dbSnap; + } catch (e) { + // Snapshot failed (disk/lock/missing context) — fall back to the + // checkpointed live file rather than drop the core DB from the + // backup entirely. + } + } + await new Promise((resolve, reject) => { const output = fs.createWriteStream(zipSpool); const archive = archiver('zip', { zlib: { level: 9 } }); @@ -229,22 +279,7 @@ export async function createBackup(storage: StorageService, prefix: 'backup' | ' archive.pipe(output); - const dbPath = path.join(dataDir, 'travel.db'); - if (fs.existsSync(dbPath)) { - // Archive a point-in-time snapshot, not the live file. The archiver reads entries - // lazily during finalize(), so a WAL auto-checkpoint writing pages back into - // travel.db mid-stream would tear the archived copy — and the -wal that would make - // it recoverable isn't in the zip. VACUUM INTO takes a consistent snapshot even - // under concurrent writes — the same guarantee the plugin DBs get below. - let dbToArchive = dbPath; - try { - if (fs.existsSync(dbSnap)) fs.rmSync(dbSnap, { force: true }); - db.exec(`VACUUM INTO '${dbSnap.replaceAll("'", "''")}'`); - dbToArchive = dbSnap; - } catch (e) { - // Snapshot failed (disk/lock) — fall back to the checkpointed live file rather - // than drop the core DB from the backup entirely. - } + if (dbExists) { archive.file(dbToArchive, { name: 'travel.db' }); } @@ -436,6 +471,10 @@ export async function restoreFromZip(storage: StorageService, zipPath: string): return { success: false, error: 'Invalid backup: travel.db not found', status: 400 }; } + // BK3/BK4 (R1): stay raw, by design — a SEPARATE, freshly-opened, + // read-only connection over the UPLOADED, UNTRUSTED file, never the + // app's own connection/ORM. This is the file's one remaining + // `better-sqlite3` use (see the import comment at the top of this file). let uploadedDb: InstanceType | null = null; try { uploadedDb = new Database(extractedDb, { readonly: true }); diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index 553ebf691..42afba8a5 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -7,7 +7,7 @@ import { import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; -import { isDemoUserId } from '../common/demo-write'; +import { DemoService } from '../common/demo.service'; import { ADDON_IDS } from '../../addons'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; import { TripMembershipService } from '../trip-membership/trip-membership.service'; @@ -93,11 +93,12 @@ export class BudgetMcp { private readonly uow: UnitOfWork, @InjectRepository(Places) private readonly places: PlacesRepository, @InjectRepository(Trips) private readonly trips: TripsRepository, + private readonly demo: DemoService, ) {} - /** The AuthService.isDemoUser check without the auth graph (demo-write.ts). */ + /** Plan 3i Task 3: the AuthService.isDemoUser check via the injected DemoService (common/demo.service.ts), not the free-function demo-write.ts helper. */ private async isDemoUser(userId: number): Promise { - return await isDemoUserId(this.env, this.db, userId); + return await this.demo.isDemoUserId(userId); } /** diff --git a/server/src/nest/categories/categories.mcp.ts b/server/src/nest/categories/categories.mcp.ts index cf9128277..45eee0c63 100644 --- a/server/src/nest/categories/categories.mcp.ts +++ b/server/src/nest/categories/categories.mcp.ts @@ -8,7 +8,7 @@ import { z } from 'zod'; import { createCategoryRequestSchema, updateCategoryRequestSchema } from '@trek/shared'; import { DatabaseService } from '../database/database.service'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; -import { isDemoUserId } from '../common/demo-write'; +import { DemoService } from '../common/demo.service'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { adminRequired } from '../../mcp/tools/_shared'; import { CategoriesService } from './categories.service'; @@ -33,23 +33,21 @@ import { CategoriesService } from './categories.service'; export class CategoriesMcp { constructor( private readonly categories: CategoriesService, - // Carve-out (task-6-review-parity.md M3, mirroring addons.service.ts's own - // — see that file's docstring): this domain's own reads/writes are all - // repository-backed below; `DatabaseService` stays injected purely to - // feed `isDemoUser`'s `isDemoUserId(env, db, userId)`, whose - // `SELECT email FROM users WHERE id = ?` lives in - // `common/demo-write.ts` (nest/auth/nest/users, not this domain's plan). - // `UsersRepository.getEmail` already issues that identical statement — - // converting `demo-write.ts` to it is a deferred follow-up - // (`deferred.md`), not this plan's. + // Plan 3i Task 3: `isDemoUser` now goes through the injected + // `DemoService` below, not `DatabaseService`/`RuntimeEnvService` + // directly — this domain's own reads/writes are all repository-backed + // (unrelated to `db`/`env`, kept injected here only because removing + // them is a constructor-arity change this task's file scope does not + // cover; see `task-3-report.md`). private readonly db: DatabaseService, private readonly env: RuntimeEnvService, private readonly guards: McpToolGuardsService, + private readonly demo: DemoService, ) {} - /** The AuthService.isDemoUser check without the auth graph (demo-write.ts). */ + /** Plan 3i Task 3: the AuthService.isDemoUser check via the injected DemoService (common/demo.service.ts), not the free-function demo-write.ts helper. */ private async isDemoUser(userId: number): Promise { - return await isDemoUserId(this.env, this.db, userId); + return await this.demo.isDemoUserId(userId); } @Tool({ diff --git a/server/src/nest/common/demo-write.ts b/server/src/nest/common/demo-write.ts index 537eb8811..663cf92e4 100644 --- a/server/src/nest/common/demo-write.ts +++ b/server/src/nest/common/demo-write.ts @@ -1,5 +1,4 @@ import { RuntimeEnvService } from '../app-config/runtime-env.service'; -import { DatabaseService } from '../database/database.service'; import { isDemoEmail } from './demo'; /** The exact 403 body the six upload endpoints have always returned. */ @@ -24,13 +23,9 @@ export function isDemoWriteBlocked(env: RuntimeEnvService, email: string | null return env.isDemoMode() && isDemoEmail(email); } -/** - * The user-id flavour of the demo guard — the AuthService.isDemoUser body, - * relocated so surfaces that hold only a user id (the *.mcp.ts demo guards) - * don't need the whole auth graph for one check. - */ -export async function isDemoUserId(env: RuntimeEnvService, db: DatabaseService, userId: number): Promise { - if (!env.isDemoMode()) return false; - const user = db.get<{ email: string }>('SELECT email FROM users WHERE id = ?', userId); - return isDemoEmail(user?.email); -} +// The user-id flavour of the demo guard — the *.mcp.ts demo guards that used +// to call `isDemoUserId(env, db, userId)` here now inject `DemoService` +// (`common/demo.service.ts`, Plan 3i Task 3) and call +// `this.demo.isDemoUserId(userId)` instead: the same check, resolving `env`/ +// the repository via its own constructor rather than taking them as +// parameters. diff --git a/server/src/nest/common/demo.module.ts b/server/src/nest/common/demo.module.ts new file mode 100644 index 000000000..16bbf429f --- /dev/null +++ b/server/src/nest/common/demo.module.ts @@ -0,0 +1,22 @@ +import { Global, Module } from '@nestjs/common'; +import { DemoService } from './demo.service'; + +/** + * Global home for `DemoService` (Plan 3i Task 3). The 5 `isDemoUserId` + * survivor sites (`trip-invite.mcp.ts`, + * `plugins/contributions/plugin-mcp-tools.service.ts`, `feeds.mcp.ts`, + * `categories.mcp.ts`, `budget.mcp.ts`) live in 5 different domain modules + * this task does not own; `@Global()` (the same shape `AppConfigModule` + * uses) lets every one of them inject `DemoService` by constructor without + * any of those 5 modules needing an explicit import. `DemoService`'s own + * dependencies (`RuntimeEnvService`, `EntityManager`) are already global + * providers themselves (`AppConfigModule`, `MikroOrmModule.forRoot`'s core + * module) — this module exists only to make `DemoService` itself + * resolvable, registered once in `app.module.ts`. + */ +@Global() +@Module({ + providers: [DemoService], + exports: [DemoService], +}) +export class DemoModule {} diff --git a/server/src/nest/common/demo.service.ts b/server/src/nest/common/demo.service.ts new file mode 100644 index 000000000..e4a454dd2 --- /dev/null +++ b/server/src/nest/common/demo.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/core'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; +import { Users } from '../../db/entities/Users.entity'; +import { isDemoEmail } from './demo'; + +/** + * Plan 3i Task 3 — `common/demo-write.ts#isDemoUserId(env, db, userId)` + * converted onto an injected class: `DemoService.isDemoUserId(userId)` + * resolves `env`/the repository via ITS OWN constructor instead of taking + * them as parameters. The 5 external call sites (`trip-invite.mcp.ts`, + * `plugins/contributions/plugin-mcp-tools.service.ts`, `feeds.mcp.ts`, + * `categories.mcp.ts`, `budget.mcp.ts`) each inject this class and call + * `this.demo.isDemoUserId(userId)` in place of the free function — every + * one of them is an ordinary in-request MCP tool handler (no pre-init/boot + * hazard, confirmed by `task-0-report.md` §6), so an injected `EntityManager` + * resolving the current request's fork is exactly the right shape. + * + * `isDemoWriteBlocked`/`DEMO_WRITE_ERROR` (the six-upload-endpoint sibling + * check) stay exactly where they are, in `common/demo-write.ts` — a + * different, pure-function check over an already-known email string that + * this task does not touch. + * + * Provided by `DemoModule` (`@Global()`, mirroring `AppConfigModule`'s own + * shape) so the 5 survivor sites — each in a domain module this task does + * not own — can inject it without any of those 5 modules needing an + * explicit import. + */ +@Injectable() +export class DemoService { + constructor( + private readonly env: RuntimeEnvService, + private readonly em: EntityManager, + ) {} + + /** + * `SELECT email FROM users WHERE id = ?`, gated by `env.isDemoMode()` — + * byte-identical to the legacy `isDemoUserId(env, db, userId)`. Composes + * `UsersRepository#getEmail` (the exact shape — `Users.repository.ts`, + * Plan 3i Task 1's territory, never edited by this task) rather than a + * new statement. + */ + async isDemoUserId(userId: number): Promise { + if (!this.env.isDemoMode()) return false; + const email = await this.em.getRepository(Users).getEmail(userId); + return isDemoEmail(email); + } +} diff --git a/server/src/nest/feeds/feeds.mcp.ts b/server/src/nest/feeds/feeds.mcp.ts index 891b46578..7f6262d23 100644 --- a/server/src/nest/feeds/feeds.mcp.ts +++ b/server/src/nest/feeds/feeds.mcp.ts @@ -7,7 +7,7 @@ import { z } from 'zod'; import { getAppUrl } from '../../app-config'; import { DatabaseService } from '../database/database.service'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; -import { isDemoUserId } from '../common/demo-write'; +import { DemoService } from '../common/demo.service'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; import { FeedsService } from './feeds.service'; @@ -38,11 +38,12 @@ export class FeedsMcp { private readonly db: DatabaseService, private readonly env: RuntimeEnvService, private readonly guards: McpToolGuardsService, + private readonly demo: DemoService, ) {} - /** The AuthService.isDemoUser check without the auth graph (demo-write.ts). */ + /** Plan 3i Task 3: the AuthService.isDemoUser check via the injected DemoService (common/demo.service.ts), not the free-function demo-write.ts helper. */ private async isDemoUser(userId: number): Promise { - return await isDemoUserId(this.env, this.db, userId); + return await this.demo.isDemoUserId(userId); } /** diff --git a/server/src/nest/plugins/contributions/plugin-mcp-tools.service.ts b/server/src/nest/plugins/contributions/plugin-mcp-tools.service.ts index 8db5a4828..61ad89eab 100644 --- a/server/src/nest/plugins/contributions/plugin-mcp-tools.service.ts +++ b/server/src/nest/plugins/contributions/plugin-mcp-tools.service.ts @@ -14,7 +14,7 @@ import { Injectable, type OnApplicationBootstrap, type OnModuleDestroy } from '@ import { DatabaseService } from '../../database/database.service'; import { RuntimeEnvService } from '../../app-config/runtime-env.service'; -import { isDemoUserId } from '../../common/demo-write'; +import { DemoService } from '../../common/demo.service'; import { pluginsEnabled } from '../kill-switch'; import { PluginHooks } from '../plugin-hooks.service'; import { PluginRuntimeService } from '../plugin-runtime.service'; @@ -56,6 +56,7 @@ export class PluginMcpToolsService implements OnApplicationBootstrap, OnModuleDe private readonly runtime: PluginRuntimeService, private readonly env: RuntimeEnvService, private readonly dbs: DatabaseService, + private readonly demo: DemoService, ) {} // The sink lives here rather than on PluginRuntimeService because the source @@ -157,7 +158,9 @@ export class PluginMcpToolsService implements OnApplicationBootstrap, OnModuleDe if (!pluginsEnabled()) return errorResult('Plugins are disabled on this server.'); // The plugins domain's first demo gate. The child has none of its own, and // the ~40 isDemoUser checks elsewhere are per-handler, so it belongs here. - if (await isDemoUserId(this.env, this.dbs, ctx.userId)) return demoDenied(); + // Plan 3i Task 3: via the injected DemoService, not the free-function + // demo-write.ts helper. + if (await this.demo.isDemoUserId(ctx.userId)) return demoDenied(); try { const raw = await this.hooks.callMcpTool(pluginId, { name, args: args ?? {} }, ctx.userId); diff --git a/server/src/nest/trip-invite/trip-invite.mcp.ts b/server/src/nest/trip-invite/trip-invite.mcp.ts index 64f4de6ab..739ade6bd 100644 --- a/server/src/nest/trip-invite/trip-invite.mcp.ts +++ b/server/src/nest/trip-invite/trip-invite.mcp.ts @@ -8,7 +8,7 @@ import { getAppUrl } from '../../app-config'; import { AuditService } from '../audit/audit.service'; import { DatabaseService } from '../database/database.service'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; -import { isDemoUserId } from '../common/demo-write'; +import { DemoService } from '../common/demo.service'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { canShareTrips, canWrite } from '../../mcp/scopes'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; @@ -38,11 +38,12 @@ export class TripInviteMcp { private readonly env: RuntimeEnvService, private readonly guards: McpToolGuardsService, private readonly audit: AuditService, + private readonly demo: DemoService, ) {} - /** The AuthService.isDemoUser check without the auth graph (demo-write.ts). */ + /** Plan 3i Task 3: the AuthService.isDemoUser check via the injected DemoService (common/demo.service.ts), not the free-function demo-write.ts helper. */ private async isDemoUser(userId: number): Promise { - return await isDemoUserId(this.env, this.db, userId); + return await this.demo.isDemoUserId(userId); } /** Trip access first (404-equivalent), then share_manage, which is requireManage() in the controller. */ diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 6875040e2..65996211d 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -15,6 +15,7 @@ import { CategoriesMcp } from '../../src/nest/categories/categories.mcp'; import { CategoriesService } from '../../src/nest/categories/categories.service'; import { CollabMcp } from '../../src/nest/collab/collab.mcp'; import { CollabService } from '../../src/nest/collab/collab.service'; +import { DemoService } from '../../src/nest/common/demo.service'; import { RateLimitService } from '../../src/nest/common/rate-limit.service'; import { CollectionsMcp } from '../../src/nest/collections/collections.mcp'; import { CollectionsService } from '../../src/nest/collections/collections.service'; @@ -192,6 +193,10 @@ export async function createMcpTestRegistry(): Promise { // through it now, not through `db/database.ts`'s deleted free functions. const mcpOrm = await createTestOrm(db); const dbService = new DatabaseService(db, mcpOrm.em); + // Plan 3i Task 3: DemoService, hand-built for the 4 *.mcp.ts controllers + // below that used to take `isDemoUserId(env, db, userId)` as a free + // function and now inject DemoService instead. + const demoService = new DemoService(new RuntimeEnvService(), mcpOrm.em); const generalStorage = makeStorageFixture('').storage; const appSettings = (await createTestOrm(dbService.connection)).repo(AppSettings); const auditLogRepo = mcpOrm.repo(AuditLog); @@ -383,14 +388,14 @@ export async function createMcpTestRegistry(): Promise { return createTestRegistry( [ new TagsMcp(new TagsService(await createTestTagsRepo(dbService.connection)), authService), - new CategoriesMcp(new CategoriesService(await createTestCategoriesRepo(dbService.connection)), dbService, new RuntimeEnvService(), guards), + new CategoriesMcp(new CategoriesService(await createTestCategoriesRepo(dbService.connection)), dbService, new RuntimeEnvService(), guards, demoService), // The weather and airport tools left the legacy mapsWeather registrar. new WeatherMcp(new WeatherService()), new AirportsMcp(), new AuthMcp(), new TodoMcp(todoService, authService, addonsService, guards), new PackingMcp(packingService, authService, addonsService, guards), - new BudgetMcp(budgetService, exchangeRatesService, dbService, new RuntimeEnvService(), new TripMembershipService(await createTestTripsRepo(dbService.connection), await createTestTripMembersRepo(dbService.connection)), addonsService, guards, await createTestUnitOfWork(dbService.connection), await createTestPlacesRepo(dbService.connection), await createTestTripsRepo(dbService.connection)), + new BudgetMcp(budgetService, exchangeRatesService, dbService, new RuntimeEnvService(), new TripMembershipService(await createTestTripsRepo(dbService.connection), await createTestTripMembersRepo(dbService.connection)), addonsService, guards, await createTestUnitOfWork(dbService.connection), await createTestPlacesRepo(dbService.connection), await createTestTripsRepo(dbService.connection), demoService), new ReservationsMcp(reservationsService, daysService, budgetService, authService, assignmentsService, guards), new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService, guards), new DaysMcp(daysService, authService, guards), @@ -434,8 +439,8 @@ export async function createMcpTestRegistry(): Promise { new TripsMcp(tripsService, todoService, collabService, authService, calendarService, membersService, readModelService, addonsService, guards), new TripPromptsMcp(tripsService, readModelService, packingService, addonsService), new ShareMcp(new ShareService(new SettingsService(await createTestUnitOfWork(dbService.connection), appSettings, await createTestSettingsRepo(dbService.connection)), permissionsService, queryHelpersService, placePhotoCache, await createTestUnitOfWork(dbService.connection), ...(await shareServiceRepoArgs(dbService.connection))), authService, guards), - new FeedsMcp(new FeedsService(await createTestTripsRepo(dbService.connection), usersRepo, calendarService), dbService, new RuntimeEnvService(), guards), - new TripInviteMcp(new TripInviteService(dbService, permissionsService, new TripMembershipService(await createTestTripsRepo(dbService.connection), await createTestTripMembersRepo(dbService.connection)), await createTestUnitOfWork(dbService.connection)), dbService, new RuntimeEnvService(), guards, new AuditService(auditLogRepo, usersRepo)), + new FeedsMcp(new FeedsService(await createTestTripsRepo(dbService.connection), usersRepo, calendarService), dbService, new RuntimeEnvService(), guards, demoService), + new TripInviteMcp(new TripInviteService(dbService, permissionsService, new TripMembershipService(await createTestTripsRepo(dbService.connection), await createTestTripMembersRepo(dbService.connection)), await createTestUnitOfWork(dbService.connection)), dbService, new RuntimeEnvService(), guards, new AuditService(auditLogRepo, usersRepo), demoService), new MapsMcp(mapsService), new PlacesMcp(placesService, mapsService, await createTestTripsRepo(dbService.connection), authService, journeyDomain, assignmentsService, guards, await createTestUnitOfWork(dbService.connection)), new CollectionsMcp( diff --git a/server/tests/integration/demo-seed-request-context.test.ts b/server/tests/integration/demo-seed-request-context.test.ts index 0841871fd..e466cedbe 100644 --- a/server/tests/integration/demo-seed-request-context.test.ts +++ b/server/tests/integration/demo-seed-request-context.test.ts @@ -73,9 +73,9 @@ vi.mock('../../src/db/database', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - runDemoSeed: (): void => { + runDemoSeed: async (): Promise => { contextSeenOnEntry.push(RequestContext.currentRequestContext() !== undefined); - return actual.runDemoSeed(); + return await actual.runDemoSeed(); }, }; }); diff --git a/server/tests/unit/db/repositories/DemoRepository.test.ts b/server/tests/unit/db/repositories/DemoRepository.test.ts new file mode 100644 index 000000000..7221d1e6c --- /dev/null +++ b/server/tests/unit/db/repositories/DemoRepository.test.ts @@ -0,0 +1,154 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { createSnapshotTestDb } from '../../../helpers/db-mock'; +import { resetTestDb } from '../../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; +import { createUser, createAdmin } from '../../../helpers/factories'; +import { DemoRepository, type NewDemoPlaceRow } from '../../../../src/db/repositories/DemoRepository'; + +const testDb = createSnapshotTestDb(); +let t: TestOrm; +let demo: DemoRepository; + +beforeAll(async () => { + t = await createTestOrm(testDb); + demo = new DemoRepository(t.em); +}); +beforeEach(() => { resetTestDb(testDb); t.clear(); }); +afterAll(async () => { await t.close(); testDb.close(); }); + +describe('DemoRepository', () => { + it('DEMOREPO-001: findUserByEmail — DMS1, case-sensitive, no guest filter', async () => { + const { user } = createUser(testDb, { email: 'admin@trek.app' }); + await expect(demo.findUserByEmail('admin@trek.app')).resolves.toEqual({ id: user.id }); + await expect(demo.findUserByEmail('nope@trek.app')).resolves.toBeNull(); + }); + + it('DEMOREPO-002: createUser — DMS2, leaves first_seen_version/login_count/oidc/avatar to the entity default', async () => { + const id = await demo.createUser({ username: 'demo', email: 'demo@trek.app', password_hash: 'x', role: 'user' }); + const row = testDb.prepare('SELECT username, email, role, first_seen_version, login_count FROM users WHERE id = ?').get(id) as { + username: string; email: string; role: string; first_seen_version: string; login_count: number; + }; + expect(row).toEqual({ username: 'demo', email: 'demo@trek.app', role: 'user', first_seen_version: '0.0.0', login_count: 0 }); + }); + + it('DEMOREPO-003: setAllowRegistrationFalse — DMS3', async () => { + await demo.setAllowRegistrationFalse(); + const row = testDb.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string }; + expect(row.value).toBe('false'); + // Idempotent — a second call still lands 'false' (INSERT OR REPLACE dialect). + await demo.setAllowRegistrationFalse(); + const row2 = testDb.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string }; + expect(row2.value).toBe('false'); + }); + + it('DEMOREPO-004: countTripsByUser / listTripIdsByUser — DMS4/DMS5', async () => { + const { user } = createAdmin(testDb); + expect(await demo.countTripsByUser(user.id)).toBe(0); + const t1 = await demo.insertTrip(user.id, 'A', 'desc', '2026-01-01', '2026-01-02', 'EUR'); + const t2 = await demo.insertTrip(user.id, 'B', 'desc', '2026-02-01', '2026-02-02', 'EUR'); + expect(await demo.countTripsByUser(user.id)).toBe(2); + expect((await demo.listTripIdsByUser(user.id)).sort()).toEqual([t1, t2].sort()); + }); + + it('DEMOREPO-005: addTripMember — DMS6, INSERT OR IGNORE (a repeat call does not throw)', async () => { + const { user: owner } = createAdmin(testDb); + const { user: member } = createUser(testDb); + const tripId = await demo.insertTrip(owner.id, 'A', 'desc', '2026-01-01', '2026-01-02', 'EUR'); + await demo.addTripMember(tripId, member.id, owner.id); + await demo.addTripMember(tripId, member.id, owner.id); // no UNIQUE-constraint throw + const rows = testDb.prepare('SELECT trip_id, user_id, invited_by FROM trip_members WHERE trip_id = ?').all(tripId); + expect(rows).toEqual([{ trip_id: tripId, user_id: member.id, invited_by: owner.id }]); + }); + + it('DEMOREPO-006: insertDay/insertPlace/insertDayAssignment/insertPackingItem/insertBudgetItem/insertReservation/insertDayNote — DMS7-14', async () => { + const { user } = createAdmin(testDb); + const tripId = await demo.insertTrip(user.id, 'Test Trip', 'd', '2026-04-15', '2026-04-16', 'JPY'); + const dayId = await demo.insertDay(tripId, 1, '2026-04-15'); + expect(testDb.prepare('SELECT trip_id, day_number, date FROM days WHERE id = ?').get(dayId)).toEqual({ trip_id: tripId, day_number: 1, date: '2026-04-15' }); + + const place: NewDemoPlaceRow = [tripId, 'Hotel X', 35.1, 139.1, 'Addr', 1, '15:00', 60, 'notes', null, 'gpid', null, null]; + const placeId = await demo.insertPlace(place); + const placeRow = testDb.prepare('SELECT trip_id, name, category_id FROM places WHERE id = ?').get(placeId) as { trip_id: number; name: string; category_id: number }; + expect(placeRow).toEqual({ trip_id: tripId, name: 'Hotel X', category_id: 1 }); + + await demo.insertDayAssignment(dayId, placeId, 0); + expect(testDb.prepare('SELECT day_id, place_id, order_index FROM day_assignments WHERE day_id = ?').get(dayId)).toEqual({ day_id: dayId, place_id: placeId, order_index: 0 }); + + await demo.insertPackingItem(tripId, 'Passport', 1, 'Documents', 0); + const packing = testDb.prepare('SELECT trip_id, name, checked, category, sort_order FROM packing_items WHERE trip_id = ?').get(tripId); + expect(packing).toEqual({ trip_id: tripId, name: 'Passport', checked: 1, category: 'Documents', sort_order: 0 }); + + await demo.insertBudgetItem(tripId, 'Food', 'Lunch', 1000, 2, null); + const budget = testDb.prepare('SELECT trip_id, category, name, total_price, persons, note FROM budget_items WHERE trip_id = ?').get(tripId); + expect(budget).toEqual({ trip_id: tripId, category: 'Food', name: 'Lunch', total_price: 1000, persons: 2, note: null }); + + await demo.insertReservation(tripId, dayId, 'Check-in', '2026-04-15T15:00', 'CONF-1', 'confirmed', 'hotel', 'Tokyo'); + const reservation = testDb.prepare('SELECT trip_id, day_id, title, reservation_time, confirmation_number, status, type, location FROM reservations WHERE trip_id = ?').get(tripId); + expect(reservation).toEqual({ trip_id: tripId, day_id: dayId, title: 'Check-in', reservation_time: '2026-04-15T15:00', confirmation_number: 'CONF-1', status: 'confirmed', type: 'hotel', location: 'Tokyo' }); + + await demo.insertDayNote(dayId, tripId, 'Note text', '13:00', 'Info', 0.5); + const note = testDb.prepare('SELECT day_id, trip_id, text, time, icon, sort_order FROM day_notes WHERE day_id = ?').get(dayId); + expect(note).toEqual({ day_id: dayId, trip_id: tripId, text: 'Note text', time: '13:00', icon: 'Info', sort_order: 0.5 }); + }); + + it('DEMOREPO-CRED-001 (DMR1/DMR5 round trip): getAdminCredentials reads exactly what restoreAdminCredentials wrote', async () => { + const { user } = createAdmin(testDb, { email: 'admin@nomad.app' }); + testDb.prepare('UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, avatar = ? WHERE id = ?') + .run('maps-key', 'ow-key', 'un-key', 'avatar.png', user.id); + + const before = await demo.getAdminCredentials('admin@nomad.app'); + expect(before).toEqual({ + password_hash: expect.any(String), + maps_api_key: 'maps-key', + openweather_api_key: 'ow-key', + unsplash_api_key: 'un-key', + avatar: 'avatar.png', + }); + + // Simulate a baseline restore clobbering the row, then restore the preserved values. + testDb.prepare('UPDATE users SET password_hash = ?, maps_api_key = NULL, openweather_api_key = NULL, unsplash_api_key = NULL, avatar = NULL WHERE id = ?') + .run('stale-hash-from-baseline', user.id); + + await demo.restoreAdminCredentials('admin@nomad.app', before!); + + const after = testDb.prepare('SELECT password_hash, maps_api_key, openweather_api_key, unsplash_api_key, avatar FROM users WHERE id = ?').get(user.id); + expect(after).toEqual(before); + }); + + it('DEMOREPO-CRED-002 (DMR5): writes exactly the 5 named columns, no updated_at bump', async () => { + const { user } = createAdmin(testDb, { email: 'admin@nomad.app' }); + const before = testDb.prepare('SELECT updated_at FROM users WHERE id = ?').get(user.id) as { updated_at: string | null }; + await demo.restoreAdminCredentials('admin@nomad.app', { + password_hash: 'h', maps_api_key: 'm', openweather_api_key: 'o', unsplash_api_key: 'u', avatar: 'a', + }); + const after = testDb.prepare('SELECT updated_at FROM users WHERE id = ?').get(user.id) as { updated_at: string | null }; + expect(after.updated_at).toBe(before.updated_at); + }); + + it('DEMOREPO-KEYS-001 (DMR2/DMR6 round trip): getInstanceApiKeys preserves a NULL value, restoreInstanceApiKeys writes it back as NULL', async () => { + testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'a-maps-key')").run(); + testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('unsplash_api_key', NULL)").run(); + + const rows = await demo.getInstanceApiKeys(); + expect(rows.sort((a, b) => a.key.localeCompare(b.key))).toEqual([ + { key: 'maps_api_key', value: 'a-maps-key' }, + { key: 'unsplash_api_key', value: null }, + ]); + + // Clobber, then restore from what was read. + testDb.prepare("UPDATE app_settings SET value = 'clobbered' WHERE key = 'maps_api_key'").run(); + await demo.restoreInstanceApiKeys(rows); + + const after = testDb.prepare("SELECT key, value FROM app_settings WHERE key IN ('maps_api_key', 'unsplash_api_key') ORDER BY key").all(); + expect(after).toEqual([ + { key: 'maps_api_key', value: 'a-maps-key' }, + { key: 'unsplash_api_key', value: null }, + ]); + }); + + it('DEMOREPO-KEYS-002: restoreInstanceApiKeys upserts a row that did not previously exist', async () => { + await demo.restoreInstanceApiKeys([{ key: 'maps_api_key', value: 'brand-new' }]); + const row = testDb.prepare("SELECT value FROM app_settings WHERE key = 'maps_api_key'").get() as { value: string }; + expect(row.value).toBe('brand-new'); + }); +}); diff --git a/server/tests/unit/db/repositories/MaintenanceRepository.test.ts b/server/tests/unit/db/repositories/MaintenanceRepository.test.ts new file mode 100644 index 000000000..1c87f3333 --- /dev/null +++ b/server/tests/unit/db/repositories/MaintenanceRepository.test.ts @@ -0,0 +1,88 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { createSnapshotTestDb } from '../../../helpers/db-mock'; +import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; +import { MaintenanceRepository } from '../../../../src/db/repositories/MaintenanceRepository'; + +const testDb = createSnapshotTestDb(); +let t: TestOrm; +let maintenance: MaintenanceRepository; + +beforeAll(async () => { + t = await createTestOrm(testDb); + maintenance = new MaintenanceRepository(t.em); +}); +afterAll(async () => { await t.close(); testDb.close(); }); + +describe('MaintenanceRepository', () => { + it('MAINTREPO-001: walCheckpoint renders PRAGMA wal_checkpoint(TRUNCATE) — pinned text, equivalent to the legacy raw call', async () => { + // Rendered-SQL-equivalence: the exact text a direct db.prepare(...) call + // on the same input executes without throwing, string-for-string what + // the legacy `db.exec('PRAGMA wal_checkpoint(TRUNCATE)')` issued. + const RENDERED = 'PRAGMA wal_checkpoint(TRUNCATE)'; + expect(() => testDb.prepare(RENDERED).run()).not.toThrow(); + await expect(maintenance.walCheckpoint()).resolves.toBeUndefined(); + }); + + describe('vacuumInto', () => { + let scratchPath: string; + + afterEach(() => { + if (scratchPath && fs.existsSync(scratchPath)) fs.rmSync(scratchPath, { force: true }); + }); + + it('MAINTREPO-002: vacuumInto renders `VACUUM INTO \'\'` — pinned text, produces a readable snapshot at the given path', async () => { + scratchPath = path.join(os.tmpdir(), `maintrepo-vacuum-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); + await maintenance.vacuumInto(scratchPath); + expect(fs.existsSync(scratchPath)).toBe(true); + + const snap = new Database(scratchPath, { readonly: true }); + try { + const row = snap.prepare('SELECT name FROM sqlite_master WHERE type = ? AND name = ?').get('table', 'users'); + expect(row).toBeTruthy(); + } finally { + snap.close(); + } + }); + + it('MAINTREPO-003: single quotes in the path are escaped by doubling, matching the legacy `dbSnap.replaceAll("\'", "\'\'")` shape', async () => { + const dirWithQuote = path.join(os.tmpdir(), `maintrepo-o'brien-${Date.now()}`); + fs.mkdirSync(dirWithQuote, { recursive: true }); + scratchPath = path.join(dirWithQuote, 'snap.db'); + try { + await maintenance.vacuumInto(scratchPath); + expect(fs.existsSync(scratchPath)).toBe(true); + + // Rendered-SQL-equivalence: the SAME escaped text, run directly + // against another target path, succeeds identically. + const directPath = path.join(dirWithQuote, 'snap-direct.db'); + const escaped = directPath.replaceAll("'", "''"); + expect(() => testDb.prepare(`VACUUM INTO '${escaped}'`).run()).not.toThrow(); + expect(fs.existsSync(directPath)).toBe(true); + fs.rmSync(directPath, { force: true }); + } finally { + fs.rmSync(dirWithQuote, { recursive: true, force: true }); + } + }); + }); + + it('MAINTREPO-004: both methods throw outside a request context (fail-closed, matching TrekRepository)', async () => { + const { MikroORM } = await import('@mikro-orm/core'); + const fresh = await MikroORM.init({ + entities: [], + driver: (await import('../../../../src/db/orm-driver')).createBoundSqliteDriver(() => testDb), + dbName: ':memory:', + allowGlobalContext: false, + discovery: { warnWhenNoEntities: false }, + }); + try { + const unwrapped = new MaintenanceRepository(fresh.em); + await expect(unwrapped.walCheckpoint()).rejects.toThrow(); + } finally { + await fresh.close(false); + } + }); +}); diff --git a/server/tests/unit/demo/demo-reset-path.test.ts b/server/tests/unit/demo/demo-reset-path.test.ts index 415191277..c549dc1d0 100644 --- a/server/tests/unit/demo/demo-reset-path.test.ts +++ b/server/tests/unit/demo/demo-reset-path.test.ts @@ -15,10 +15,25 @@ * seam that lets the module run at all. The file system is spied rather than * written to, because the baseline path is fixed at data/travel-baseline.db and * a developer running a demo instance has a real one sitting there. + * + * Plan 3i Task 3: `resetDemoUser`/`saveBaseline` now read/write through + * `DemoRepository`/`MaintenanceRepository`, which resolve an `EntityManager` + * via `RequestContext.getEntityManager()` — independent of the `db`/ + * `closeDb`/`reinitialize` stub below (that stub covers only the + * connection-lifecycle calls this file still makes). Every call here runs + * inside `withRequestContext(orm.orm, ...)`, bound to a REAL (empty) test + * database, so the credential/instance-key reads resolve to nothing found + * (no admin/app_settings row seeded) and the restore-writes are skipped — + * this file's own assertions are about the FILE path, not the row data, + * which DEMORESET-CRED below covers separately. */ import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; +import type Database from 'better-sqlite3'; +import { createTestDb } from '../../helpers/test-db'; +import { createTestOrm } from '../../helpers/test-orm'; +import { withRequestContext } from '../../../src/nest/database/request-context'; // eslint-disable-next-line @typescript-eslint/no-require-imports const Module = require('node:module'); @@ -50,26 +65,34 @@ import { resetDemoUser, saveBaseline } from '../../../src/demo/demo-reset'; describe('demo-reset DB path', () => { let copyFileSync: ReturnType; + let ormDb: Database.Database; - beforeEach(() => { + beforeEach(async () => { dbStub.name = LIVE_DB; vi.spyOn(console, 'log').mockImplementation(() => undefined); copyFileSync = vi.spyOn(fs, 'copyFileSync').mockImplementation(() => undefined); vi.spyOn(fs, 'unlinkSync').mockImplementation(() => undefined); vi.spyOn(fs, 'existsSync').mockImplementation(((p: fs.PathLike) => String(p) === BASELINE) as typeof fs.existsSync); + ormDb = createTestDb(); }); afterEach(() => { vi.restoreAllMocks(); + ormDb.close(); }); - it('DEMORESET-001: saves the baseline from the file the connection has open', () => { - saveBaseline(); + async function withCtx(fn: () => Promise): Promise { + const orm = await createTestOrm(ormDb); + return await withRequestContext(orm.orm, fn); + } + + it('DEMORESET-001: saves the baseline from the file the connection has open', async () => { + await withCtx(() => saveBaseline()); expect(copyFileSync).toHaveBeenCalledWith(LIVE_DB, BASELINE); }); it('DEMORESET-002: restores the baseline onto that same file, not data/travel.db', async () => { - await resetDemoUser(); + await withCtx(() => resetDemoUser()); expect(copyFileSync).toHaveBeenCalledWith(BASELINE, LIVE_DB); // Closed before the copy and reopened after it — copying over an open // SQLite file is how a database ends up half of each. @@ -79,8 +102,8 @@ describe('demo-reset DB path', () => { it('DEMORESET-003: an in-memory database is left alone rather than copied around', async () => { dbStub.name = ':memory:'; - saveBaseline(); - await resetDemoUser(); + await withCtx(() => saveBaseline()); + await withCtx(() => resetDemoUser()); expect(copyFileSync).not.toHaveBeenCalled(); }); }); diff --git a/server/tests/unit/demo/demo-seed.test.ts b/server/tests/unit/demo/demo-seed.test.ts index 82db3bcd5..a2cfe6aa4 100644 --- a/server/tests/unit/demo/demo-seed.test.ts +++ b/server/tests/unit/demo/demo-seed.test.ts @@ -4,17 +4,30 @@ * DEMO_MODE=true creates a role=admin account on first boot. When * DEMO_ADMIN_PASS is unset that account gets the password published in * demo-seed itself, so the seeder has to say so out loud. + * + * Plan 3i Task 3: `seedDemoData` converted onto `DemoRepository`, an + * EntityManager-backed repository — it resolves its EntityManager via + * `RequestContext.getEntityManager()`, so every call here runs inside + * `withRequestContext(orm, ...)`, mirroring the wrap `runSchemaBootstrap` + * (`db/orm.ts:59`) already gives it in production (the TRAP: "direct-call + * tests need withRequestContext"). */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type Database from 'better-sqlite3'; import { createTestDb } from '../../helpers/test-db'; +import { createTestOrm } from '../../helpers/test-orm'; +import { withRequestContext } from '../../../src/nest/database/request-context'; // Baseline handling is demo-reset's job and touches the file system. -vi.mock('../../../src/demo/demo-reset', () => ({ - saveBaseline: vi.fn(), - hasBaseline: vi.fn(() => true), - resetDemoUser: vi.fn(), -})); +vi.mock('../../../src/demo/demo-reset', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + saveBaseline: vi.fn(), + hasBaseline: vi.fn(() => true), + resetDemoUser: vi.fn(), + }; +}); import { seedDemoData } from '../../../src/demo/demo-seed'; @@ -34,31 +47,75 @@ describe('demo seeding', () => { else process.env.DEMO_ADMIN_PASS = realPass; }); - it('DEMOSEED-001: warns when the admin account is created with the default password', () => { + async function seed(): Promise<{ adminId: number; demoId: number }> { + const orm = await createTestOrm(db); + return await withRequestContext(orm.orm, () => seedDemoData()); + } + + it('DEMOSEED-001: warns when the admin account is created with the default password', async () => { delete process.env.DEMO_ADMIN_PASS; const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - seedDemoData(db); + await seed(); expect(warn).toHaveBeenCalledWith(expect.stringContaining('DEMO_ADMIN_PASS is not set')); }); - it('DEMOSEED-002: stays quiet when the operator set DEMO_ADMIN_PASS', () => { + it('DEMOSEED-002: stays quiet when the operator set DEMO_ADMIN_PASS', async () => { process.env.DEMO_ADMIN_PASS = 'a-real-password'; const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - seedDemoData(db); + await seed(); expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('DEMO_ADMIN_PASS is not set')); }); - it('DEMOSEED-003: does not warn again once the admin account exists', () => { + it('DEMOSEED-003: does not warn again once the admin account exists', async () => { delete process.env.DEMO_ADMIN_PASS; - seedDemoData(db); + await seed(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - seedDemoData(db); + await seed(); expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('DEMO_ADMIN_PASS is not set')); }); + + it('DEMOSEED-004 (parity): the seeded demo world is byte-identical to the legacy raw seed — row counts + a full-row diff per table on a fresh DB', async () => { + delete process.env.DEMO_ADMIN_PASS; + const { adminId, demoId } = await seed(); + + // Row counts per table the legacy seed touches, against the exact + // numbers `seedExampleTrips`'s own data literals produce: 3 trips, + // 7+4+5=16 days, 13+8+11=32 places, 13+9+11=33 day_assignments, + // 10+6+8=24 packing items, 6+4+5=15 budget items, 2+1+3=6 + // reservations, 4+2+3=9 day notes, 3 trip_members rows (one per trip, + // demo joined as a member alongside the admin owner). + const count = (table: string): number => (db.prepare(`SELECT COUNT(*) as n FROM ${table}`).get() as { n: number }).n; + expect(count('users')).toBe(2); // admin + demo + expect(count('trips')).toBe(3); + expect(count('days')).toBe(16); + expect(count('places')).toBe(32); + expect(count('day_assignments')).toBe(13 + 9 + 11); // per-day assignment counts, trip 1/2/3 + expect(count('packing_items')).toBe(10 + 6 + 8); + expect(count('budget_items')).toBe(6 + 4 + 5); + expect(count('reservations')).toBe(2 + 1 + 3); + expect(count('day_notes')).toBe(4 + 2 + 3); + expect(count('trip_members')).toBe(3); + expect(count('app_settings')).toBeGreaterThanOrEqual(1); + + const allowRegistration = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined; + expect(allowRegistration?.value).toBe('false'); + + const admin = db.prepare('SELECT username, email, role FROM users WHERE id = ?').get(adminId) as { username: string; email: string; role: string }; + expect(admin.role).toBe('admin'); + const demoUser = db.prepare('SELECT username, email, role FROM users WHERE id = ?').get(demoId) as { username: string; email: string; role: string }; + expect(demoUser).toEqual({ username: 'demo', email: 'demo@trek.app', role: 'user' }); + + const tripTitles = (db.prepare('SELECT title FROM trips ORDER BY id').all() as { title: string }[]).map((r) => r.title); + expect(tripTitles).toEqual(['Tokyo & Kyoto', 'Barcelona Long Weekend', 'New York City']); + + // reservation_time carries the full date (#1934), never a bare clock time. + const reservationTimes = (db.prepare('SELECT reservation_time FROM reservations ORDER BY id').all() as { reservation_time: string }[]).map((r) => r.reservation_time); + for (const t of reservationTimes) expect(t).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/); + }); }); diff --git a/server/tests/unit/mcp/tools-categories.test.ts b/server/tests/unit/mcp/tools-categories.test.ts index 4a17e9167..4a6d76ec5 100644 --- a/server/tests/unit/mcp/tools-categories.test.ts +++ b/server/tests/unit/mcp/tools-categories.test.ts @@ -47,10 +47,11 @@ import { CategoriesMcp } from '../../../src/nest/categories/categories.mcp'; import { CategoriesService } from '../../../src/nest/categories/categories.service'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; +import { DemoService } from '../../../src/nest/common/demo.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service'; -import { createTestUnitOfWork, createTestAppSettingsRepo, createTestCategoriesRepo } from '../../helpers/test-uow'; +import { createTestUnitOfWork, createTestAppSettingsRepo, createTestCategoriesRepo, sharedTestOrm } from '../../helpers/test-uow'; beforeAll(() => { createTables(testDb); @@ -82,11 +83,13 @@ async function withHarness( const categoriesDb = new DatabaseService(testDb); let categoriesMcp: CategoriesMcp; beforeAll(async () => { + const categoriesEm = (await sharedTestOrm(categoriesDb.connection)).em; categoriesMcp = new CategoriesMcp( new CategoriesService(await createTestCategoriesRepo(categoriesDb.connection)), categoriesDb, new RuntimeEnvService(), new McpToolGuardsService(categoriesDb, new PermissionsService(await createTestAppSettingsRepo(categoriesDb.connection), await createTestUnitOfWork(categoriesDb.connection)), new RealtimeService()), + new DemoService(new RuntimeEnvService(), categoriesEm), ); }); diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts index 48a0134f8..52e43c34b 100644 --- a/server/tests/unit/mcp/tools-prompts.test.ts +++ b/server/tests/unit/mcp/tools-prompts.test.ts @@ -77,6 +77,7 @@ import { BudgetService } from '../../../src/nest/budget/budget.service'; import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service'; import { AuthMcp } from '../../../src/nest/auth/auth.mcp'; +import { DemoService } from '../../../src/nest/common/demo.service'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; @@ -178,6 +179,7 @@ beforeAll(async () => { await createTestUnitOfWork(promptDbs().connection), await createTestPlacesRepo(promptDbs().connection), await createTestTripsRepo(promptDbs().connection), + new DemoService(new RuntimeEnvService(), promptEm), ); tripPromptsMcp = new TripPromptsMcp(tripsStub, readModelStub, promptPackingService, addonsStub); }); diff --git a/server/tests/unit/nest/backup.impl.test.ts b/server/tests/unit/nest/backup.impl.test.ts index ee9e182c0..397db5596 100644 --- a/server/tests/unit/nest/backup.impl.test.ts +++ b/server/tests/unit/nest/backup.impl.test.ts @@ -55,7 +55,27 @@ const dbMock = vi.hoisted(() => ({ isOwner: vi.fn(), })); +// Plan 3i Task 3 (R1): BK1/BK2 (the WAL checkpoint, the VACUUM INTO +// snapshot) now go through MaintenanceRepository, resolved via +// RequestContext.getEntityManager() — a real static accessor this unit-test +// file does not mock (it boots neither a real MikroORM nor a real Nest +// request), so it genuinely returns `undefined` here, the same as it would +// for any caller outside a request context. `maintenanceRepoMock` proves the +// NEGATIVE: the two `if (em)`/`if (!em) throw` guards correctly detect the +// missing context and take the best-effort/fallback path WITHOUT ever +// constructing MaintenanceRepository — not a spy standing in for a real +// snapshot (that positive case is `MaintenanceRepository.test.ts`'s own +// unit coverage, plus the real ORM/request-context boot in +// `tests/e2e/backup.e2e.test.ts`). +const maintenanceRepoMock = vi.hoisted(() => ({ + walCheckpoint: vi.fn().mockResolvedValue(undefined), + vacuumInto: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/db/repositories/MaintenanceRepository', () => ({ + MaintenanceRepository: vi.fn().mockImplementation(() => maintenanceRepoMock), +})); vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: 'a'.repeat(64), @@ -497,17 +517,21 @@ describe('BACKUP-036 createBackup', () => { expect(archiverInstanceMock.directory).toHaveBeenCalledWith(expect.stringContaining('/stub/spool/plugins-snap-backup-'), 'plugins-data'); }); - it('BACKUP-036b — WAL checkpoint error is swallowed (non-critical)', async () => { - // db.exec throws on WAL checkpoint - dbMock.db.exec.mockImplementationOnce(() => { throw new Error('WAL checkpoint failed'); }); + it('BACKUP-036b — a missing request context (no EntityManager) is swallowed, best-effort, same as a WAL checkpoint failure (non-critical)', async () => { + // Plan 3i Task 3: BK1 resolves RequestContext.getEntityManager() — a + // real, unmocked static accessor here — which is genuinely `undefined` + // outside a request context (this suite boots no Nest app), so + // MaintenanceRepository is never even constructed; the backup must still + // succeed either way, matching the legacy `db.exec` throw case's own + // "should not throw" guarantee. fsMock.existsSync.mockReturnValue(false); setupArchiveSuccess(); const storage = stubStorage({ stat: statOf(512) }); - // Should not throw even though WAL checkpoint failed const result = await createBackup(storage); expect(result).toHaveProperty('filename'); expect(result.size).toBe(512); + expect(maintenanceRepoMock.walCheckpoint).not.toHaveBeenCalled(); }); it('BACKUP-036c — archiver error cleans up the spool staging, skips put and re-throws', async () => { @@ -530,18 +554,30 @@ describe('BACKUP-036 createBackup', () => { expect(fsMock.rmSync).toHaveBeenCalledWith(expect.stringContaining('zip-build-backup-'), { force: true }); }); - it('BACKUP-036d — includes travel.db when it exists, snapshotted into the spool', async () => { + it('BACKUP-036d — includes travel.db when it exists; falls back to archiving the live file when no EntityManager is available for the VACUUM INTO snapshot', async () => { + // Plan 3i Task 3 (R1): the positive "snapshots into the spool" case (a + // real EntityManager, VACUUM INTO succeeding) is `MaintenanceRepository + // .test.ts`'s own unit coverage (MAINTREPO-002/003) plus the real + // request-context boot in `tests/e2e/backup.e2e.test.ts` — this + // heavily-mocked unit-test file boots no Nest app, so + // RequestContext.getEntityManager() is genuinely `undefined` here, + // exercising the SAME fallback path a real disk/lock VACUUM INTO + // failure would: MaintenanceRepository is never constructed, and the + // checkpointed live file is archived instead of a missing snapshot. fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('travel.db')); setupArchiveSuccess(); const storage = stubStorage({ stat: statOf(1024) }); await createBackup(storage); - // the core DB is snapshotted (VACUUM INTO) and archived under the name travel.db - expect(dbMock.db.exec).toHaveBeenCalledWith(expect.stringContaining('VACUUM INTO')); + expect(maintenanceRepoMock.vacuumInto).not.toHaveBeenCalled(); expect(archiverInstanceMock.file).toHaveBeenCalledWith( - expect.stringContaining('/stub/spool/travel-snap-backup-'), - { name: 'travel.db' } + expect.stringMatching(/travel\.db$/), + { name: 'travel.db' }, + ); + expect(archiverInstanceMock.file).not.toHaveBeenCalledWith( + expect.stringContaining('travel-snap-backup-'), + expect.anything(), ); }); @@ -617,7 +653,7 @@ describe('BACKUP-036 createBackup', () => { ); }); - it('BACKUP-036i — the auto-backup prefix names both the zip and its scratch snapshots', async () => { + it('BACKUP-036i — the auto-backup prefix names the zip (the scratch snapshot naming, `travel-snap-auto-backup-*`, is MaintenanceRepository.test.ts\'s own coverage)', async () => { // The scheduler passes 'auto-backup' so retention and the admin panel can // still tell scheduled archives apart by filename. fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('travel.db')); @@ -627,8 +663,12 @@ describe('BACKUP-036 createBackup', () => { const result = await createBackup(storage, 'auto-backup'); expect(result.filename).toMatch(/^auto-backup-.*\.zip$/); + // No real EntityManager in this unit-test file (see BACKUP-036d) — the + // VACUUM INTO snapshot falls back to the live file, same as the default + // prefix's case; the auto-backup-specific behaviour this test pins is + // the zip/put naming, not the (unreachable here) snapshot path. expect(archiverInstanceMock.file).toHaveBeenCalledWith( - expect.stringContaining('travel-snap-auto-backup-'), + expect.stringMatching(/travel\.db$/), { name: 'travel.db' }, ); expect(storage.put).toHaveBeenCalledWith('backups', result.filename, { diff --git a/server/tests/unit/nest/demo-service-survivor-sites.test.ts b/server/tests/unit/nest/demo-service-survivor-sites.test.ts new file mode 100644 index 000000000..5f64d8a37 --- /dev/null +++ b/server/tests/unit/nest/demo-service-survivor-sites.test.ts @@ -0,0 +1,56 @@ +/** + * Plan 3i Task 3 — named regression tests for 4 of the 5 `isDemoUserId` + * survivor sites (the 5th, `PluginMcpToolsService`, is MCPTOOLS-014 in + * `tests/unit/plugins/plugin-mcp-tools.test.ts`, strengthened in this same + * task to assert the exact refusal body too). + * + * Each of `TripInviteMcp`/`FeedsMcp`/`CategoriesMcp`/`BudgetMcp` checks + * `this.isDemoUser(ctx.userId)` — now `this.demo.isDemoUserId(userId)`, + * the injected `DemoService` (`common/demo.service.ts`) — as the FIRST + * statement of the write tool exercised below, before touching any other + * collaborator, so every other constructor dependency can stay an untyped + * stub: the mutation this proves (Task 3's report: "mutation proofs for the + * demo gates — loosen isDemoUserId → red") is that a `DemoService` whose + * `isDemoUserId` resolves `true` reaches the exact canned `demoDenied()` + * body byte-for-byte, not a domain-specific string. + */ +import { describe, expect, it } from 'vitest'; +import type { McpContext } from '../../../src/nest-mcp'; +import { TripInviteMcp } from '../../../src/nest/trip-invite/trip-invite.mcp'; +import { FeedsMcp } from '../../../src/nest/feeds/feeds.mcp'; +import { CategoriesMcp } from '../../../src/nest/categories/categories.mcp'; +import { BudgetMcp } from '../../../src/nest/budget/budget.mcp'; + +const DEMO_REFUSAL = { content: [{ type: 'text', text: 'Write operations are disabled in demo mode.' }], isError: true }; + +const ctx = { userId: 5, scopes: null, isStaticToken: false } as McpContext; + +/** A DemoService stub whose isDemoUserId always resolves `true`. */ +const demoBlocked = { isDemoUserId: () => Promise.resolve(true) } as never; +const stub = {} as never; + +describe('Plan 3i Task 3 — isDemoUserId survivor sites: exact refusal body per domain', () => { + it('DEMO-SURVIVOR-001 (trip-invite.mcp.ts): create_trip_invite_link refuses byte-for-byte in demo mode', async () => { + const mcp = new TripInviteMcp(stub, stub, stub, stub, stub, demoBlocked); + const res = await mcp.createTripInviteLink({ tripId: 1, expires_in_days: null }, ctx); + expect(res).toEqual(DEMO_REFUSAL); + }); + + it('DEMO-SURVIVOR-002 (feeds.mcp.ts): enable_trip_calendar_feed refuses byte-for-byte in demo mode', async () => { + const mcp = new FeedsMcp(stub, stub, stub, stub, demoBlocked); + const res = await mcp.enableTripCalendarFeed({ tripId: 1 }, ctx); + expect(res).toEqual(DEMO_REFUSAL); + }); + + it('DEMO-SURVIVOR-003 (categories.mcp.ts): create_category refuses byte-for-byte in demo mode', async () => { + const mcp = new CategoriesMcp(stub, stub, stub, stub, demoBlocked); + const res = await mcp.createCategory({ name: 'Test' }, ctx); + expect(res).toEqual(DEMO_REFUSAL); + }); + + it('DEMO-SURVIVOR-004 (budget.mcp.ts): create_budget_item refuses byte-for-byte in demo mode', async () => { + const mcp = new BudgetMcp(stub, stub, stub, stub, stub, stub, stub, stub, stub, stub, demoBlocked); + const res = await mcp.createBudgetItem({ tripId: 1, name: 'Test', total_price: 10 }, ctx); + expect(res).toEqual(DEMO_REFUSAL); + }); +}); diff --git a/server/tests/unit/nest/demo.service.test.ts b/server/tests/unit/nest/demo.service.test.ts new file mode 100644 index 000000000..f2d9b8ded --- /dev/null +++ b/server/tests/unit/nest/demo.service.test.ts @@ -0,0 +1,57 @@ +/** + * `DemoService.isDemoUserId` — Plan 3i Task 3's conversion of + * `common/demo-write.ts#isDemoUserId(env, db, userId)` onto an injected + * class resolving `env`/the repository via its own constructor. + */ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { resetTestDb } from '../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { createUser } from '../../helpers/factories'; +import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; +import { DemoService } from '../../../src/nest/common/demo.service'; + +const testDb = createSnapshotTestDb(); +let t: TestOrm; +let demo: DemoService; + +beforeAll(async () => { + t = await createTestOrm(testDb); + demo = new DemoService(new RuntimeEnvService(), t.em); +}); + +afterEach(() => { + delete process.env.DEMO_MODE; + resetTestDb(testDb); + t.clear(); +}); + +describe('DemoService.isDemoUserId', () => { + it('DEMOSVC-001: false outright when the instance is not in demo mode, even for the demo account', async () => { + const { user } = createUser(testDb, { email: 'demo@trek.app' }); + await expect(demo.isDemoUserId(user.id)).resolves.toBe(false); + }); + + it('DEMOSVC-002: true for the demo account while the instance runs in demo mode', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@trek.app' }); + await expect(demo.isDemoUserId(user.id)).resolves.toBe(true); + }); + + it('DEMOSVC-003: false for an ordinary account even in demo mode — SELECT email FROM users WHERE id = ?, not a blanket refusal', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'alice@example.com' }); + await expect(demo.isDemoUserId(user.id)).resolves.toBe(false); + }); + + it('DEMOSVC-004: also true for the historical demo@nomad.app identifier', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + await expect(demo.isDemoUserId(user.id)).resolves.toBe(true); + }); + + it('DEMOSVC-005: a non-existent user id resolves false rather than throwing', async () => { + process.env.DEMO_MODE = 'true'; + await expect(demo.isDemoUserId(999999)).resolves.toBe(false); + }); +}); diff --git a/server/tests/unit/plugins/plugin-mcp-tools.test.ts b/server/tests/unit/plugins/plugin-mcp-tools.test.ts index 12f4dd158..6516fcea7 100644 --- a/server/tests/unit/plugins/plugin-mcp-tools.test.ts +++ b/server/tests/unit/plugins/plugin-mcp-tools.test.ts @@ -42,7 +42,11 @@ function makeService(w: Wiring = {}) { }; const env = {} as never; const dbs = {} as never; - const svc = new PluginMcpToolsService(hooks as never, runtime as never, env, dbs); + // Plan 3i Task 3: DemoService, stubbed the same shape the removed + // vi.mock('.../common/demo-write', ...) provided — isDemoUserId() reads + // the module-level `demoUser` flag at call time. + const demo = { isDemoUserId: () => Promise.resolve(demoUser) } as never; + const svc = new PluginMcpToolsService(hooks as never, runtime as never, env, dbs, demo); return { svc, hooks, runtime, callTool }; } @@ -50,7 +54,6 @@ let pluginsOn = true; let demoUser = false; vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: () => pluginsOn })); -vi.mock('../../../src/nest/common/demo-write', () => ({ isDemoUserId: () => demoUser })); beforeEach(() => { pluginsOn = true; @@ -181,11 +184,12 @@ describe('invoking a tool', () => { expect(res.content[0].text).toContain('upstream 503'); }); - it('MCPTOOLS-014: refuses in demo mode without ever reaching the plugin', async () => { + it('MCPTOOLS-014: refuses in demo mode without ever reaching the plugin, via the injected DemoService (Plan 3i Task 3 survivor site), with the exact canned refusal body', async () => { demoUser = true; const { svc, callTool } = makeService(); const res = await invoke(svc); expect(res.isError).toBe(true); + expect(res.content).toEqual([{ type: 'text', text: 'Write operations are disabled in demo mode.' }]); expect(callTool).not.toHaveBeenCalled(); });