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', role: 'editor' })), })); vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock })); vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' })); vi.mock('../../lib/extraction-client.js', () => ({ extractFromText: vi.fn(async () => ({ summary: 'A concise summary.' })), })); vi.mock('../../lib/telemetry.js', () => ({ trackEvent: vi.fn() })); vi.mock('../../lib/feature-flags.js', () => ({ isFeatureEnabled: vi.fn(() => true) })); import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js'; import { noteRoutes } from './routes.js'; let app: FastifyInstance; beforeAll(async () => { app = await buildTestApp(noteRoutes); }); beforeEach(() => { resetMemoryDatastore(); extractAuthMock.mockResolvedValue({ sub: 'user_1', type: 'access', role: 'editor' }); }); 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/:id/restore sets status to active', async () => { await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); await app.inject({ method: 'POST', url: '/api/notes/note-1/archive', payload: { workspaceId: 'ws-1' } }); const res = await app.inject({ method: 'POST', url: '/api/notes/note-1/restore', payload: { workspaceId: 'ws-1' }, }); expect(res.statusCode).toBe(200); expect(res.json().status).toBe('active'); }); 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('POST /notes/:id/summarize creates a summary artifact', async () => { await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); const res = await app.inject({ method: 'POST', url: '/api/notes/note-1/summarize', payload: { workspaceId: 'ws-1' }, }); expect(res.statusCode).toBe(201); const body = res.json(); expect(body.artifactType).toBe('summary'); expect(body.description).toBe('A concise summary.'); }); it('GET /notes/export returns JSON by default', async () => { await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); const res = await app.inject({ method: 'GET', url: '/api/notes/export' }); expect(res.statusCode).toBe(200); const body = res.json(); expect(res.headers['content-disposition']).toContain('notelett-notes-all.json'); expect(body.schemaVersion).toBe('notelett.notes.export.v1'); expect(body.scope).toEqual({ userId: 'user_1', workspaceId: null }); expect(body.metadata).toMatchObject({ noteCount: 1, sort: 'workspaceId:asc,updatedAt:desc,id:asc', importStatus: 'deferred', }); expect(body.notes).toHaveLength(1); expect(body.exportedAt).toBeDefined(); expect(body.notes[0]).not.toHaveProperty('embedding'); }); it('GET /notes/export?format=markdown returns markdown', async () => { await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); const res = await app.inject({ method: 'GET', url: '/api/notes/export?format=markdown&workspaceId=ws-1' }); expect(res.statusCode).toBe(200); expect(res.headers['content-type']).toContain('text/markdown'); expect(res.headers['content-disposition']).toContain('notelett-notes-ws-1.md'); expect(res.body).toContain('# NoteLett Notes Export'); expect(res.body).toContain('## Test Note'); expect(res.body).toContain('- Import: deferred'); }); it('GET /notes/export paginates all notes and keeps user/workspace scope', async () => { for (let i = 0; i < 105; i += 1) { await app.inject({ method: 'POST', url: '/api/notes', payload: { ...validNote, id: `note-${i}`, workspaceId: i % 2 === 0 ? 'ws-1' : 'ws-2', title: `Note ${i}`, }, }); } extractAuthMock.mockResolvedValueOnce({ sub: 'user_2', type: 'access', role: 'editor' }); await app.inject({ method: 'POST', url: '/api/notes', payload: { ...validNote, id: 'other-user-note', title: 'Other User' }, }); const res = await app.inject({ method: 'GET', url: '/api/notes/export?workspaceId=ws-1' }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.metadata.noteCount).toBe(53); expect(body.scope).toEqual({ userId: 'user_1', workspaceId: 'ws-1' }); expect(body.notes.every((note: { workspaceId: string }) => note.workspaceId === 'ws-1')).toBe(true); expect(body.notes.some((note: { id: string }) => note.id === 'other-user-note')).toBe(false); }); it('GET /notes/export rejects invalid format', async () => { const res = await app.inject({ method: 'GET', url: '/api/notes/export?format=csv' }); expect(res.statusCode).toBe(400); }); 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); }); it('POST /notes/search returns ranked hits in hybrid mode', async () => { await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); const res = await app.inject({ method: 'POST', url: '/api/notes/search', payload: { q: 'Test', mode: 'hybrid', limit: 10, offset: 0 }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body) as { mode: string; items: Array<{ noteId: string }> }; expect(body.mode).toBe('hybrid'); expect(body.items.length).toBeGreaterThan(0); expect(body.items[0].noteId).toBe('note-1'); }); });