diff --git a/backend/src/modules/ecosystem-phase3/routes.test.ts b/backend/src/modules/ecosystem-phase3/routes.test.ts new file mode 100644 index 0000000..86d97ef --- /dev/null +++ b/backend/src/modules/ecosystem-phase3/routes.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const { + requireWriterMock, + createNoteMock, + createNoteArtifactMock, + loadLatestTrailReportArtifactMock, + loadLatestTrailReportCreatedEventMock, + buildPhase3TrailNoteImportMock, + persistPhase3NoteOutputsMock, +} = vi.hoisted(() => ({ + requireWriterMock: vi.fn(async () => ({ sub: 'user_1', role: 'editor' })), + createNoteMock: vi.fn(async (note: unknown) => note), + createNoteArtifactMock: vi.fn(async (artifact: unknown) => artifact), + loadLatestTrailReportArtifactMock: vi.fn(async () => ({ + id: 'art_trail_1', + ownership: { orgId: null }, + provenance: { sessionId: 'sess_1', correlationId: 'corr_1', lineage: [] }, + payload: { sourceProduct: 'claw-cowork', sourceTaskId: 'task_1', actionCount: 3, safetySignalCount: 1, actionBreakdown: [], entries: [] }, + title: 'Cowork audit report', + summary: '3 audited actions', + })), + loadLatestTrailReportCreatedEventMock: vi.fn(async () => ({ + eventId: 'evt_trail_created_1', + eventName: 'artifact.created', + trace: { correlationId: 'corr_1', causationId: null, parentEventId: null }, + })), + buildPhase3TrailNoteImportMock: vi.fn(() => ({ + note: { id: 'note_1' }, + noteArtifactDoc: { id: 'na_1' }, + ecosystemNoteArtifact: { id: 'art_note_1' }, + createdEvent: { eventId: 'evt_note_created_1' }, + linkedEvent: { eventId: 'evt_note_linked_1' }, + })), + persistPhase3NoteOutputsMock: vi.fn(async () => undefined), +})); + +vi.mock('../../lib/auth.js', () => ({ requireWriter: requireWriterMock })); +vi.mock('zod', () => ({ + z: { + object: (shape: Record { max: () => unknown } }>) => ({ + safeParse: (value: unknown) => { + const workspaceId = (value as { workspaceId?: unknown } | undefined)?.workspaceId; + if (typeof workspaceId === 'string' && workspaceId.length > 0) { + return { success: true, data: { workspaceId } }; + } + return { success: false, error: { issues: [{ message: 'workspaceId is required' }] } }; + }, + }), + string: () => ({ + min: () => ({ + max: () => ({}), + }), + }), + }, +})); +vi.mock('@bytelyst/errors', () => ({ + BadRequestError: class BadRequestError extends Error { + constructor(message: string) { + super(message); + this.name = 'BadRequestError'; + } + }, +})); +vi.mock('../notes/repository.js', () => ({ createNote: createNoteMock })); +vi.mock('../note-artifacts/repository.js', () => ({ createNoteArtifact: createNoteArtifactMock })); +vi.mock('../../lib/ecosystem-phase3.js', () => ({ + loadLatestTrailReportArtifact: loadLatestTrailReportArtifactMock, + loadLatestTrailReportCreatedEvent: loadLatestTrailReportCreatedEventMock, + buildPhase3TrailNoteImport: buildPhase3TrailNoteImportMock, + persistPhase3NoteOutputs: persistPhase3NoteOutputsMock, +})); + +import { ecosystemPhase3Routes } from './routes.js'; + +describe('ecosystemPhase3Routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('registers the import handler and returns imported trail report metadata', async () => { + let handler: ((req: { body?: unknown }) => Promise) | undefined; + const app = { + post: vi.fn((path: string, cb: typeof handler) => { + expect(path).toBe('/ecosystem/phase3/import-latest-trail-report'); + handler = cb; + }), + }; + + await ecosystemPhase3Routes(app as never); + + const result = await handler?.({ body: { workspaceId: 'ws_1' } }); + + expect(requireWriterMock).toHaveBeenCalledOnce(); + expect(loadLatestTrailReportArtifactMock).toHaveBeenCalledOnce(); + expect(loadLatestTrailReportCreatedEventMock).toHaveBeenCalledOnce(); + expect(buildPhase3TrailNoteImportMock).toHaveBeenCalledOnce(); + expect(createNoteMock).toHaveBeenCalledOnce(); + expect(createNoteArtifactMock).toHaveBeenCalledOnce(); + expect(persistPhase3NoteOutputsMock).toHaveBeenCalledOnce(); + expect(result).toEqual({ + note: { id: 'note_1' }, + noteArtifact: { id: 'na_1' }, + ecosystemArtifactId: 'art_note_1', + linkedTrailReportArtifactId: 'art_trail_1', + events: ['evt_note_created_1', 'evt_note_linked_1'], + }); + }); + + it('rejects invalid request bodies', async () => { + let handler: ((req: { body?: unknown }) => Promise) | undefined; + const app = { + post: vi.fn((_path: string, cb: typeof handler) => { + handler = cb; + }), + }; + + await ecosystemPhase3Routes(app as never); + + await expect(handler?.({ body: {} })).rejects.toMatchObject({ + name: 'BadRequestError', + }); + }); +}); diff --git a/backend/src/modules/ecosystem-phase3/routes.ts b/backend/src/modules/ecosystem-phase3/routes.ts new file mode 100644 index 0000000..7492bda --- /dev/null +++ b/backend/src/modules/ecosystem-phase3/routes.ts @@ -0,0 +1,47 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { BadRequestError } from '@bytelyst/errors'; +import { requireWriter } from '../../lib/auth.js'; +import { createNote } from '../notes/repository.js'; +import { createNoteArtifact } from '../note-artifacts/repository.js'; +import { + buildPhase3TrailNoteImport, + loadLatestTrailReportArtifact, + loadLatestTrailReportCreatedEvent, + persistPhase3NoteOutputs, +} from '../../lib/ecosystem-phase3.js'; + +const ImportLatestTrailReportSchema = z.object({ + workspaceId: z.string().min(1).max(128), +}); + +export async function ecosystemPhase3Routes(app: FastifyInstance) { + app.post('/ecosystem/phase3/import-latest-trail-report', async req => { + const auth = await requireWriter(req); + const parsed = ImportLatestTrailReportSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + + const trailReportArtifact = await loadLatestTrailReportArtifact(); + const trailReportCreatedEvent = await loadLatestTrailReportCreatedEvent(); + const generated = buildPhase3TrailNoteImport({ + trailReportArtifact, + trailReportCreatedEvent, + workspaceId: parsed.data.workspaceId, + userId: auth.sub, + }); + + const note = await createNote(generated.note); + const noteArtifactDoc = await createNoteArtifact(generated.noteArtifactDoc); + await persistPhase3NoteOutputs(generated); + + return { + note, + noteArtifact: noteArtifactDoc, + ecosystemArtifactId: generated.ecosystemNoteArtifact.id, + linkedTrailReportArtifactId: trailReportArtifact.id, + events: [generated.createdEvent.eventId, generated.linkedEvent.eventId], + }; + }); +} diff --git a/backend/src/server.test.ts b/backend/src/server.test.ts index dff1638..fa03294 100644 --- a/backend/src/server.test.ts +++ b/backend/src/server.test.ts @@ -30,6 +30,7 @@ vi.mock('./modules/note-tasks/routes.js', () => ({ noteTaskRoutes: vi.fn() })); vi.mock('./modules/saved-views/routes.js', () => ({ savedViewRoutes: vi.fn() })); vi.mock('./modules/workspaces/routes.js', () => ({ workspaceRoutes: vi.fn() })); vi.mock('./modules/ecosystem-phase1/routes.js', () => ({ ecosystemPhase1Routes: vi.fn() })); +vi.mock('./modules/ecosystem-phase3/routes.js', () => ({ ecosystemPhase3Routes: vi.fn() })); vi.mock('./lib/cosmos-init.js', () => ({ initCosmosIfNeeded: initCosmosIfNeededMock })); vi.mock('./lib/datastore.js', () => ({ initDatastore: initDatastoreMock })); vi.mock('./lib/config.js', () => ({ @@ -49,6 +50,8 @@ vi.mock('./lib/product-config.js', () => ({ vi.mock('./lib/field-encrypt.js', () => ({ initEncryption: vi.fn(async () => undefined), getEncryptor: vi.fn() })); vi.mock('./lib/feature-flags.js', () => ({ getAllFlags: vi.fn(() => ({})) })); vi.mock('./lib/telemetry.js', () => ({ getBufferedEvents: vi.fn(() => []), flushEvents: vi.fn(() => []) })); +vi.mock('./modules/note-shares/repository.js', () => ({ findShareByToken: vi.fn(async () => null) })); +vi.mock('./modules/notes/repository.js', () => ({ getNote: vi.fn(async () => null) })); describe('server bootstrap', () => { beforeEach(() => { @@ -66,7 +69,7 @@ describe('server bootstrap', () => { expect(initDatastoreMock).toHaveBeenCalledOnce(); expect(createServiceAppMock).toHaveBeenCalledOnce(); expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce(); - expect(appMock.register).toHaveBeenCalledTimes(8); + expect(appMock.register).toHaveBeenCalledTimes(9); expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4016, host: '0.0.0.0' }); }); }); diff --git a/backend/src/server.ts b/backend/src/server.ts index b252219..b57ec69 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -2,6 +2,7 @@ import { createServiceApp, registerOptionalJwtContext, startService } from '@byt import { jwtVerify } from 'jose'; import { noteAgentActionRoutes } from './modules/note-agent-actions/routes.js'; import { ecosystemPhase1Routes } from './modules/ecosystem-phase1/routes.js'; +import { ecosystemPhase3Routes } from './modules/ecosystem-phase3/routes.js'; import { noteArtifactRoutes } from './modules/note-artifacts/routes.js'; import { noteRoutes } from './modules/notes/routes.js'; import { noteRelationshipRoutes } from './modules/note-relationships/routes.js'; @@ -53,6 +54,7 @@ async function registerApiPlugin(plugin: unknown) { await registerApiPlugin(noteAgentActionRoutes); await registerApiPlugin(ecosystemPhase1Routes); +await registerApiPlugin(ecosystemPhase3Routes); await registerApiPlugin(noteArtifactRoutes); await registerApiPlugin(noteRoutes); await registerApiPlugin(noteRelationshipRoutes);