feat: add immutable level template lifecycle

This commit is contained in:
2026-08-14 14:17:54 +02:00
parent 5599d330d8
commit c8a870549d
10 changed files with 386 additions and 26 deletions
+64 -5
View File
@@ -70,9 +70,14 @@ suite('level persistence API', () => {
state.viewport = { x: 91, y: -42, zoom: 0.85 }
const documentId = randomUUID()
const folderId = randomUUID()
state.documents = [{ id: documentId, title: 'Evidence', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00Z', body: [], regions: [], 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] }]
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 },
]
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',
@@ -129,18 +134,72 @@ suite('level persistence API', () => {
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 }>(`SELECT
const normalized = await appPool.query<{ exhibits: string; documents: string; folders: string; memberships: string; metadata: string; sources: string; connections: 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`)
expect(normalized.rows[0]).toEqual({ exhibits: '3', documents: '2', folders: '1', memberships: '1', metadata: '2' })
(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' })
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 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 })
})
})