Files
gupi-osint-board/server/api.integration.test.ts
T

133 lines
6.7 KiB
TypeScript
Raw Normal View History

2026-08-14 12:57:06 +02:00
import { createServer } from 'node:net'
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()
})
it('persists authoring, uploads, player state, and reset behavior', 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 }
state.documents = [{ id: 'doc-1', title: 'Evidence', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00Z', body: [], regions: [], fileType: 'image', metadata: {} }]
state.evidence = [{ id: 'folder-1', type: 'folder', title: 'Folder', content: 'Evidence folder', x: 685, y: 417, width: 260, config: { open: true }, containedDocumentIds: ['doc-1'] }]
state.relations = [{ id: 'membership-1', fromWidgetId: 'folder-1', toWidgetId: 'doc-1', type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }]
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: '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 })
2026-08-14 12:57:06 +02:00
})
})