mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(demo,backup): seed and reset through DemoRepository, live-connection backup statements through MaintenanceRepository, isDemoUserId through an injected DemoService
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
+15
-14
@@ -43,20 +43,21 @@ export async function runSchemaBootstrap(orm: AnyOrm): Promise<void> {
|
||||
// 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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Entity>` — 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<number> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<number[]> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<DemoAdminCredentialsRow | null> {
|
||||
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<DemoInstanceKeyRow[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Entity>` — 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<void> {
|
||||
this.validateRequestContext();
|
||||
await this.em.getConnection().execute('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
}
|
||||
|
||||
/**
|
||||
* `` VACUUM INTO '<path>' `` — 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<void> {
|
||||
this.validateRequestContext();
|
||||
const escaped = path.replaceAll("'", "''");
|
||||
await this.em.getConnection().execute(`VACUUM INTO '${escaped}'`);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
// 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<void> {
|
||||
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');
|
||||
|
||||
+117
-130
@@ -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<void> {
|
||||
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<void> {
|
||||
// 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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 '<path>', 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<void>((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<typeof Database> | null = null;
|
||||
try {
|
||||
uploadedDb = new Database(extractedDb, { readonly: true });
|
||||
|
||||
@@ -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<boolean> {
|
||||
return await isDemoUserId(this.env, this.db, userId);
|
||||
return await this.demo.isDemoUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<boolean> {
|
||||
return await isDemoUserId(this.env, this.db, userId);
|
||||
return await this.demo.isDemoUserId(userId);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
|
||||
@@ -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<boolean> {
|
||||
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.
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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<boolean> {
|
||||
if (!this.env.isDemoMode()) return false;
|
||||
const email = await this.em.getRepository(Users).getEmail(userId);
|
||||
return isDemoEmail(email);
|
||||
}
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
return await isDemoUserId(this.env, this.db, userId);
|
||||
return await this.demo.isDemoUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<boolean> {
|
||||
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. */
|
||||
|
||||
@@ -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<McpRegistry> {
|
||||
// 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<McpRegistry> {
|
||||
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<McpRegistry> {
|
||||
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(
|
||||
|
||||
@@ -73,9 +73,9 @@ vi.mock('../../src/db/database', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/db/database')>();
|
||||
return {
|
||||
...actual,
|
||||
runDemoSeed: (): void => {
|
||||
runDemoSeed: async (): Promise<void> => {
|
||||
contextSeenOnEntry.push(RequestContext.currentRequestContext() !== undefined);
|
||||
return actual.runDemoSeed();
|
||||
return await actual.runDemoSeed();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 \'<path>\'` — 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.spyOn>;
|
||||
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<T>(fn: () => Promise<T>): Promise<T> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof import('../../../src/demo/demo-reset')>();
|
||||
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}$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user