import { createServer } from 'node:net' import path from 'node:path' import { fileURLToPath } from 'node:url' import pg from 'pg' import jwt from 'jsonwebtoken' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import type { PlaythroughState } from './narrativeRepository.js' import { runMigrations } from './migrations.js' const { Client } = pg const baseDatabaseUrl = process.env.TEST_DATABASE_URL const suite = baseDatabaseUrl ? describe : describe.skip const databaseName = `osint_narrative_test_${process.pid}_${Date.now()}` let adminClient: InstanceType let appServer: Awaited['server'] let appPool: Awaited['pool'] let baseUrl = '' let adminAuthorization = '' function authFetch(url: string, authorization?: string, init: RequestInit = {}) { const headers = new Headers(init.headers) if (authorization) headers.set('authorization', authorization) return fetch(url, { ...init, headers }) } async function availablePort() { return new Promise((resolve, reject) => { const probe = createServer() probe.once('error', reject) probe.listen(0, '127.0.0.1', () => { const address = probe.address() const port = typeof address === 'object' && address ? address.port : 0 probe.close(error => error ? reject(error) : resolve(port)) }) }) } suite('narrative graph runtime', () => { beforeAll(async () => { const adminUrl = new URL(baseDatabaseUrl!) adminUrl.pathname = '/postgres' adminClient = new Client({ connectionString: adminUrl.toString() }) await adminClient.connect() await adminClient.query(`CREATE DATABASE "${databaseName}"`) const testUrl = new URL(baseDatabaseUrl!) testUrl.pathname = `/${databaseName}` const databaseUrl = testUrl.toString() await runMigrations(databaseUrl, path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations'), () => undefined) const port = await availablePort() process.env.DATABASE_URL = databaseUrl process.env.LEVEL_EDITING_ENABLED = 'true' process.env.JWT_SECRET = 'osint-narrative-jwt-secret' process.env.ASSET_STORAGE_DRIVER = 'memory' process.env.PORT = String(port) const serverModule = await import('./index.js') appServer = serverModule.server appPool = serverModule.pool baseUrl = `http://127.0.0.1:${port}` adminAuthorization = `Bearer ${jwt.sign({ sub: 'integration-admin', role: 'admin' }, process.env.JWT_SECRET)}` }) afterAll(async () => { if (appServer) await new Promise((resolve, reject) => appServer.close(error => error ? reject(error) : resolve())) if (appPool) await appPool.end() if (!adminClient) return await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`) await adminClient.end() }) it('New Game walks the seeded graph: cutscene → dialogue → level → finished', async () => { const json = { 'content-type': 'application/json' } // A frozen level template to back the level node. await authFetch(`${baseUrl}/api/levels`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({ id: 'gr-src', title: 'Runtime Source' }) }) await authFetch(`${baseUrl}/api/levels/gr-src/templates?edit=1`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({ name: 'Runtime Chapter', slug: 'gr-mystery-chapter' }) }) // Mystery + cast, then seed a linear graph. await authFetch(`${baseUrl}/api/mysteries?edit=1`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({ slug: 'gr-mystery', title: 'Runtime Mystery', cast: [{ key: 'prof', name: 'Prof. Test', role: 'GU' }] }) }) const mysteries = await (await authFetch(`${baseUrl}/api/admin/mysteries`, adminAuthorization)).json() as { id: string; slug: string }[] const mysteryId = mysteries.find(m => m.slug === 'gr-mystery')!.id const seed = await authFetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({ entry: 'intro', nodes: [ { key: 'intro', type: 'cutscene', label: 'Title', componentKey: 'runtime-title', x: 0, y: 0, terminals: [{ key: 'continue', to: 'brief' }] }, { key: 'brief', type: 'dialogue', label: 'Briefing', x: 200, y: 0, terminals: [{ key: 'continue', to: 'level' }], utterances: [{ npc: 'prof', text: 'Welcome.' }, { npc: 'prof', text: 'Investigate.' }] }, { key: 'level', type: 'level', label: 'Board', templateSlug: 'gr-mystery-chapter', x: 400, y: 0, terminals: [{ key: 'report_back', to: 'debrief' }] }, { key: 'debrief', type: 'dialogue', label: 'Debrief', x: 600, y: 0, terminals: [{ key: 'continue', to: null }], utterances: [{ npc: 'prof', text: 'Case closed.' }] }, ], }) }) expect(seed.status).toBe(201) // New Game lands on the entry cutscene. const created = await authFetch(`${baseUrl}/api/playthroughs`, undefined, { method: 'POST', headers: json, body: '{}' }) expect(created.status).toBe(201) const start = await created.json() as PlaythroughState expect(start.node?.kind).toBe('cutscene') expect(start.node?.componentKey).toBe('runtime-title') const id = start.playthrough.id // Advance into the briefing dialogue (NPC utterances become steps). const brief = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState expect(brief.node?.kind).toBe('dialogue') expect(brief.node?.utterances).toHaveLength(2) const root = brief.node?.utterances?.find(u => u.id === brief.node?.rootId) expect(root).toMatchObject({ text: 'Welcome.', speaker: { name: 'Prof. Test' } }) expect(root?.childIds).toHaveLength(1) // linear parent-chain // Advance into the level (a board is instantiated and loadable). const level = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState expect(level.node?.kind).toBe('level') expect(level.node?.levelSlug).toMatch(/^gr-mystery-play-/) expect((await fetch(`${baseUrl}/api/levels/${level.node!.levelSlug}`)).status).toBe(200) // Report back → debrief dialogue. const debrief = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState expect(debrief.node?.kind).toBe('dialogue') // Final advance → finished; current returns nothing active. const done = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState expect(done.playthrough.status).toBe('finished') expect(done.node).toBeNull() expect((await authFetch(`${baseUrl}/api/playthroughs/current`, undefined)).status).toBe(204) // Identity scoping: another user has no playthrough and cannot advance this one. const playerTwo = `Bearer ${jwt.sign({ sub: 'player-two' }, process.env.JWT_SECRET!)}` expect((await authFetch(`${baseUrl}/api/playthroughs/current`, playerTwo)).status).toBe(204) expect((await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, playerTwo, { method: 'POST', headers: json, body: '{}' })).status).toBe(404) }) })