learning_ai_notes/backend/src/modules/notes/routes.integration.test.ts

303 lines
12 KiB
TypeScript

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 { createCollaborator } from '../note-collaborators/repository.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 allows direct collaborators to read the note', async () => {
await app.inject({ method: 'POST', url: '/api/notes', payload: validNote });
await createCollaborator({
id: 'collab-1',
productId: 'notelett',
noteId: 'note-1',
workspaceId: 'ws-1',
sharedByUserId: 'user_1',
sharedWithUserId: 'user_2',
permission: 'view',
createdAt: '2026-05-05T00:00:00.000Z',
});
extractAuthMock.mockResolvedValueOnce({ sub: 'user_2', type: 'access', role: 'viewer' });
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('PATCH /notes/:id requires owner or edit collaborator access', async () => {
await app.inject({ method: 'POST', url: '/api/notes', payload: validNote });
await createCollaborator({
id: 'collab-view',
productId: 'notelett',
noteId: 'note-1',
workspaceId: 'ws-1',
sharedByUserId: 'user_1',
sharedWithUserId: 'user_2',
permission: 'view',
createdAt: '2026-05-05T00:00:00.000Z',
});
extractAuthMock.mockResolvedValueOnce({ sub: 'user_2', type: 'access', role: 'editor' });
const res = await app.inject({
method: 'PATCH',
url: '/api/notes/note-1?workspaceId=ws-1',
payload: { title: 'Unauthorized' },
});
expect(res.statusCode).toBe(404);
});
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('creates, lists, and revokes expiring public note shares', async () => {
await app.inject({ method: 'POST', url: '/api/notes', payload: validNote });
const createRes = await app.inject({
method: 'POST',
url: '/api/notes/note-1/share',
payload: { workspaceId: 'ws-1', expiresInDays: 7 },
});
expect(createRes.statusCode).toBe(201);
expect(createRes.json().expiresAt).toBeDefined();
const listRes = await app.inject({ method: 'GET', url: '/api/notes/note-1/shares?workspaceId=ws-1' });
expect(listRes.statusCode).toBe(200);
expect(listRes.json().items).toHaveLength(1);
const deleteRes = await app.inject({
method: 'DELETE',
url: `/api/notes/note-1/shares/${createRes.json().shareToken}?workspaceId=ws-1`,
});
expect(deleteRes.statusCode).toBe(204);
const afterDelete = await app.inject({ method: 'GET', url: '/api/notes/note-1/shares?workspaceId=ws-1' });
expect(afterDelete.json().items).toHaveLength(0);
});
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');
});
});