import { describe, it, expect, vi, beforeEach } from 'vitest'; import request from 'supertest'; import app from '../app.js'; import type { IntegrationRow } from '../types/integration.js'; import type { Note, NoteInput } from '../types/domain.js'; vi.mock('../db/integrations.dao.js', () => ({ findByApiKeyHash: vi.fn(), })); vi.mock('../db/notes.dao.js', () => ({ createNotes: vi.fn(), getAllNotes: vi.fn(), streamAllNotes: vi.fn(), })); import { findByApiKeyHash } from '../db/integrations.dao.js'; import { createNotes } from '../db/notes.dao.js'; const mockFindByApiKeyHash = vi.mocked(findByApiKeyHash); const mockCreateNotes = vi.mocked(createNotes); const INTEGRATION: IntegrationRow = { id: 7, provider: 'rest', externalWorkspaceId: 'acme', displayName: 'Acme', scopes: [], tokenExpiresAt: null, }; const INPUT: NoteInput[] = [ { id: 'note_100', text: 'Retro: deploys are scary', author: 'user_1' }, ]; const STORED: Note[] = [ { id: 'note_100', text: 'Retro: deploys are scary', x: 0, y: 0, author: 'user_1', color: 'yellow' }, ]; const post = (body: object, key = 'secret-key') => request(app).post('/v1/notes').set('Authorization', `Bearer ${key}`).send(body); describe('POST /v1/notes', () => { beforeEach(() => { vi.clearAllMocks(); mockFindByApiKeyHash.mockResolvedValue(INTEGRATION); mockCreateNotes.mockResolvedValue(STORED); }); it('should insert notes and return 201 with the stored rows', async () => { const res = await post({ notes: INPUT }); expect(res.status).toBe(201); expect(res.body).toEqual({ inserted: 1, notes: STORED }); expect(mockCreateNotes).toHaveBeenCalledWith(INPUT); }); it('should report the inserted count from the database, not the request', async () => { mockCreateNotes.mockResolvedValue([]); const res = await post({ notes: INPUT }); expect(res.body.inserted).toBe(0); }); it('should reject a body with no notes key', async () => { const res = await post({}); expect(res.status).toBe(400); expect(mockCreateNotes).not.toHaveBeenCalled(); }); it('should reject notes that is not an array', async () => { const res = await post({ notes: 'nope' }); expect(res.status).toBe(400); }); it('should reject an empty notes array', async () => { const res = await post({ notes: [] }); expect(res.status).toBe(400); expect(mockCreateNotes).not.toHaveBeenCalled(); }); it('should reject a note missing text', async () => { const res = await post({ notes: [{ id: 'a', author: 'user_1' }] }); expect(res.status).toBe(400); }); it('should reject a note whose x is not a number', async () => { const res = await post({ notes: [{ ...INPUT[0], x: 'left' }] }); expect(res.status).toBe(400); }); it('should reject a batch over the per-request limit', async () => { const many = Array.from({ length: 5001 }, (_, i) => ({ id: `note_${i}`, text: 'text', author: 'user_1', })); const res = await post({ notes: many }); expect(res.status).toBe(400); expect(mockCreateNotes).not.toHaveBeenCalled(); }); it('should return 401 when no Authorization header is sent', async () => { const res = await request(app).post('/v1/notes').send({ notes: INPUT }); expect(res.status).toBe(401); expect(mockCreateNotes).not.toHaveBeenCalled(); }); it('should return 401 for a non-bearer scheme', async () => { const res = await request(app) .post('/v1/notes') .set('Authorization', 'Basic abc123') .send({ notes: INPUT }); expect(res.status).toBe(401); }); it('should return 401 for an unknown key without revealing why', async () => { mockFindByApiKeyHash.mockResolvedValue(null); const res = await post({ notes: INPUT }); expect(res.status).toBe(401); expect(res.body).toEqual({ error: 'Unauthorized' }); }); it('should not send the raw key to the database', async () => { await post({ notes: INPUT }, 'plaintext-key'); const [hash] = mockFindByApiKeyHash.mock.calls[0]; expect(hash).not.toContain('plaintext-key'); expect(hash).toMatch(/^[0-9a-f]{64}$/); }); it('should return 500 when the insert fails', async () => { mockCreateNotes.mockRejectedValue(new Error('connection refused')); const res = await post({ notes: INPUT }); expect(res.status).toBe(500); }); it('should leave the existing GET /v1/notes route reachable', async () => { const res = await request(app).get('/v1/notes'); expect(res.status).not.toBe(404); }); });