143 lines
9.2 KiB
TypeScript
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)
|
||
|
|
})
|
||
|
|
})
|