import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import Database from 'better-sqlite3'; import { readFile } from 'node:fs/promises'; import { newFixture, type Fixture } from '../helpers/fixture.js'; let fixture: Fixture; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-09-28T12:00:00Z')); }); afterEach(async () => { await fixture.cleanup(); vi.useRealTimers(); }); async function setup() { const { db } = await import('../../src/lib/server/db/index.js'); const handle = db(); handle.prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by) VALUES ('operation', 'server', 'create', 'waiting', 'Recorded stage', '2026-09-28T12:00:00Z', 'test')`).run(); const { recordProgress, readProgress } = await import('../../src/lib/server/provisioning/progress.js'); const read = () => readProgress(handle.prepare('SELECT observation_json FROM server_deployments WHERE id = ?').get('operation') as { observation_json: string }); return { handle, recordProgress, read }; } describe('provisioning evidence persistence', () => { it('keeps phase duration stable and avoids semantic event spam during repeated occupancy checks', async () => { const { handle, recordProgress, read } = await setup(); for (let players = 4; players > 0; players--) { recordProgress('operation', { phase: 'eligibility', blocker: { code: 'occupied', message: `${players} players remain`, automatic: true }, checks: [{ key: 'occupancy', label: 'Players', source: 'telemetry', result: 'waiting', actual: players, receivedAt: new Date().toISOString() }] }); vi.setSystemTime(Date.now() + 15_000); } expect(read()).toMatchObject({ phaseStartedAt: '2026-09-28T12:00:00.000Z', observedAt: '2026-09-28T12:00:45.000Z', checks: [expect.objectContaining({ actual: 1, receivedAt: '2026-09-28T12:00:45.000Z' })] }); expect(handle.prepare("SELECT count(*) AS count FROM server_deployment_events WHERE kind = 'blocker'").get()).toEqual({ count: 1 }); recordProgress('operation', { phase: 'container-action', blocker: null, checkpoint: { key: 'eligibility', label: 'Eligibility checked', phase: 'eligibility' } }); expect(read()).toMatchObject({ phaseStartedAt: '2026-09-28T12:01:00.000Z', blocker: null }); }); it('replaces transient checks while retaining confirmed milestones and prior command uncertainty', async () => { const { handle, recordProgress, read } = await setup(); recordProgress('operation', { phase: 'verify-game', command: { state: 'uncertain', message: 'Reply was lost' }, checkpoint: { key: 'container-action', label: 'Submission attempted', phase: 'container-action' }, checks: [{ key: 'container', label: 'Container', source: 'agent', result: 'pass' }, { key: 'game', label: 'Game', source: 'telemetry', result: 'pass' }] }); vi.setSystemTime(Date.now() + 15_000); recordProgress('operation', { phase: 'verify-container', checks: [{ key: 'container', label: 'Container', source: 'agent', result: 'unknown' }] }); expect(read()?.checks).toHaveLength(1); expect(read()?.checks[0].result).toBe('unknown'); expect(read()?.checkpoints).toHaveLength(1); expect(read()?.command?.state).toBe('uncertain'); expect(handle.prepare("SELECT message FROM server_deployment_events WHERE kind = 'command'").get()).toEqual({ message: 'Reply was lost' }); }); it('preserves Plutonium release and runtime evidence when later progress is recorded', async () => { const { recordProgress, read } = await setup(); recordProgress('operation', { phase: 'verify-plutonium', checks: [ { key: 'plutonium-release', label: 'Production Plutonium release', result: 'pass', source: 'manifest', actual: 5200 }, { key: 'plutonium-version', label: 'Running Plutonium revision', result: 'pass', source: 'rcon', expected: 5200, actual: 5200 } ], checkpoint: { key: 'verify-plutonium', label: 'Plutonium revision verified' } }); vi.setSystemTime(Date.now() + 15_000); recordProgress('operation', { blocker: null }); expect(read()).toMatchObject({ phase: 'verify-plutonium', phaseStartedAt: '2026-09-28T12:00:00.000Z', checks: [ { key: 'plutonium-release', result: 'pass', source: 'manifest', actual: 5200 }, { key: 'plutonium-version', result: 'pass', source: 'rcon', expected: 5200, actual: 5200 } ], checkpoints: [{ key: 'verify-plutonium', phase: 'verify-plutonium' }] }); }); it('keeps operation-owned recovery evidence after slot/server removal and does not rewrite the failed outcome', async () => { const { handle, recordProgress, read } = await setup(); handle.prepare("UPDATE server_deployments SET state = 'failed', finished_at = '2026-09-28T12:00:00Z' WHERE id = 'operation'").run(); recordProgress('operation', { recovery: { state: 'held', message: 'Observing possible submission' } }); vi.setSystemTime(Date.now() + 30_000); recordProgress('operation', { recovery: { state: 'released', message: 'Recovery verified' } }); recordProgress('operation', { recovery: { state: 'released', message: 'Recovery verified' } }); expect(read()?.recovery).toMatchObject({ state: 'released', at: '2026-09-28T12:00:30.000Z' }); expect(handle.prepare("SELECT state, finished_at FROM server_deployments WHERE id = 'operation'").get()) .toEqual({ state: 'failed', finished_at: '2026-09-28T12:00:00Z' }); expect(handle.prepare("SELECT count(*) AS count FROM server_deployment_events WHERE kind = 'recovery'").get()).toEqual({ count: 2 }); }); it('rolls back state, snapshot, and milestone events together with its caller transaction', async () => { const { handle, recordProgress, read } = await setup(); expect(() => handle.transaction(() => { handle.prepare("UPDATE server_deployments SET state = 'running' WHERE id = 'operation'").run(); recordProgress('operation', { phase: 'cleanup', checkpoint: { key: 'container-action', label: 'Remote deletion confirmed' } }); throw new Error('Interrupted transaction'); })()).toThrow('Interrupted transaction'); expect(read()).toBeNull(); expect(handle.prepare("SELECT state FROM server_deployments WHERE id = 'operation'").get()).toEqual({ state: 'waiting' }); expect(handle.prepare('SELECT * FROM server_deployment_events').all()).toEqual([]); }); it('treats unsupported or malformed historical snapshots as unrecorded', async () => { const { readProgress } = await import('../../src/lib/server/provisioning/progress.js'); for (const observation_json of [null, '', '[]', '{broken', '{"version":2}', '{"version":1,"checks":"pass"}']) { expect(readProgress({ observation_json })).toBeNull(); } }); it('adds nullable evidence columns without losing historical identity, row order, or events across reopen', async () => { const schema = (await readFile(new URL('../../src/lib/server/db/schema.sql', import.meta.url), 'utf8')) .replace(',\n phase_key TEXT,\n phase_started_at TEXT,\n observed_at TEXT,\n observation_json TEXT', '') .replace(',\n kind TEXT,\n phase_key TEXT,\n details_json TEXT', '') .replace("'create', 'start', 'restart', 'rebuild', 'stop', 'destroy'", "'create', 'destroy'"); const old = new Database(fixture.dbPath); old.exec(schema); old.prepare(`INSERT INTO server_deployments (rowid,id,server,server_deleted_at,action,state,stage,started_at,triggered_by) VALUES (42,'legacy','server','2026-09-01','create','done','Recorded result','2026-08-01','test')`).run(); old.prepare("INSERT INTO server_deployment_events (id,deployment_id,created_at,message) VALUES (19,'legacy','2026-08-01','Original evidence')").run(); old.close(); const { db, closeDb } = await import('../../src/lib/server/db/index.js'); expect(db().prepare("SELECT rowid, server_deleted_at, observation_json FROM server_deployments WHERE id = 'legacy'").get()) .toEqual({ rowid: 42, server_deleted_at: '2026-09-01', observation_json: null }); const { recordProgress, readProgress } = await import('../../src/lib/server/provisioning/progress.js'); recordProgress('legacy', { recovery: { state: 'unknown', message: 'Historical slot release was not recorded' } }); closeDb(); const saved = db().prepare("SELECT observation_json FROM server_deployments WHERE id = 'legacy'").get() as { observation_json: string }; expect(readProgress(saved)?.recovery?.state).toBe('unknown'); expect(db().prepare('SELECT id, message FROM server_deployment_events WHERE id = 19').get()).toEqual({ id: 19, message: 'Original evidence' }); expect(db().pragma('foreign_key_check')).toEqual([]); }); });