Files
gupi-osint-board/server/storyGraph.integration.test.ts
T
gitprovandClaude Opus 4.8 ddb3a386f0 Add story-graph narrative system (campaigns, editors, runtime)
Introduce the narrative layer as a directed story-flow graph: an authored
campaign a player walks node by node, replacing the interim slot/chapter model.

Schema (migrations 015-021):
- mysteries, global NPC templates + named poses, per-user playthroughs
- story_nodes, terminals, utterances (the flow graph and dialogue trees)
- clean cutover: retire slot cutscenes/chapters/seen_dialogue

Runtime:
- New Game creates a playthrough bound to the JWT identity (dev test-user fallback)
- advance() walks the graph cutscene -> dialogue -> level -> ..., auto-skipping gates
- branching dialogue: player choices route out through node terminals

Admin authoring:
- NPC editor: upload named poses to the gupi MinIO bucket
- mystery graph editor: vertical node canvas, wiring, entrypoint, delete-by-click
- dialogue crafter: utterance tree, Tab to add child, 1/2 speaker, undo

Content authored via the manifest importer / admin panel and seeded for Glass
Harbour. MinIO added to the dev stack; dev container runs in development mode.

Also includes a folder-widget simplification (removes open/close) and a
resolveUserId auth helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 15:37:46 +02:00

143 lines
9.2 KiB
TypeScript

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<typeof Client>
let appServer: Awaited<typeof import('./index.js')>['server']
let appPool: Awaited<typeof import('./index.js')>['pool']
let baseUrl = ''
let auth = ''
async function availablePort() {
return new Promise<number>((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<void>((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)
})
})