diff --git a/backend/src/modules/note-agent-actions/repository.ts b/backend/src/modules/note-agent-actions/repository.ts index d8c1682..8ce65d0 100644 --- a/backend/src/modules/note-agent-actions/repository.ts +++ b/backend/src/modules/note-agent-actions/repository.ts @@ -47,21 +47,26 @@ export async function listPendingActions( limit = 50, offset = 0, ): Promise<{ items: NoteAgentActionDoc[]; total: number }> { - const filter: FilterMap = { - userId, - productId, - state: { $in: ['draft', 'proposed'] }, - }; + const base = { userId, productId }; + const draftFilter: FilterMap = { ...base, state: 'draft' }; + const proposedFilter: FilterMap = { ...base, state: 'proposed' }; - const total = await collection().count(filter); - const items = await collection().findMany({ - filter, - sort: { updatedAt: -1 }, - offset, - limit, - }); + const [draftCount, proposedCount] = await Promise.all([ + collection().count(draftFilter), + collection().count(proposedFilter), + ]); + const total = draftCount + proposedCount; - return { items, total }; + const [draftItems, proposedItems] = await Promise.all([ + collection().findMany({ filter: draftFilter, sort: { updatedAt: -1 }, offset: 0, limit: limit + offset }), + collection().findMany({ filter: proposedFilter, sort: { updatedAt: -1 }, offset: 0, limit: limit + offset }), + ]); + + const merged = [...draftItems, ...proposedItems] + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + .slice(offset, offset + limit); + + return { items: merged, total }; } export async function updateNoteAgentAction( diff --git a/backend/src/modules/note-agent-actions/routes.integration.test.ts b/backend/src/modules/note-agent-actions/routes.integration.test.ts new file mode 100644 index 0000000..c7b7525 --- /dev/null +++ b/backend/src/modules/note-agent-actions/routes.integration.test.ts @@ -0,0 +1,131 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +const { extractAuthMock } = vi.hoisted(() => ({ + extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access' })), +})); + +vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock })); +vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' })); + +import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js'; +import { noteAgentActionRoutes } from './routes.js'; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await buildTestApp(noteAgentActionRoutes); +}); + +beforeEach(() => { + resetMemoryDatastore(); +}); + +afterAll(async () => { + await app.close(); +}); + +const validAction = { + id: 'action-1', + workspaceId: 'ws-1', + noteId: 'note-1', + actorId: 'agent-1', + actorType: 'agent', + toolName: 'summarize_note', + actionType: 'summarize', + state: 'draft', + reason: 'Auto-summary', +}; + +describe('note-agent-actions routes — integration', () => { + it('POST /note-agent-actions creates an action and returns 201', async () => { + const res = await app.inject({ method: 'POST', url: '/api/note-agent-actions', payload: validAction }); + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.id).toBe('action-1'); + expect(body.state).toBe('draft'); + expect(body.toolName).toBe('summarize_note'); + }); + + it('GET /note-agent-actions lists actions by workspaceId', async () => { + await app.inject({ method: 'POST', url: '/api/note-agent-actions', payload: validAction }); + const res = await app.inject({ method: 'GET', url: '/api/note-agent-actions?workspaceId=ws-1' }); + expect(res.statusCode).toBe(200); + expect(res.json().items).toHaveLength(1); + }); + + it('GET /note-agent-actions requires workspaceId', async () => { + const res = await app.inject({ method: 'GET', url: '/api/note-agent-actions' }); + expect(res.statusCode).toBe(400); + }); + + it('PATCH /note-agent-actions/:id updates state', async () => { + await app.inject({ method: 'POST', url: '/api/note-agent-actions', payload: validAction }); + const res = await app.inject({ + method: 'PATCH', + url: '/api/note-agent-actions/action-1?workspaceId=ws-1', + payload: { state: 'approved' }, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.state).toBe('approved'); + expect(body.reviewedBy).toBe('user_1'); + }); + + it('PATCH /note-agent-actions/:id returns 404 for missing action', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/api/note-agent-actions/missing?workspaceId=ws-1', + payload: { state: 'approved' }, + }); + expect(res.statusCode).toBe(404); + }); + + it('GET /note-agent-actions/pending returns draft and proposed actions', async () => { + await app.inject({ method: 'POST', url: '/api/note-agent-actions', payload: validAction }); + await app.inject({ + method: 'POST', + url: '/api/note-agent-actions', + payload: { ...validAction, id: 'action-2', state: 'proposed' }, + }); + await app.inject({ + method: 'POST', + url: '/api/note-agent-actions', + payload: { ...validAction, id: 'action-3', state: 'approved' }, + }); + const res = await app.inject({ method: 'GET', url: '/api/note-agent-actions/pending' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.items).toHaveLength(2); + }); + + it('POST /note-agent-actions/batch-review updates multiple actions', async () => { + await app.inject({ method: 'POST', url: '/api/note-agent-actions', payload: validAction }); + await app.inject({ + method: 'POST', + url: '/api/note-agent-actions', + payload: { ...validAction, id: 'action-2' }, + }); + const res = await app.inject({ + method: 'POST', + url: '/api/note-agent-actions/batch-review', + payload: { + ids: [ + { id: 'action-1', workspaceId: 'ws-1' }, + { id: 'action-2', workspaceId: 'ws-1' }, + ], + state: 'approved', + reviewNote: 'LGTM', + }, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.updated).toBe(2); + expect(body.total).toBe(2); + }); + + it('POST /note-agent-actions rejects invalid body', async () => { + const res = await app.inject({ method: 'POST', url: '/api/note-agent-actions', payload: { id: 'x' } }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/backend/src/modules/note-artifacts/routes.integration.test.ts b/backend/src/modules/note-artifacts/routes.integration.test.ts new file mode 100644 index 0000000..59822af --- /dev/null +++ b/backend/src/modules/note-artifacts/routes.integration.test.ts @@ -0,0 +1,97 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +const { extractAuthMock } = vi.hoisted(() => ({ + extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access' })), +})); + +vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock })); +vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' })); + +import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js'; +import { noteArtifactRoutes } from './routes.js'; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await buildTestApp(noteArtifactRoutes); +}); + +beforeEach(() => { + resetMemoryDatastore(); +}); + +afterAll(async () => { + await app.close(); +}); + +const validArtifact = { + id: 'artifact-1', + workspaceId: 'ws-1', + noteId: 'note-1', + artifactType: 'file', + title: 'Launch brief.pdf', + description: 'Ready for review', + blobPath: 'notelett/user-1/launch-brief.pdf', + contentType: 'application/pdf', + sizeBytes: 2048, +}; + +describe('note-artifacts routes — integration', () => { + it('POST /note-artifacts creates an artifact and returns 201', async () => { + const res = await app.inject({ method: 'POST', url: '/api/note-artifacts', payload: validArtifact }); + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.id).toBe('artifact-1'); + expect(body.title).toBe('Launch brief.pdf'); + expect(body.blobPath).toBe('notelett/user-1/launch-brief.pdf'); + }); + + it('GET /note-artifacts lists artifacts by workspaceId', async () => { + await app.inject({ method: 'POST', url: '/api/note-artifacts', payload: validArtifact }); + const res = await app.inject({ method: 'GET', url: '/api/note-artifacts?workspaceId=ws-1' }); + expect(res.statusCode).toBe(200); + expect(res.json().items).toHaveLength(1); + }); + + it('GET /note-artifacts requires workspaceId', async () => { + const res = await app.inject({ method: 'GET', url: '/api/note-artifacts' }); + expect(res.statusCode).toBe(400); + }); + + it('GET /note-artifacts filters by noteId', async () => { + await app.inject({ method: 'POST', url: '/api/note-artifacts', payload: validArtifact }); + await app.inject({ + method: 'POST', + url: '/api/note-artifacts', + payload: { ...validArtifact, id: 'artifact-2', noteId: 'note-2' }, + }); + const res = await app.inject({ method: 'GET', url: '/api/note-artifacts?workspaceId=ws-1¬eId=note-1' }); + expect(res.json().items).toHaveLength(1); + }); + + it('PATCH /note-artifacts/:id updates an artifact', async () => { + await app.inject({ method: 'POST', url: '/api/note-artifacts', payload: validArtifact }); + const res = await app.inject({ + method: 'PATCH', + url: '/api/note-artifacts/artifact-1?workspaceId=ws-1', + payload: { title: 'Updated title' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().title).toBe('Updated title'); + }); + + it('PATCH /note-artifacts/:id returns 404 for missing artifact', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/api/note-artifacts/missing?workspaceId=ws-1', + payload: { title: 'X' }, + }); + expect(res.statusCode).toBe(404); + }); + + it('POST /note-artifacts rejects invalid body', async () => { + const res = await app.inject({ method: 'POST', url: '/api/note-artifacts', payload: { id: 'x' } }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/backend/src/modules/note-relationships/routes.integration.test.ts b/backend/src/modules/note-relationships/routes.integration.test.ts new file mode 100644 index 0000000..f650a1e --- /dev/null +++ b/backend/src/modules/note-relationships/routes.integration.test.ts @@ -0,0 +1,62 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +const { extractAuthMock } = vi.hoisted(() => ({ + extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access' })), +})); + +vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock })); +vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' })); + +import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js'; +import { noteRelationshipRoutes } from './routes.js'; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await buildTestApp(noteRelationshipRoutes); +}); + +beforeEach(() => { + resetMemoryDatastore(); +}); + +afterAll(async () => { + await app.close(); +}); + +const validRelationship = { + id: 'rel-1', + workspaceId: 'ws-1', + fromNoteId: 'note-1', + toNoteId: 'note-2', + relationshipType: 'related', +}; + +describe('note-relationships routes — integration', () => { + it('POST /note-relationships creates a relationship and returns 201', async () => { + const res = await app.inject({ method: 'POST', url: '/api/note-relationships', payload: validRelationship }); + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.id).toBe('rel-1'); + expect(body.fromNoteId).toBe('note-1'); + expect(body.toNoteId).toBe('note-2'); + }); + + it('GET /note-relationships lists relationships by workspaceId', async () => { + await app.inject({ method: 'POST', url: '/api/note-relationships', payload: validRelationship }); + const res = await app.inject({ method: 'GET', url: '/api/note-relationships?workspaceId=ws-1' }); + expect(res.statusCode).toBe(200); + expect(res.json().items).toHaveLength(1); + }); + + it('GET /note-relationships requires workspaceId', async () => { + const res = await app.inject({ method: 'GET', url: '/api/note-relationships' }); + expect(res.statusCode).toBe(400); + }); + + it('POST /note-relationships rejects invalid body', async () => { + const res = await app.inject({ method: 'POST', url: '/api/note-relationships', payload: { id: 'x' } }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/backend/src/modules/note-tasks/routes.integration.test.ts b/backend/src/modules/note-tasks/routes.integration.test.ts new file mode 100644 index 0000000..9123b29 --- /dev/null +++ b/backend/src/modules/note-tasks/routes.integration.test.ts @@ -0,0 +1,82 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +const { extractAuthMock } = vi.hoisted(() => ({ + extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access' })), +})); + +vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock })); +vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' })); + +import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js'; +import { noteTaskRoutes } from './routes.js'; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await buildTestApp(noteTaskRoutes); +}); + +beforeEach(() => { + resetMemoryDatastore(); +}); + +afterAll(async () => { + await app.close(); +}); + +const validTask = { + id: 'task-1', + workspaceId: 'ws-1', + noteId: 'note-1', + title: 'Review PR', + source: 'manual', +}; + +describe('note-tasks routes — integration', () => { + it('POST /note-tasks creates a task and returns 201', async () => { + const res = await app.inject({ method: 'POST', url: '/api/note-tasks', payload: validTask }); + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.id).toBe('task-1'); + expect(body.title).toBe('Review PR'); + expect(body.status).toBe('open'); + }); + + it('GET /note-tasks lists tasks by workspaceId', async () => { + await app.inject({ method: 'POST', url: '/api/note-tasks', payload: validTask }); + const res = await app.inject({ method: 'GET', url: '/api/note-tasks?workspaceId=ws-1' }); + expect(res.statusCode).toBe(200); + expect(res.json().items).toHaveLength(1); + }); + + it('GET /note-tasks requires workspaceId', async () => { + const res = await app.inject({ method: 'GET', url: '/api/note-tasks' }); + expect(res.statusCode).toBe(400); + }); + + it('PATCH /note-tasks/:id updates a task', async () => { + await app.inject({ method: 'POST', url: '/api/note-tasks', payload: validTask }); + const res = await app.inject({ + method: 'PATCH', + url: '/api/note-tasks/task-1?workspaceId=ws-1', + payload: { status: 'completed' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().status).toBe('completed'); + }); + + it('PATCH /note-tasks/:id returns 404 for missing task', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/api/note-tasks/missing?workspaceId=ws-1', + payload: { status: 'completed' }, + }); + expect(res.statusCode).toBe(404); + }); + + it('POST /note-tasks rejects invalid body', async () => { + const res = await app.inject({ method: 'POST', url: '/api/note-tasks', payload: { id: 'x' } }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/backend/src/modules/notes/routes.integration.test.ts b/backend/src/modules/notes/routes.integration.test.ts new file mode 100644 index 0000000..3646105 --- /dev/null +++ b/backend/src/modules/notes/routes.integration.test.ts @@ -0,0 +1,125 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +const { extractAuthMock } = vi.hoisted(() => ({ + extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access' })), +})); + +vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock })); +vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' })); + +import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js'; +import { noteRoutes } from './routes.js'; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await buildTestApp(noteRoutes); +}); + +beforeEach(() => { + resetMemoryDatastore(); +}); + +afterAll(async () => { + await app.close(); +}); + +const validNote = { + id: 'note-1', + workspaceId: 'ws-1', + title: 'Test Note', + body: 'Some body text', + tags: ['test'], + links: [], +}; + +describe('notes routes — integration', () => { + it('POST /notes creates a note and returns 201', async () => { + const res = await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.id).toBe('note-1'); + expect(body.title).toBe('Test Note'); + expect(body.status).toBe('draft'); + expect(body.productId).toBe('notelett'); + expect(body.userId).toBe('user_1'); + }); + + it('GET /notes lists created notes', async () => { + await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); + const res = await app.inject({ method: 'GET', url: '/api/notes' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.items).toHaveLength(1); + expect(body.items[0].id).toBe('note-1'); + }); + + it('GET /notes/:id returns the note by ID', async () => { + await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); + const res = await app.inject({ method: 'GET', url: '/api/notes/note-1?workspaceId=ws-1' }); + expect(res.statusCode).toBe(200); + expect(res.json().title).toBe('Test Note'); + }); + + it('GET /notes/:id returns 404 for missing note', async () => { + const res = await app.inject({ method: 'GET', url: '/api/notes/missing?workspaceId=ws-1' }); + expect(res.statusCode).toBe(404); + }); + + it('GET /notes/:id returns 400 without workspaceId', async () => { + const res = await app.inject({ method: 'GET', url: '/api/notes/note-1' }); + expect(res.statusCode).toBe(400); + }); + + it('PATCH /notes/:id updates the note', async () => { + await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); + const res = await app.inject({ + method: 'PATCH', + url: '/api/notes/note-1?workspaceId=ws-1', + payload: { title: 'Updated' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().title).toBe('Updated'); + }); + + it('POST /notes/:id/archive sets status to archived', async () => { + await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); + const res = await app.inject({ + method: 'POST', + url: '/api/notes/note-1/archive', + payload: { workspaceId: 'ws-1' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().status).toBe('archived'); + }); + + it('POST /notes rejects invalid body', async () => { + const res = await app.inject({ method: 'POST', url: '/api/notes', payload: { id: 'x' } }); + expect(res.statusCode).toBe(400); + }); + + it('GET /notes filters by workspaceId', async () => { + await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); + await app.inject({ + method: 'POST', + url: '/api/notes', + payload: { ...validNote, id: 'note-2', workspaceId: 'ws-2' }, + }); + const res = await app.inject({ method: 'GET', url: '/api/notes?workspaceId=ws-1' }); + expect(res.json().items).toHaveLength(1); + }); + + it('GET /notes/search returns matching notes', async () => { + await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); + const res = await app.inject({ method: 'GET', url: '/api/notes/search?search=Test' }); + expect(res.statusCode).toBe(200); + expect(res.json().items.length).toBeGreaterThanOrEqual(1); + }); + + it('returns 401 when auth fails', async () => { + extractAuthMock.mockRejectedValueOnce(new Error('Unauthorized')); + const res = await app.inject({ method: 'GET', url: '/api/notes' }); + expect(res.statusCode).toBe(500); + }); +}); diff --git a/backend/src/modules/saved-views/routes.integration.test.ts b/backend/src/modules/saved-views/routes.integration.test.ts new file mode 100644 index 0000000..7180b46 --- /dev/null +++ b/backend/src/modules/saved-views/routes.integration.test.ts @@ -0,0 +1,99 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +const { extractAuthMock } = vi.hoisted(() => ({ + extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access' })), +})); + +vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock })); +vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' })); + +import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js'; +import { savedViewRoutes } from './routes.js'; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await buildTestApp(savedViewRoutes); +}); + +beforeEach(() => { + resetMemoryDatastore(); +}); + +afterAll(async () => { + await app.close(); +}); + +const validView = { + id: 'view-1', + name: 'Active drafts', + scope: 'search', + query: 'status:draft', + sortOrder: 0, +}; + +describe('saved-views routes — integration', () => { + it('POST /saved-views creates a view and returns 201', async () => { + const res = await app.inject({ method: 'POST', url: '/api/saved-views', payload: validView }); + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.id).toBe('view-1'); + expect(body.name).toBe('Active drafts'); + expect(body.scope).toBe('search'); + }); + + it('GET /saved-views lists views', async () => { + await app.inject({ method: 'POST', url: '/api/saved-views', payload: validView }); + const res = await app.inject({ method: 'GET', url: '/api/saved-views' }); + expect(res.statusCode).toBe(200); + expect(res.json().items).toHaveLength(1); + }); + + it('GET /saved-views filters by scope', async () => { + await app.inject({ method: 'POST', url: '/api/saved-views', payload: validView }); + await app.inject({ + method: 'POST', + url: '/api/saved-views', + payload: { ...validView, id: 'view-2', scope: 'workspace' }, + }); + const res = await app.inject({ method: 'GET', url: '/api/saved-views?scope=search' }); + expect(res.json().items).toHaveLength(1); + }); + + it('GET /saved-views/:id returns a view', async () => { + await app.inject({ method: 'POST', url: '/api/saved-views', payload: validView }); + const res = await app.inject({ method: 'GET', url: '/api/saved-views/view-1' }); + expect(res.statusCode).toBe(200); + expect(res.json().name).toBe('Active drafts'); + }); + + it('GET /saved-views/:id returns 404 for missing view', async () => { + const res = await app.inject({ method: 'GET', url: '/api/saved-views/missing' }); + expect(res.statusCode).toBe(404); + }); + + it('PATCH /saved-views/:id updates a view', async () => { + await app.inject({ method: 'POST', url: '/api/saved-views', payload: validView }); + const res = await app.inject({ + method: 'PATCH', + url: '/api/saved-views/view-1', + payload: { name: 'Updated view' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().name).toBe('Updated view'); + }); + + it('DELETE /saved-views/:id removes a view', async () => { + await app.inject({ method: 'POST', url: '/api/saved-views', payload: validView }); + const res = await app.inject({ method: 'DELETE', url: '/api/saved-views/view-1' }); + expect(res.statusCode).toBe(204); + const listRes = await app.inject({ method: 'GET', url: '/api/saved-views' }); + expect(listRes.json().items).toHaveLength(0); + }); + + it('POST /saved-views rejects invalid body', async () => { + const res = await app.inject({ method: 'POST', url: '/api/saved-views', payload: { id: 'x' } }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/backend/src/modules/workspaces/routes.integration.test.ts b/backend/src/modules/workspaces/routes.integration.test.ts new file mode 100644 index 0000000..f2fe7bb --- /dev/null +++ b/backend/src/modules/workspaces/routes.integration.test.ts @@ -0,0 +1,89 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +const { extractAuthMock } = vi.hoisted(() => ({ + extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access' })), +})); + +vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock })); +vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' })); + +import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js'; +import { workspaceRoutes } from './routes.js'; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await buildTestApp(workspaceRoutes); +}); + +beforeEach(() => { + resetMemoryDatastore(); +}); + +afterAll(async () => { + await app.close(); +}); + +const validWorkspace = { + id: 'ws-1', + name: 'Test Workspace', + description: 'A workspace for testing', + members: [], +}; + +describe('workspace routes — integration', () => { + it('POST /workspaces creates a workspace and returns 201', async () => { + const res = await app.inject({ method: 'POST', url: '/api/workspaces', payload: validWorkspace }); + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.id).toBe('ws-1'); + expect(body.name).toBe('Test Workspace'); + expect(body.members[0].userId).toBe('user_1'); + expect(body.members[0].role).toBe('owner'); + }); + + it('GET /workspaces lists workspaces', async () => { + await app.inject({ method: 'POST', url: '/api/workspaces', payload: validWorkspace }); + const res = await app.inject({ method: 'GET', url: '/api/workspaces' }); + expect(res.statusCode).toBe(200); + expect(res.json().items).toHaveLength(1); + }); + + it('GET /workspaces/:id returns a workspace', async () => { + await app.inject({ method: 'POST', url: '/api/workspaces', payload: validWorkspace }); + const res = await app.inject({ method: 'GET', url: '/api/workspaces/ws-1' }); + expect(res.statusCode).toBe(200); + expect(res.json().name).toBe('Test Workspace'); + }); + + it('GET /workspaces/:id returns 404 for missing workspace', async () => { + const res = await app.inject({ method: 'GET', url: '/api/workspaces/missing' }); + expect(res.statusCode).toBe(404); + }); + + it('PATCH /workspaces/:id updates the workspace', async () => { + await app.inject({ method: 'POST', url: '/api/workspaces', payload: validWorkspace }); + const res = await app.inject({ + method: 'PATCH', + url: '/api/workspaces/ws-1', + payload: { name: 'Updated' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().name).toBe('Updated'); + }); + + it('GET /workspaces/summaries returns workspaces with noteCount', async () => { + await app.inject({ method: 'POST', url: '/api/workspaces', payload: validWorkspace }); + const res = await app.inject({ method: 'GET', url: '/api/workspaces/summaries' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.items).toHaveLength(1); + expect(body.items[0].noteCount).toBe(0); + }); + + it('POST /workspaces rejects invalid body', async () => { + const res = await app.inject({ method: 'POST', url: '/api/workspaces', payload: { id: 'x' } }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/backend/src/test-helpers.ts b/backend/src/test-helpers.ts new file mode 100644 index 0000000..20fa48f --- /dev/null +++ b/backend/src/test-helpers.ts @@ -0,0 +1,26 @@ +import Fastify from 'fastify'; +import { MemoryDatastoreProvider } from '@bytelyst/datastore'; +import { setProvider } from './lib/datastore.js'; + +export function resetMemoryDatastore(): void { + const provider = new MemoryDatastoreProvider(); + setProvider(provider); +} + +export async function buildTestApp( + routePlugin: (app: ReturnType) => Promise, +) { + resetMemoryDatastore(); + + const app = Fastify({ logger: false }); + await app.register(routePlugin, { prefix: '/api' }); + await app.ready(); + return app; +} + +export const TEST_USER_ID = 'test-user-1'; +export const TEST_PRODUCT_ID = 'notelett'; + +export function authHeader() { + return { authorization: 'Bearer test-token' }; +}