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()
|
|
|
|
|
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: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 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
|
|
|
|
|
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:17:54 +02:00
|
|
|
const normalized = await appPool.query<{ exhibits: string; documents: string; folders: string; memberships: string; metadata: string; sources: string; connections: 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,
|
|
|
|
|
(SELECT COUNT(*) FROM osint.exhibit_connections)::text AS connections`)
|
|
|
|
|
expect(normalized.rows[0]).toEqual({ exhibits: '4', documents: '2', folders: '1', memberships: '1', metadata: '2', sources: '1', connections: '1' })
|
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' })
|
|
|
|
|
|
|
|
|
|
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
|
|
|
})
|
|
|
|
|
})
|