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 { StoryGraphDto, StoryNodeDto, LevelTemplateOption, UtteranceDto } from './storyGraphRepository.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_storygraph_test_${process.pid}_${Date.now()}` let adminClient: InstanceType let appServer: Awaited['server'] let appPool: Awaited['pool'] let baseUrl = '' let auth = '' 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)) }) }) } const json = { 'content-type': 'application/json' } function admin(url: string, init: RequestInit = {}) { const headers = new Headers(init.headers); headers.set('authorization', auth) return fetch(url, { ...init, headers }) } suite('story graph authoring API', () => { 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-storygraph-jwt' 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}` auth = `Bearer ${jwt.sign({ sub: 'sg-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() }) async function makeMystery(slug: string) { await admin(`${baseUrl}/api/levels`, { method: 'POST', headers: json, body: JSON.stringify({ id: `${slug}-src`, title: 'SG Source' }) }) await admin(`${baseUrl}/api/levels/${slug}-src/templates?edit=1`, { method: 'POST', headers: json, body: JSON.stringify({ name: `${slug} chapter` }) }) await admin(`${baseUrl}/api/mysteries?edit=1`, { method: 'POST', headers: json, body: JSON.stringify({ slug, title: slug, chapters: [{ templateSlug: `${slug}-chapter` }], cast: [], cutscenes: [] }) }) const list = await (await admin(`${baseUrl}/api/admin/mysteries`)).json() as { id: string; slug: string }[] return list.find(m => m.slug === slug)!.id } it('builds a node graph: create, wire, configure, set entry', async () => { const mysteryId = await makeMystery('sg-mystery') const templates = await (await admin(`${baseUrl}/api/admin/level-templates`)).json() as LevelTemplateOption[] const chapter = templates.find(t => t.slug === 'sg-mystery-chapter')! const cutscene = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 40, ypos: 40 }) })).json() as StoryNodeDto const level = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'level', xpos: 320, ypos: 40 }) })).json() as StoryNodeDto expect(cutscene.terminals).toHaveLength(1) expect(cutscene.terminals[0].terminalKey).toBe('continue') expect(level.terminals[0].terminalKey).toBe('report_back') // Wire cutscene → level; configure level template; set entrypoint. expect((await admin(`${baseUrl}/api/admin/story-terminals/${cutscene.terminals[0].id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ toNodeId: level.id }) })).status).toBe(200) await admin(`${baseUrl}/api/admin/story-nodes/${level.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ levelTemplateVersionId: chapter.versionId }) }) await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/entry`, { method: 'PUT', headers: json, body: JSON.stringify({ nodeId: cutscene.id }) }) const graph = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`)).json() as StoryGraphDto expect(graph.nodes).toHaveLength(2) expect(graph.entryNodeId).toBe(cutscene.id) expect(graph.nodes.find(n => n.id === cutscene.id)!.terminals[0].toNodeId).toBe(level.id) expect(graph.nodes.find(n => n.id === level.id)!.levelTemplateVersionId).toBe(chapter.versionId) // Deleting the target node unwires (SET NULL) rather than deleting the source's port. await admin(`${baseUrl}/api/admin/story-nodes/${level.id}`, { method: 'DELETE' }) const after = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`)).json() as StoryGraphDto expect(after.nodes).toHaveLength(1) expect(after.nodes[0].terminals[0].toNodeId).toBeNull() }) it('rejects wiring a terminal across mysteries', async () => { const mysteryA = await makeMystery('sg-a') const mysteryB = await makeMystery('sg-b') const nodeA = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryA}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 0, ypos: 0 }) })).json() as StoryNodeDto const nodeB = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryB}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 0, ypos: 0 }) })).json() as StoryNodeDto const cross = await admin(`${baseUrl}/api/admin/story-terminals/${nodeA.terminals[0].id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ toNodeId: nodeB.id }) }) expect(cross.status).toBe(400) }) it('crafts utterances: NPC prompt, player option, wiring and same-node validation', async () => { const mysteryId = await makeMystery('sg-utt') const dialogue = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'dialogue', xpos: 0, ypos: 0 }) })).json() as StoryNodeDto const terminalId = dialogue.terminals[0].id const otherNode = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'level', xpos: 400, ypos: 0 }) })).json() as StoryNodeDto const prompt = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`, { method: 'POST', headers: json, body: JSON.stringify({ utterer: 'npc', xpos: 40, ypos: 40, text: 'Are you ready?' }) })).json() as UtteranceDto const yes = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`, { method: 'POST', headers: json, body: JSON.stringify({ utterer: 'player', xpos: 300, ypos: 40, text: 'Yes' }) })).json() as UtteranceDto // 'Yes' hangs under the prompt (parent); and exits via the node's terminal. expect((await admin(`${baseUrl}/api/admin/utterances/${yes.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ parentUtteranceId: prompt.id }) })).status).toBe(200) expect((await admin(`${baseUrl}/api/admin/utterances/${yes.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ terminalId }) })).status).toBe(200) const list = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`)).json() as UtteranceDto[] const savedYes = list.find(u => u.id === yes.id)! expect(savedYes.parentUtteranceId).toBe(prompt.id) expect(savedYes.terminalId).toBe(terminalId) // A terminal from another node cannot be used as this utterance's exit. const foreign = await admin(`${baseUrl}/api/admin/utterances/${yes.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ terminalId: otherNode.terminals[0].id }) }) expect(foreign.status).toBe(400) // Deleting the NPC prompt cascades its player options. await admin(`${baseUrl}/api/admin/utterances/${prompt.id}`, { method: 'DELETE' }) const after = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`)).json() as UtteranceDto[] expect(after).toHaveLength(0) }) it('refuses graph writes without an admin claim', async () => { const mysteryId = await makeMystery('sg-guard') expect((await fetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 0, ypos: 0 }) })).status).toBe(403) }) })