/** * Artifact Center REST API Route Tests (UX-Refactor Phase 2C, S05). * * Covers the 6 artifact routes in artifacts.ts: * GET /api/artifacts list + facet filters (kind/status/tag/q) * POST /api/artifacts create (A6 artifacts.json index) * GET /api/artifacts/:id one (resolves owning workspace) * PATCH /api/artifacts/:id edit; Archive = status:'archived' (A8) * DELETE /api/artifacts/:id hard delete (A8) * GET /api/artifacts/search-related federated (artifacts+memories+tasks) * * Uses a tmp `localConfig.dataDir` so the artifacts.json / tasks.jsonl writes are * isolated and cleaned up. A ':memory:' personal mind backs the memory federation. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import Fastify from 'fastify'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; import { MindDB, FrameStore, SessionStore } from '@waggle/core'; import { artifactRoutes } from '../../src/local/routes/artifacts.js'; import { sessionRoutes } from '../../src/local/routes/sessions.js'; function createTestServer(db: MindDB, dataDir: string) { const server = Fastify({ logger: false }); server.decorate('localConfig', { dataDir }); server.decorate('workspaceManager', { list: () => [{ id: 'ws-test', name: 'Test Workspace' }], get: (id: string) => id === 'ws-test' ? { id, name: 'Test Workspace' } : undefined, }); server.decorate('agentState', { getWorkspaceMindDb: () => undefined, listWorkspaces: () => [], }); server.decorate('multiMind', { personal: db }); server.register(artifactRoutes); server.register(sessionRoutes); return server; } describe('Artifact Center routes (Phase 2C)', () => { let db: MindDB; let dataDir: string; let server: ReturnType; beforeEach(() => { dataDir = path.join(os.tmpdir(), `waggle-art-${randomUUID()}`); fs.mkdirSync(dataDir, { recursive: true }); db = new MindDB(':memory:'); server = createTestServer(db, dataDir); }); afterEach(async () => { await server.close(); db.close(); // Best-effort: emitAuditEvent opens a long-lived audit.db connection (by // design — the sidecar keeps it open), so on Windows the file can still be // locked here. A temp-dir cleanup race must not fail a passing test; the OS // reclaims os.tmpdir(). try { fs.rmSync(dataDir, { recursive: true, force: true }); } catch { /* audit.db handle may linger on Windows — ignore */ } }); async function createArtifact(body: Record) { const res = await server.inject({ method: 'POST', url: '/api/artifacts', payload: body }); expect(res.statusCode).toBe(201); return res.json(); } it('boots and lists an empty index', async () => { const res = await server.inject({ method: 'GET', url: '/api/artifacts' }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ results: [], count: 0 }); }); it('POST creates an artifact with id/status/source defaults', async () => { const a = await createArtifact({ title: 'Q3 GTM Deck', kind: 'presentation', workspaceId: 'ws-test' }); expect(a.id).toMatch(/^art_/); expect(a.kind).toBe('presentation'); expect(a.status).toBe('draft'); expect(a.source).toBe('user'); expect(a.workspaceId).toBe('ws-test'); expect(a.title).toBe('Q3 GTM Deck'); expect(typeof a.createdAt).toBe('string'); }); it('GET list returns created artifacts; GET /:id fetches one', async () => { const a = await createArtifact({ title: 'Spec', kind: 'document', workspaceId: 'ws-test' }); const list = await server.inject({ method: 'GET', url: '/api/artifacts' }); expect(list.json().count).toBe(1); expect(list.json().results[0].id).toBe(a.id); const one = await server.inject({ method: 'GET', url: `/api/artifacts/${a.id}` }); expect(one.statusCode).toBe(200); expect(one.json().id).toBe(a.id); }); it('POST rejects missing title / missing workspaceId / invalid kind', async () => { expect((await server.inject({ method: 'POST', url: '/api/artifacts', payload: { kind: 'document', workspaceId: 'ws-test' } })).statusCode).toBe(400); expect((await server.inject({ method: 'POST', url: '/api/artifacts', payload: { title: 'X', kind: 'document' } })).statusCode).toBe(400); expect((await server.inject({ method: 'POST', url: '/api/artifacts', payload: { title: 'X', kind: 'nonsense', workspaceId: 'ws-test' } })).statusCode).toBe(400); }); it('PATCH updates status + tags', async () => { const a = await createArtifact({ title: 'Report', kind: 'document', workspaceId: 'ws-test' }); const res = await server.inject({ method: 'PATCH', url: `/api/artifacts/${a.id}?workspaceId=ws-test`, payload: { status: 'final', tags: ['gtm', 'q3'] }, }); expect(res.statusCode).toBe(200); expect(res.json().status).toBe('final'); expect(res.json().tags).toEqual(['gtm', 'q3']); }); it('PATCH and DELETE require workspaceId before resolving an artifact owner', async () => { const a = await createArtifact({ title: 'Protected report', kind: 'document', workspaceId: 'ws-test' }); const patch = await server.inject({ method: 'PATCH', url: `/api/artifacts/${a.id}`, payload: { title: 'Mutated without scope' }, }); expect(patch.statusCode).toBe(400); const afterPatch = await server.inject({ method: 'GET', url: `/api/artifacts/${a.id}?workspaceId=ws-test` }); expect(afterPatch.statusCode).toBe(200); expect(afterPatch.json().title).toBe('Protected report'); const del = await server.inject({ method: 'DELETE', url: `/api/artifacts/${a.id}` }); expect(del.statusCode).toBe(400); const afterDelete = await server.inject({ method: 'GET', url: `/api/artifacts/${a.id}?workspaceId=ws-test` }); expect(afterDelete.statusCode).toBe(200); }); it('PATCH and DELETE require workspace before resolving a session owner', async () => { const sessionsDir = path.join(dataDir, 'workspaces', 'ws-test', 'sessions'); fs.mkdirSync(sessionsDir, { recursive: true }); const sessionPath = path.join(sessionsDir, 'session-protected.jsonl'); fs.writeFileSync( sessionPath, `${JSON.stringify({ type: 'meta', title: 'Original', created: '2026-07-24T00:00:00.000Z' })}\n`, 'utf-8', ); const patch = await server.inject({ method: 'PATCH', url: '/api/sessions/session-protected', payload: { title: 'Mutated without scope' }, }); expect(patch.statusCode).toBe(400); expect(JSON.parse(fs.readFileSync(sessionPath, 'utf-8').trim()).title).toBe('Original'); const del = await server.inject({ method: 'DELETE', url: '/api/sessions/session-protected', }); expect(del.statusCode).toBe(400); expect(fs.existsSync(sessionPath)).toBe(true); }); it('PATCH rejects an invalid kind/status', async () => { const a = await createArtifact({ title: 'X', kind: 'document', workspaceId: 'ws-test' }); expect((await server.inject({ method: 'PATCH', url: `/api/artifacts/${a.id}?workspaceId=ws-test`, payload: { kind: 'bogus' } })).statusCode).toBe(400); expect((await server.inject({ method: 'PATCH', url: `/api/artifacts/${a.id}?workspaceId=ws-test`, payload: { status: 'bogus' } })).statusCode).toBe(400); }); it('Archive = reversible status:archived (A8), filterable by status', async () => { const a = await createArtifact({ title: 'Old deck', kind: 'presentation', workspaceId: 'ws-test' }); const arch = await server.inject({ method: 'PATCH', url: `/api/artifacts/${a.id}?workspaceId=ws-test`, payload: { status: 'archived' } }); expect(arch.json().status).toBe('archived'); expect((await server.inject({ method: 'GET', url: '/api/artifacts?status=draft' })).json().count).toBe(0); expect((await server.inject({ method: 'GET', url: '/api/artifacts?status=archived' })).json().count).toBe(1); const restore = await server.inject({ method: 'PATCH', url: `/api/artifacts/${a.id}?workspaceId=ws-test`, payload: { status: 'draft' } }); expect(restore.json().status).toBe('draft'); }); it('facet filters: kind / tag / q', async () => { await createArtifact({ title: 'Germany GTM Deck', kind: 'presentation', workspaceId: 'ws-test', tags: ['gtm'] }); await createArtifact({ title: 'API Spec', kind: 'document', workspaceId: 'ws-test', tags: ['eng'] }); expect((await server.inject({ method: 'GET', url: '/api/artifacts?kind=presentation' })).json().count).toBe(1); expect((await server.inject({ method: 'GET', url: '/api/artifacts?tag=eng' })).json().count).toBe(1); expect((await server.inject({ method: 'GET', url: '/api/artifacts?q=germany' })).json().count).toBe(1); }); it('DELETE hard-deletes the index entry (A8 — no tombstone)', async () => { const a = await createArtifact({ title: 'Delete me', kind: 'document', workspaceId: 'ws-test' }); const del = await server.inject({ method: 'DELETE', url: `/api/artifacts/${a.id}?workspaceId=ws-test` }); expect(del.statusCode).toBe(200); expect(del.json().deleted).toBe(true); expect((await server.inject({ method: 'GET', url: `/api/artifacts/${a.id}` })).statusCode).toBe(404); }); it('GET /:id 404s for an unknown id', async () => { expect((await server.inject({ method: 'GET', url: '/api/artifacts/art_unknown' })).statusCode).toBe(404); }); it('clamps a negative limit instead of dropping the newest rows (F1)', async () => { await createArtifact({ title: 'A1', kind: 'document', workspaceId: 'ws-test' }); await createArtifact({ title: 'A2', kind: 'document', workspaceId: 'ws-test' }); const res = await server.inject({ method: 'GET', url: '/api/artifacts?limit=-1' }); expect(res.statusCode).toBe(200); expect(res.json().count).toBe(2); // not 1 — slice(0,-1) would drop the newest }); it('Archive stashes prevStatus so the prior status is restorable (A8, F3)', async () => { const a = await createArtifact({ title: 'Final deck', kind: 'presentation', workspaceId: 'ws-test', status: 'final' }); expect(a.status).toBe('final'); const arch = await server.inject({ method: 'PATCH', url: `/api/artifacts/${a.id}?workspaceId=ws-test`, payload: { status: 'archived' } }); expect(arch.json().status).toBe('archived'); expect(arch.json().prevStatus).toBe('final'); const restore = await server.inject({ method: 'PATCH', url: `/api/artifacts/${a.id}?workspaceId=ws-test`, payload: { status: 'final' } }); expect(restore.json().status).toBe('final'); expect(restore.json().prevStatus).toBeUndefined(); // cleared on leaving archive }); it('rejects a path-traversal workspaceId on list (assertSafeSegment → 400)', async () => { const url = '/api/artifacts?workspaceId=' + encodeURIComponent('../../etc'); expect((await server.inject({ method: 'GET', url })).statusCode).toBe(400); }); it('search-related requires q', async () => { expect((await server.inject({ method: 'GET', url: '/api/artifacts/search-related' })).statusCode).toBe(400); }); it('search-related federates artifacts + memories + tasks (agents empty in v1)', async () => { // artifact await createArtifact({ title: 'Germany GTM deck', kind: 'presentation', workspaceId: 'ws-test' }); // memory in the personal mind const session = new SessionStore(db).ensureActive(); new FrameStore(db).createIFrame(session.gop_id, 'Germany GTM strategy notes', 'normal', 'user_stated'); // task in the workspace tasks.jsonl const wsDir = path.join(dataDir, 'workspaces', 'ws-test'); fs.mkdirSync(wsDir, { recursive: true }); const now = new Date().toISOString(); fs.writeFileSync( path.join(wsDir, 'tasks.jsonl'), JSON.stringify({ id: 't1', title: 'Germany GTM follow-up', status: 'open', createdAt: now, updatedAt: now }) + '\n', ); const res = await server.inject({ method: 'GET', url: '/api/artifacts/search-related?q=germany' }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.artifacts.length).toBeGreaterThanOrEqual(1); expect(body.memories.length).toBeGreaterThanOrEqual(1); expect(body.tasks.length).toBeGreaterThanOrEqual(1); expect(body.agents).toEqual([]); }); });