test: complete persistence and browser safety net

This commit is contained in:
2026-08-14 13:43:37 +02:00
parent 9a4da41b49
commit 545eb013bd
14 changed files with 392 additions and 120 deletions
+47 -2
View File
@@ -54,12 +54,11 @@ suite('level persistence API', () => {
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('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1', [databaseName])
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
await adminClient.end()
})
it('persists authored viewport, exhibits, and relation positions', async () => {
it('persists authoring, uploads, player state, and reset behavior', async () => {
const createResponse = await fetch(`${baseUrl}/api/levels`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -83,5 +82,51 @@ suite('level persistence API', () => {
expect(loaded.viewport).toEqual(state.viewport)
expect(loaded.evidence[0]).toMatchObject({ id: 'folder-1', x: 685, y: 417, config: { open: true } })
expect(loaded.relations[0]).toMatchObject({ id: 'membership-1', config: { x: 1051, y: 417 } })
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: 'file' })
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)
expect(savedPlayerState.evidence[0]).toMatchObject({ id: 'folder-1', x: 812, y: 533 })
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({ x: 0, y: 28, zoom: 0.7 })
expect(resetState.evidence[0]).toMatchObject({ id: 'folder-1', x: 685, y: 417 })
})
})
+77
View File
@@ -0,0 +1,77 @@
import { once } from 'node:events'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import pg from 'pg'
import type { CaseState } from '../src/types.js'
import { runMigrations } from './migrations.js'
const { Client } = pg
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
if (!baseDatabaseUrl) throw new Error('TEST_DATABASE_URL is required for the browser harness')
const databaseName = `osint_e2e_${process.pid}_${Date.now()}`
const adminUrl = new URL(baseDatabaseUrl)
adminUrl.pathname = '/postgres'
const 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 = Number(process.env.E2E_PORT || 18788)
process.env.DATABASE_URL = databaseUrl
process.env.LEVEL_EDITING_ENABLED = 'true'
process.env.PORT = String(port)
process.env.OSINT_MANAGED_SERVER = 'true'
const { server, pool } = await import('./index.js')
if (!server.listening) await once(server, 'listening')
const baseUrl = `http://127.0.0.1:${port}`
const created = await fetch(`${baseUrl}/api/levels`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: 'e2e-level', title: 'Browser Safety Test', subtitle: 'Disposable test level' }),
})
if (!created.ok) throw new Error(`Could not create browser test level: ${created.status}`)
const state = await created.json() as CaseState
state.documents = [{
id: 'e2e-document', title: 'Dated source image', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00.000Z',
body: [], regions: [], fileType: 'image', metadata: {},
}]
state.evidence = [{
id: 'e2e-folder', type: 'folder', title: 'BROWSER TEST FOLDER', content: 'Disposable evidence',
x: 600, y: 360, width: 260, config: { open: false }, containedDocumentIds: ['e2e-document'],
}]
state.relations = [{
id: 'e2e-membership', fromWidgetId: 'e2e-folder', toWidgetId: 'e2e-document', type: 'contains', sortOrder: 0,
config: { x: 980, y: 360 },
}]
state.connections = []
state.viewport = { x: 0, y: 28, zoom: 0.7 }
const saved = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(state),
})
if (!saved.ok) throw new Error(`Could not seed browser test level: ${saved.status}`)
let shuttingDown = false
async function shutdown(exitCode: number) {
if (shuttingDown) return
shuttingDown = true
if (server.listening) await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
await pool.end()
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
await adminClient.end()
process.exit(exitCode)
}
process.on('SIGTERM', () => void shutdown(0))
process.on('SIGINT', () => void shutdown(0))
process.on('uncaughtException', error => { console.error(error); void shutdown(1) })
process.on('unhandledRejection', error => { console.error(error); void shutdown(1) })
console.log(`Browser safety harness ready on ${baseUrl}`)
+9 -3
View File
@@ -30,6 +30,12 @@ function wantsEdit(req: express.Request) {
return editingEnabled && req.query.edit === '1'
}
function isoTimestamp(value: unknown) {
if (!value) return undefined
const parsed = new Date(String(value))
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : undefined
}
async function assembleLevel(levelId: string, playthroughId = `default:${levelId}`, authorMode = false): Promise<CaseState | null> {
const levelResult = await pool.query<{ id: string; title: string; subtitle: string; status: string }>(
'SELECT id, title, subtitle, status FROM osint.levels WHERE id = $1', [levelId],
@@ -72,8 +78,8 @@ async function assembleLevel(levelId: string, playthroughId = `default:${levelId
])
const stateByWidget = new Map(authorMode ? [] : stateResult.rows.map(row => [row.widget_id, row]))
const documents: CaseDocument[] = widgetsResult.rows.filter(w => w.widget_type === 'document').map(w => { const override = stateByWidget.get(w.id)?.config || {}; const publishedAt = String(override.publishedAt || w.published_at || ''); return ({
id: w.id, title: String(override.title || w.title), kind: w.config.kind || 'DOCUMENT', date: publishedAt.slice(0, 10) || w.config.date || '', publishedAt: publishedAt || undefined,
const documents: CaseDocument[] = widgetsResult.rows.filter(w => w.widget_type === 'document').map(w => { const override = stateByWidget.get(w.id)?.config || {}; const publishedAt = isoTimestamp(override.publishedAt || w.published_at); return ({
id: w.id, title: String(override.title || w.title), kind: w.config.kind || 'DOCUMENT', date: publishedAt?.slice(0, 10) || w.config.date || '', publishedAt,
body: w.config.body || [], fileType: (override.fileType || w.config.fileType || (w.mime_type?.startsWith('image/') ? 'image' : 'file')) as CaseDocument['fileType'], metadata: (override.metadata || w.config.metadata || {}) as Record<string, string>,
assetId: w.asset_id, fileName: w.original_name, mimeType: w.mime_type, fileSize: w.byte_size,
regions: regionsResult.rows.filter(r => r.document_widget_id === w.id).map(r => ({ id: r.region_key, label: r.label, excerpt: r.excerpt, date: r.event_date })),
@@ -291,7 +297,7 @@ if (fs.existsSync(dist)) { app.use(express.static(dist)); app.get('*splat', (_re
const port = Number(process.env.PORT || 8787)
export const server = app.listen(port, '0.0.0.0', () => console.log(`GUPI OSINT Board listening on http://localhost:${port}`))
async function shutdown() { server.close(); await pool.end(); process.exit(0) }
if (!process.env.VITEST) {
if (!process.env.VITEST && process.env.OSINT_MANAGED_SERVER !== 'true') {
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
}
-1
View File
@@ -25,7 +25,6 @@ suite('PostgreSQL migrations', () => {
afterAll(async () => {
if (!adminClient) return
await adminClient.query('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1', [databaseName])
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
await adminClient.end()
})