diff --git a/server/src/demo/demo-reset.ts b/server/src/demo/demo-reset.ts index 103964ec6..d5dc70e3b 100644 --- a/server/src/demo/demo-reset.ts +++ b/server/src/demo/demo-reset.ts @@ -153,4 +153,26 @@ function hasBaseline(): boolean { return fs.existsSync(baselinePath); } -export { resetDemoUser, saveBaseline, hasBaseline }; +/** + * Whether the boot-time seed put the example trips in during this process. + * + * The first baseline is saved only after a real seed, as it always was: a demo + * database that already holds data but has no baseline keeps it that way, and + * the hourly reset stays a logged no-op until the admin saves one. The seed + * runs before the container exists, so it leaves this mark and DemoResetJob, + * which has the backup port, saves the baseline once the app is up. + */ +let seededExampleTrips = false; + +function markExampleTripsSeeded(): void { + seededExampleTrips = true; +} + +/** Reads the mark and clears it, so one seed saves at most one baseline. */ +function takeExampleTripsSeeded(): boolean { + const seeded = seededExampleTrips; + seededExampleTrips = false; + return seeded; +} + +export { resetDemoUser, saveBaseline, hasBaseline, markExampleTripsSeeded, takeExampleTripsSeeded }; diff --git a/server/src/demo/demo-seed.ts b/server/src/demo/demo-seed.ts index a8ca41f89..65326c5a3 100644 --- a/server/src/demo/demo-seed.ts +++ b/server/src/demo/demo-seed.ts @@ -4,7 +4,7 @@ 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 { requireEntityManager } from './demo-reset'; +import { markExampleTripsSeeded, 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 @@ -63,8 +63,10 @@ async function seedDemoData(): Promise<{ adminId: number; demoId: number }> { console.log('[Demo] Seeding example trips...'); await seedExampleTrips(demo, adminId, demoId); - // The first baseline is saved once the app is up, by DemoResetJob, through - // the injected backup port this boot-time seed has no access to. + // The first baseline follows this seed and only this seed. DemoResetJob saves + // it once the app is up, through the injected backup port this boot-time + // seed has no access to. + markExampleTripsSeeded(); return { adminId, demoId }; } diff --git a/server/src/nest/admin/demo-reset.job.ts b/server/src/nest/admin/demo-reset.job.ts index 1cd748775..b045e0db2 100644 --- a/server/src/nest/admin/demo-reset.job.ts +++ b/server/src/nest/admin/demo-reset.job.ts @@ -5,7 +5,7 @@ import { RuntimeEnvService } from '../app-config/runtime-env.service'; import { CronRegistrarService } from '../scheduling/cron-registrar.service'; import { DATABASE_BACKUP, type DatabaseBackupStrategy } from '../database/database-backup.interface'; import { withRequestContext } from '../database/request-context'; -import { hasBaseline, resetDemoUser, saveBaseline } from '../../demo/demo-reset'; +import { hasBaseline, resetDemoUser, saveBaseline, takeExampleTripsSeeded } from '../../demo/demo-reset'; /** * Demo mode: hourly reset of demo user data (moved from src/scheduler.ts). @@ -15,9 +15,12 @@ import { hasBaseline, resetDemoUser, saveBaseline } from '../../demo/demo-reset' * side-effect-free, and the close/swap/reopen sequence runs inside * resetDemoUser at tick time, through the injected backup port. * - * It also saves the first baseline, on a demo boot that has none. That used to - * happen inside the boot-time demo seed, which runs before the container can - * hand anything in; here the port is injected like everywhere else. + * It also saves the first baseline, on the boot whose seed has just put the + * example trips in and only when no baseline exists yet: the same trigger the + * save had inside the boot-time demo seed, which runs before the container can + * hand the port in. A demo database that already holds data without a baseline + * is left alone, so its hourly reset stays a logged no-op as before. Like the + * old save, it does not depend on the cron registrar being enabled. */ @Injectable() export class DemoResetJob implements OnApplicationBootstrap { @@ -36,7 +39,7 @@ export class DemoResetJob implements OnApplicationBootstrap { this.registrar.register('demo-reset', '0 * * * *', () => void this.tick(), { timezone: 'none' }); logInfo('Demo hourly reset scheduled'); } - if (!hasBaseline()) await this.saveFirstBaseline(); + if (takeExampleTripsSeeded() && !hasBaseline()) await this.saveFirstBaseline(); } async tick(): Promise { diff --git a/server/tests/unit/demo/demo-seed.test.ts b/server/tests/unit/demo/demo-seed.test.ts index 18ace932a..c82d72825 100644 --- a/server/tests/unit/demo/demo-seed.test.ts +++ b/server/tests/unit/demo/demo-seed.test.ts @@ -30,6 +30,7 @@ vi.mock('../../../src/demo/demo-reset', async (importOriginal) => { }); import { seedDemoData } from '../../../src/demo/demo-seed'; +import { takeExampleTripsSeeded } from '../../../src/demo/demo-reset'; describe('demo seeding', () => { let db: Database.Database; @@ -118,4 +119,19 @@ describe('demo seeding', () => { 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}$/); }); + + it('DEMOSEED-005: marks a first baseline as due only on the boot that seeded the example trips', async () => { + // The mark is process state; earlier cases may have left it set. + takeExampleTripsSeeded(); + + await seed(); + expect(takeExampleTripsSeeded()).toBe(true); + // Taking it clears it, so one seed saves at most one baseline. + expect(takeExampleTripsSeeded()).toBe(false); + + // A later boot finds the trips in place and returns before seeding: no mark, + // so a database that already holds data never becomes a baseline on its own. + await seed(); + expect(takeExampleTripsSeeded()).toBe(false); + }); }); diff --git a/server/tests/unit/nest/admin-jobs.test.ts b/server/tests/unit/nest/admin-jobs.test.ts index 4f8e53d9c..cd99bd9e7 100644 --- a/server/tests/unit/nest/admin-jobs.test.ts +++ b/server/tests/unit/nest/admin-jobs.test.ts @@ -7,14 +7,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const logMock = vi.hoisted(() => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() })); -const { resetDemoUserMock, saveBaselineMock, hasBaselineMock } = vi.hoisted(() => ({ +const { resetDemoUserMock, saveBaselineMock, hasBaselineMock, takeExampleTripsSeededMock } = vi.hoisted(() => ({ resetDemoUserMock: vi.fn(), saveBaselineMock: vi.fn(async () => {}), hasBaselineMock: vi.fn(() => true), + takeExampleTripsSeededMock: vi.fn(() => false), })); vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock); -vi.mock('../../../src/demo/demo-reset', () => ({ resetDemoUser: resetDemoUserMock, saveBaseline: saveBaselineMock, hasBaseline: hasBaselineMock })); +vi.mock('../../../src/demo/demo-reset', () => ({ + resetDemoUser: resetDemoUserMock, + saveBaseline: saveBaselineMock, + hasBaseline: hasBaselineMock, + takeExampleTripsSeeded: takeExampleTripsSeededMock, +})); import { VersionCheckJob } from '../../../src/nest/admin/version-check.job'; import { DemoResetJob } from '../../../src/nest/admin/demo-reset.job'; @@ -35,6 +41,7 @@ function registrarStub(enabled = true) { beforeEach(() => { vi.clearAllMocks(); hasBaselineMock.mockReturnValue(true); + takeExampleTripsSeededMock.mockReturnValue(false); }); describe('VersionCheckJob', () => { @@ -105,7 +112,8 @@ describe('DemoResetJob', () => { expect(logMock.logError).toHaveBeenCalledWith('Demo reset: baseline gone'); }); - it('AJOB-007: a demo boot without a baseline saves the first one through the port, gate or not', async () => { + it('AJOB-007: the boot that seeded the example trips saves the first baseline through the port, gate or not', async () => { + takeExampleTripsSeededMock.mockReturnValue(true); hasBaselineMock.mockReturnValue(false); const { job } = make(true, false); await job.onApplicationBootstrap(); @@ -113,6 +121,7 @@ describe('DemoResetJob', () => { }); it('AJOB-008: an existing baseline is left alone, and so is a non-demo boot', async () => { + takeExampleTripsSeededMock.mockReturnValue(true); await make(true).job.onApplicationBootstrap(); hasBaselineMock.mockReturnValue(false); await make(false).job.onApplicationBootstrap(); @@ -120,9 +129,18 @@ describe('DemoResetJob', () => { }); it('AJOB-009: a first baseline that cannot be saved is logged, not thrown into the boot', async () => { + takeExampleTripsSeededMock.mockReturnValue(true); hasBaselineMock.mockReturnValue(false); saveBaselineMock.mockRejectedValueOnce(new Error('database or disk is full')); await expect(make(true).job.onApplicationBootstrap()).resolves.toBeUndefined(); expect(logMock.logError).toHaveBeenCalledWith('Demo baseline: database or disk is full'); }); + + it('AJOB-010: a demo boot that seeded nothing saves no baseline, even with none on disk', async () => { + // A database that already holds data (possibly user edits) and has no + // baseline must not become the reset target behind the admin's back. + hasBaselineMock.mockReturnValue(false); + await make(true).job.onApplicationBootstrap(); + expect(saveBaselineMock).not.toHaveBeenCalled(); + }); }); diff --git a/wiki/Demo-Mode.md b/wiki/Demo-Mode.md index 241e6c90d..d95082e86 100644 --- a/wiki/Demo-Mode.md +++ b/wiki/Demo-Mode.md @@ -54,7 +54,7 @@ The baseline is the snapshot the hourly reset restores to. The admin can update In the admin panel, a **Demo Baseline** card sits above the tabs while demo mode is on; its **Save Baseline** button calls this endpoint. The baseline captures the current state of the database, trip data, settings and encrypted API keys included, so demo features (maps, photos, weather) continue to work after each reset. It is written as a consistent snapshot and only then put in place of the previous baseline, so a save that fails leaves the previous one intact. -On first start with demo mode active, TREK seeds three example trips (Tokyo & Kyoto, Barcelona Long Weekend, New York City) owned by the admin and shared with the demo user. Once the server is up, a demo start that finds no baseline saves the initial one automatically. +On first start with demo mode active, TREK seeds three example trips (Tokyo & Kyoto, Barcelona Long Weekend, New York City) owned by the admin and shared with the demo user, then saves the initial baseline automatically once the server is up. Only that seeding start saves a baseline on its own: an instance that already holds data but has no baseline keeps skipping the hourly reset until the admin saves one. ## Limitations