2026-08-14 12:57:06 +02:00
import { createServer } from 'node:net'
2026-08-14 14:09:24 +02:00
import { randomUUID } from 'node:crypto'
2026-08-14 12:57:06 +02:00
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import pg from 'pg'
import { afterAll , beforeAll , describe , expect , it } from 'vitest'
import type { CaseState } from '../src/types.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_api_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 = ''
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 ))
})
})
}
suite ( 'level persistence 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 ()
const migrationsDir = path . resolve ( path . dirname ( fileURLToPath ( import . meta . url )), '..' , 'migrations' )
await runMigrations ( databaseUrl , migrationsDir , () => undefined )
const port = await availablePort ()
process . env . DATABASE_URL = databaseUrl
process . env . LEVEL_EDITING_ENABLED = 'true'
process . env . PORT = String ( port )
const serverModule = await import ( './index.js' )
appServer = serverModule . server
appPool = serverModule . pool
baseUrl = `http://127.0.0.1: ${ port } `
})
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 ()
})
2026-08-14 14:09:24 +02:00
it ( 'persists one normalized level across authoring and play views' , async () => {
2026-08-14 12:57:06 +02:00
const createResponse = await fetch ( ` ${ baseUrl } /api/levels` , {
method : 'POST' ,
headers : { 'content-type' : 'application/json' },
body : JSON.stringify ({ id : 'api-smoke-level' , title : 'API Smoke Level' }),
})
expect ( createResponse . status ). toBe ( 201 )
const state = await createResponse . json () as CaseState
state . viewport = { x : 91 , y : - 42 , zoom : 0.85 }
2026-08-14 14:09:24 +02:00
const documentId = randomUUID ()
const folderId = randomUUID ()
2026-08-14 14:17:54 +02:00
const noteId = randomUUID ()
2026-08-14 14:31:52 +02:00
const eventId = randomUUID ()
2026-08-14 14:44:58 +02:00
const personConceptId = randomUUID ()
const organizationConceptId = randomUUID ()
state . brief = { body : 'Identify Ada Lovelace and Analytical Engines Ltd in the source material.' , concepts : [
{ id : personConceptId , label : 'Ada Lovelace' , context : 'Named as the correspondent.' , expectedPartyKind : 'person' },
{ id : organizationConceptId , label : 'Analytical Engines Ltd' , context : 'Issued the filing.' , expectedPartyKind : 'organization' },
] }
2026-08-14 14:17:54 +02:00
state . documents = [{ id : documentId , title : 'Evidence' , kind : 'IMAGE' , date : '2021-04-17' , publishedAt : '2021-04-17T12:00:00Z' , body : [ 'Extracted body' ], regions : [{ id : 'stamp' , label : 'Date stamp' , excerpt : '17 April 2021' , date : '2021-04-17T09:00:00Z' }], fileType : 'image' , metadata : {} }]
state . evidence = [
{ id : folderId , type : 'folder' , title : 'Folder' , content : 'Evidence folder' , x : 685 , y : 417 , width : 260 , config : { open : true }, containedDocumentIds : [ documentId ] },
{ id : noteId , type : 'note' , title : 'Extract' , content : 'Date matters' , sourceDocumentId : documentId , sourceRegionId : 'stamp' , x : 420 , y : 300 , width : 108 },
2026-08-14 14:31:52 +02:00
{ id : eventId , type : 'event' , title : 'The meeting occurred' , content : 'The evidence places the meeting on 18 April.' , eventDate : '2021-04-18T14:30:00Z' , supportingEvidenceIds : [ documentId , noteId ], x : 520 , y : 610 , width : 270 },
2026-08-14 14:17:54 +02:00
]
2026-08-14 14:09:24 +02:00
state . relations = [{ id : `contains: ${ folderId } : ${ documentId } ` , fromWidgetId : folderId , toWidgetId : documentId , type : 'contains' , sortOrder : 0 , config : { x : 1051 , y : 417 } }]
2026-08-14 14:17:54 +02:00
state . connections = [{ id : randomUUID (), fromEvidenceId : folderId , toEvidenceId : noteId }]
2026-08-14 12:57:06 +02:00
const saveResponse = await fetch ( ` ${ baseUrl } /api/levels/ ${ state . id } ?edit=1` , {
method : 'PUT' ,
headers : { 'content-type' : 'application/json' },
body : JSON.stringify ( state ),
})
expect ( await saveResponse . json ()). toEqual ({ ok : true , mode : 'author' })
const loaded = await ( await fetch ( ` ${ baseUrl } /api/levels/ ${ state . id } ?edit=1` )). json () as CaseState
expect ( loaded . viewport ). toEqual ( state . viewport )
2026-08-14 14:09:24 +02:00
expect ( loaded . evidence [ 0 ]). toMatchObject ({ id : folderId , x : 685 , y : 417 , config : { open : true } })
expect ( loaded . relations [ 0 ]). toMatchObject ({ id : `contains: ${ folderId } : ${ documentId } ` , config : { x : 1051 , y : 417 } })
2026-08-14 14:44:58 +02:00
expect ( loaded . brief . concepts ). toEqual ( expect . arrayContaining ([
expect . objectContaining ({ label : 'Ada Lovelace' , expectedPartyKind : 'person' }),
expect . objectContaining ({ label : 'Analytical Engines Ltd' , expectedPartyKind : 'organization' }),
]))
2026-08-14 13:43:37 +02:00
const upload = new FormData ()
upload . append ( 'file' , new Blob ([ 'OSINT smoke evidence' ], { type : 'text/plain' }), 'smoke-evidence.txt' )
const uploadResponse = await fetch ( ` ${ baseUrl } /api/levels/ ${ state . id } /documents?edit=1` , { method : 'POST' , body : upload })
expect ( uploadResponse . status ). toBe ( 201 )
const uploaded = await uploadResponse . json () as CaseState [ 'documents' ][ number ]
2026-08-14 14:09:24 +02:00
expect ( uploaded ). toMatchObject ({ title : 'smoke-evidence.txt' , fileName : 'smoke-evidence.txt' , mimeType : 'text/plain' , fileType : 'text' })
2026-08-14 13:43:37 +02:00
expect ( uploaded . assetId ). toBeTruthy ()
expect ( await ( await fetch ( ` ${ baseUrl } /api/assets/ ${ uploaded . assetId } ` )). text ()). toBe ( 'OSINT smoke evidence' )
const withUpload = await ( await fetch ( ` ${ baseUrl } /api/levels/ ${ state . id } ?edit=1` )). json () as CaseState
const uploadedDocument = withUpload . documents . find ( document => document . id === uploaded . id ) !
uploadedDocument . title = 'Renamed smoke evidence'
uploadedDocument . publishedAt = '2022-06-15T10:30:00.000Z'
uploadedDocument . metadata = { witness : 'Integration test' , confidence : 'high' }
const metadataSave = await fetch ( ` ${ baseUrl } /api/levels/ ${ state . id } ?edit=1` , {
method : 'PUT' ,
headers : { 'content-type' : 'application/json' },
body : JSON.stringify ( withUpload ),
})
expect ( metadataSave . ok ). toBe ( true )
const afterMetadataSave = await ( await fetch ( ` ${ baseUrl } /api/levels/ ${ state . id } ?edit=1` )). json () as CaseState
expect ( afterMetadataSave . documents . find ( document => document . id === uploaded . id )). toMatchObject ({
title : 'Renamed smoke evidence' ,
publishedAt : '2022-06-15T10:30:00.000Z' ,
metadata : { witness : 'Integration test' , confidence : 'high' },
})
const playerState = await ( await fetch ( ` ${ baseUrl } /api/levels/ ${ state . id } ` )). json () as CaseState
2026-08-14 14:44:58 +02:00
expect ( playerState . brief . concepts . every ( concept => concept . expectedPartyKind === undefined )). toBe ( true )
const personPartyId = randomUUID ()
const organizationPartyId = randomUUID ()
playerState . evidence . push (
{ id : personPartyId , type : 'party' , partyKind : 'person' , title : 'Ada Lovelace' , content : 'Named as the correspondent.' , aliases : [ 'A. A. L.' ], relatedEvidenceIds : [ documentId , noteId ], x : 720 , y : 250 , width : 280 },
{ id : organizationPartyId , type : 'party' , partyKind : 'organization' , organizationKind : 'business' , title : 'Analytical Engines Ltd' , content : 'Issued the filing.' , aliases : [ 'AEL' ], relatedEvidenceIds : [ documentId ], x : 1020 , y : 250 , width : 280 },
)
playerState . brief . concepts = playerState . brief . concepts . map ( concept => ({ ... concept ,
resolvedPartyExhibitId : concept.id === personConceptId ? personPartyId : organizationPartyId }))
2026-08-14 13:43:37 +02:00
playerState . viewport = { x : - 150 , y : 88 , zoom : 1.1 }
playerState . evidence [ 0 ] = { ... playerState . evidence [ 0 ], x : 812 , y : 533 }
const playerSave = await fetch ( ` ${ baseUrl } /api/levels/ ${ state . id } ` , {
method : 'PUT' ,
headers : { 'content-type' : 'application/json' },
body : JSON.stringify ( playerState ),
})
expect ( await playerSave . json ()). toEqual ({ ok : true , mode : 'play' })
const savedPlayerState = await ( await fetch ( ` ${ baseUrl } /api/levels/ ${ state . id } ` )). json () as CaseState
expect ( savedPlayerState . viewport ). toEqual ( playerState . viewport )
2026-08-14 14:09:24 +02:00
expect ( savedPlayerState . evidence [ 0 ]). toMatchObject ({ id : folderId , x : 812 , y : 533 })
const sameLevelInEditView = await ( await fetch ( ` ${ baseUrl } /api/levels/ ${ state . id } ?edit=1` )). json () as CaseState
expect ( sameLevelInEditView . viewport ). toEqual ( playerState . viewport )
expect ( sameLevelInEditView . evidence [ 0 ]). toMatchObject ({ id : folderId , x : 812 , y : 533 })
2026-08-14 14:44:58 +02:00
const normalized = await appPool . query < { exhibits : string ; documents : string ; folders : string ; memberships : string ; metadata : string ; sources : string ; connections : string ; events : string ; event_evidence : string ; parties : string ; people : string ; organizations : string ; party_evidence : string ; concepts : string ; hidden_answers : string } > ( `SELECT
2026-08-14 14:09:24 +02:00
(SELECT COUNT(*) FROM osint.exhibits)::text AS exhibits,
(SELECT COUNT(*) FROM osint.document_exhibits)::text AS documents,
(SELECT COUNT(*) FROM osint.folder_exhibits)::text AS folders,
(SELECT COUNT(*) FROM osint.folder_memberships)::text AS memberships,
2026-08-14 14:17:54 +02:00
(SELECT COUNT(*) FROM osint.exhibit_metadata_text_values)::text AS metadata,
(SELECT COUNT(*) FROM osint.exhibit_sources)::text AS sources,
2026-08-14 14:31:52 +02:00
(SELECT COUNT(*) FROM osint.exhibit_connections)::text AS connections,
(SELECT COUNT(*) FROM osint.event_exhibits)::text AS events,
2026-08-14 14:44:58 +02:00
(SELECT COUNT(*) FROM osint.event_evidence)::text AS event_evidence,
(SELECT COUNT(*) FROM osint.party_exhibits)::text AS parties,
(SELECT COUNT(*) FROM osint.person_parties)::text AS people,
(SELECT COUNT(*) FROM osint.organization_parties)::text AS organizations,
(SELECT COUNT(*) FROM osint.party_evidence)::text AS party_evidence,
(SELECT COUNT(*) FROM osint.brief_concepts)::text AS concepts,
(SELECT COUNT(*) FROM osint.brief_concepts WHERE expected_party_kind IS NOT NULL)::text AS hidden_answers` )
expect ( normalized . rows [ 0 ]). toEqual ({ exhibits : '7' , documents : '2' , folders : '1' , memberships : '1' , metadata : '2' , sources : '1' , connections : '1' , events : '1' , event_evidence : '2' , parties : '2' , people : '1' , organizations : '1' , party_evidence : '3' , concepts : '2' , hidden_answers : '2' })
2026-08-14 13:43:37 +02:00
const resetResponse = await fetch ( ` ${ baseUrl } /api/levels/ ${ state . id } /reset` , { method : 'POST' })
expect ( resetResponse . ok ). toBe ( true )
const resetState = await resetResponse . json () as CaseState
2026-08-14 14:09:24 +02:00
expect ( resetState . viewport ). toEqual ( playerState . viewport )
expect ( resetState . evidence [ 0 ]). toMatchObject ({ id : folderId , x : 812 , y : 533 })
2026-08-14 14:17:54 +02:00
const templateResponse = await fetch ( ` ${ baseUrl } /api/levels/ ${ state . id } /templates?edit=1` , {
method : 'POST' , headers : { 'content-type' : 'application/json' }, body : JSON.stringify ({ name : 'Smoke Template' }),
})
expect ( templateResponse . status ). toBe ( 201 )
expect ( await templateResponse . json ()). toMatchObject ({ slug : 'smoke-template' , currentVersion : 1 , versionCount : 1 })
expect ( await ( await fetch ( ` ${ baseUrl } /api/templates` )). json ()). toEqual ([
expect . objectContaining ({ slug : 'smoke-template' , currentVersion : 1 , versionCount : 1 }),
])
const changedSource = structuredClone ( savedPlayerState )
changedSource . title = 'Changed after template freeze'
changedSource . evidence [ 0 ]. x = 999
await fetch ( ` ${ baseUrl } /api/levels/ ${ state . id } ?edit=1` , {
method : 'PUT' , headers : { 'content-type' : 'application/json' }, body : JSON.stringify ( changedSource ),
})
const cloneResponse = await fetch ( ` ${ baseUrl } /api/templates/smoke-template/levels?edit=1` , {
method : 'POST' , headers : { 'content-type' : 'application/json' }, body : JSON.stringify ({ id : 'smoke-template-copy' , title : 'Playable copy' }),
})
expect ( cloneResponse . status ). toBe ( 201 )
const clone = await cloneResponse . json () as CaseState
expect ( clone ). toMatchObject ({ id : 'smoke-template-copy' , title : 'Playable copy' , sourceTemplateVersionId : expect.any ( String ) })
expect ( clone . evidence . find ( item => item . type === 'folder' )). toMatchObject ({ x : 812 , y : 533 })
expect ( clone . documents . find ( item => item . title === 'Renamed smoke evidence' ) ? . assetId ). toBe ( uploaded . assetId )
expect ( clone . documents [ 0 ]. id ). not . toBe ( savedPlayerState . documents [ 0 ]. id )
expect ( clone . evidence . map ( item => item . id )). not . toContain ( folderId )
expect ( clone . connections ). toHaveLength ( 1 )
expect ( clone . evidence . find ( item => item . type === 'note' )). toMatchObject ({ sourceRegionId : 'stamp' })
2026-08-14 14:31:52 +02:00
const clonedEvent = clone . evidence . find ( item => item . type === 'event' ) !
expect ( clonedEvent ). toMatchObject ({ title : 'The meeting occurred' , eventDate : '2021-04-18T14:30:00.000Z' })
expect ( clonedEvent . supportingEvidenceIds ). toHaveLength ( 2 )
expect ( clonedEvent . supportingEvidenceIds ). not . toContain ( documentId )
expect ( clonedEvent . supportingEvidenceIds ). not . toContain ( noteId )
2026-08-14 14:44:58 +02:00
expect ( clone . evidence . filter ( item => item . type === 'party' )). toHaveLength ( 2 )
expect ( clone . brief . concepts . every ( concept => Boolean ( concept . resolvedPartyExhibitId ))). toBe ( true )
expect ( clone . brief . concepts . map ( concept => concept . resolvedPartyExhibitId )). not . toContain ( personPartyId )
const authoredClone = await ( await fetch ( ` ${ baseUrl } /api/levels/ ${ clone . id } ?edit=1` )). json () as CaseState
expect ( authoredClone . brief . concepts . map ( concept => concept . expectedPartyKind ). sort ()). toEqual ([ 'organization' , 'person' ])
2026-08-14 14:17:54 +02:00
const clonedFolder = clone . evidence . find ( item => item . type === 'folder' ) !
clonedFolder . x = 1234
clone . viewport = { x : 333 , y : 222 , zoom : 1.2 }
await fetch ( ` ${ baseUrl } /api/levels/ ${ clone . id } ` , {
method : 'PUT' , headers : { 'content-type' : 'application/json' }, body : JSON.stringify ( clone ),
})
const cloneReset = await ( await fetch ( ` ${ baseUrl } /api/levels/ ${ clone . id } /reset` , { method : 'POST' })). json () as CaseState
expect ( cloneReset ). toMatchObject ({ title : 'API Smoke Level' , viewport : { x : 0 , y : 28 , zoom : 0.7 } })
expect ( cloneReset . evidence . find ( item => item . type === 'folder' )). toMatchObject ({ x : 812 , y : 533 })
expect ( cloneReset . evidence . map ( item => item . id )). not . toContain ( clonedFolder . id )
const versionTwoResponse = await fetch ( ` ${ baseUrl } /api/levels/ ${ state . id } /templates?edit=1` , {
method : 'POST' , headers : { 'content-type' : 'application/json' }, body : JSON.stringify ({ name : 'Smoke Template' }),
})
expect ( await versionTwoResponse . json ()). toMatchObject ({ currentVersion : 2 , versionCount : 2 })
const oldVersion = await ( await fetch ( ` ${ baseUrl } /api/templates/smoke-template/levels?edit=1` , {
method : 'POST' , headers : { 'content-type' : 'application/json' }, body : JSON.stringify ({ id : 'old-version-copy' , version : 1 }),
})). json () as CaseState
const currentVersion = await ( await fetch ( ` ${ baseUrl } /api/templates/smoke-template/levels?edit=1` , {
method : 'POST' , headers : { 'content-type' : 'application/json' }, body : JSON.stringify ({ id : 'current-version-copy' }),
})). json () as CaseState
expect ( oldVersion . evidence . find ( item => item . type === 'folder' )). toMatchObject ({ x : 812 })
expect ( currentVersion . evidence . find ( item => item . type === 'folder' )). toMatchObject ({ x : 999 })
2026-08-14 12:57:06 +02:00
})
})