import { createServer } from 'node:net' import { randomUUID } from 'node:crypto' 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 let appServer: Awaited['server'] let appPool: Awaited['pool'] let baseUrl = '' 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('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((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('persists one normalized level across authoring and play views', async () => { 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 } const documentId = randomUUID() const folderId = randomUUID() const noteId = randomUUID() const eventId = randomUUID() 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' }, ] } 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 }, { 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 }, ] state.relations = [{ id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }] state.connections = [{ id: randomUUID(), fromEvidenceId: folderId, toEvidenceId: noteId }] 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) 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 } }) expect(loaded.brief.concepts).toEqual(expect.arrayContaining([ expect.objectContaining({ label: 'Ada Lovelace', expectedPartyKind: 'person' }), expect.objectContaining({ label: 'Analytical Engines Ltd', expectedPartyKind: 'organization' }), ])) 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] expect(uploaded).toMatchObject({ title: 'smoke-evidence.txt', fileName: 'smoke-evidence.txt', mimeType: 'text/plain', fileType: 'text' }) 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 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 })) 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) 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 }) 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 (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, (SELECT COUNT(*) FROM osint.exhibit_metadata_text_values)::text AS metadata, (SELECT COUNT(*) FROM osint.exhibit_sources)::text AS sources, (SELECT COUNT(*) FROM osint.exhibit_connections)::text AS connections, (SELECT COUNT(*) FROM osint.event_exhibits)::text AS events, (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' }) const resetResponse = await fetch(`${baseUrl}/api/levels/${state.id}/reset`, { method: 'POST' }) expect(resetResponse.ok).toBe(true) const resetState = await resetResponse.json() as CaseState expect(resetState.viewport).toEqual(playerState.viewport) expect(resetState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 }) 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' }) 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) 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']) 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 }) }) })