import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Readable } from 'node:stream'; import type { Note, NoteInput } from '../types/domain.js'; const { mockQuery, mockConnect } = vi.hoisted(() => ({ mockQuery: vi.fn(), mockConnect: vi.fn(), })); vi.mock('../db/index.js', () => ({ query: mockQuery, getPool: () => ({ connect: mockConnect }), })); import { getAllNotes, streamAllNotes, getNoteById, createNote, createNotes, } from '../db/notes.dao.js'; const MOCK_ROWS: Note[] = [ { id: 'note_001', text: 'Login flow feels confusing', x: 193, y: 191, author: 'user_5', color: 'yellow' }, { id: 'note_002', text: 'Login flow is broken on mobile', x: 214, y: 281, author: 'user_9', color: 'yellow' }, ]; describe('notes.dao', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('getAllNotes', () => { it('should return all notes ordered by id', async () => { mockQuery.mockResolvedValue({ rows: MOCK_ROWS }); const result = await getAllNotes(); expect(result).toEqual(MOCK_ROWS); expect(mockQuery).toHaveBeenCalledWith( 'SELECT id, text, x, y, author, color FROM notes ORDER BY id' ); }); it('should return an empty array when no notes exist', async () => { mockQuery.mockResolvedValue({ rows: [] }); const result = await getAllNotes(); expect(result).toEqual([]); }); it('should propagate database errors', async () => { mockQuery.mockRejectedValue(new Error('connection refused')); await expect(getAllNotes()).rejects.toThrow('connection refused'); }); }); describe('streamAllNotes', () => { const mockClient = (rows: Note[]) => { const release = vi.fn(); const client = { release, query: vi.fn(() => Readable.from(rows, { objectMode: true })), }; mockConnect.mockResolvedValue(client); return { client, release }; }; it('should stream rows without buffering them into an array', async () => { mockClient(MOCK_ROWS); const stream = await streamAllNotes(); const received: Note[] = []; for await (const row of stream) received.push(row as Note); expect(received).toEqual(MOCK_ROWS); }); it('should release the pooled client once the stream ends', async () => { const { release } = mockClient(MOCK_ROWS); const stream = await streamAllNotes(); for await (const row of stream) void row; expect(release).toHaveBeenCalledOnce(); }); it('should release the pooled client when a consumer destroys the stream early', async () => { const { release } = mockClient(MOCK_ROWS); const stream = await streamAllNotes(); stream.destroy(); await new Promise((resolve) => stream.once('close', resolve)); expect(release).toHaveBeenCalledOnce(); }); it('should release the pooled client when starting the query throws', async () => { const release = vi.fn(); mockConnect.mockResolvedValue({ release, query: vi.fn(() => { throw new Error('cursor failed'); }), }); await expect(streamAllNotes()).rejects.toThrow('cursor failed'); expect(release).toHaveBeenCalledOnce(); }); }); describe('getNoteById', () => { it('should return a single note when found', async () => { mockQuery.mockResolvedValue({ rows: [MOCK_ROWS[0]] }); const result = await getNoteById('note_001'); expect(result).toEqual(MOCK_ROWS[0]); expect(mockQuery).toHaveBeenCalledWith( 'SELECT id, text, x, y, author, color FROM notes WHERE id = $1', ['note_001'] ); }); it('should return null when note is not found', async () => { mockQuery.mockResolvedValue({ rows: [] }); const result = await getNoteById('note_999'); expect(result).toBeNull(); }); }); describe('createNote', () => { it('should insert a note and return it', async () => { const input: NoteInput = { id: 'note_003', text: 'Export fails', x: 100, y: 200, author: 'user_1', color: 'blue' }; mockQuery.mockResolvedValue({ rows: [input] }); const result = await createNote(input); expect(result).toEqual(input); expect(mockQuery).toHaveBeenCalledOnce(); const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]]; expect(sql).toContain('INSERT INTO notes'); expect(params).toEqual(['note_003', 'Export fails', 100, 200, 'user_1', 'blue', '{}']); }); it('should serialize provenance into source_meta', async () => { const input: NoteInput = { id: 'note_005', text: 'From Slack', author: 'user_3', sourceMeta: { provider: 'slack', externalId: 'msg_1' }, }; mockQuery.mockResolvedValue({ rows: [input] }); await createNote(input); const params = mockQuery.mock.calls[0][1] as unknown[]; expect(params[6]).toBe('{"provider":"slack","externalId":"msg_1"}'); }); it('should use defaults for missing x, y, and color', async () => { const input: NoteInput = { id: 'note_004', text: 'Needs fixing', author: 'user_2' }; mockQuery.mockResolvedValue({ rows: [{ ...input, x: 0, y: 0, color: 'yellow' }] }); await createNote(input); const params = mockQuery.mock.calls[0][1] as unknown[]; expect(params[2]).toBe(0); expect(params[3]).toBe(0); expect(params[5]).toBe('yellow'); }); }); describe('createNotes', () => { it('should batch-insert multiple notes and return them', async () => { mockQuery.mockResolvedValue({ rows: MOCK_ROWS }); const result = await createNotes(MOCK_ROWS); expect(result).toEqual(MOCK_ROWS); expect(mockQuery).toHaveBeenCalledOnce(); const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]]; expect(sql).toContain('INSERT INTO notes'); expect(sql).toContain('ON CONFLICT (id) DO NOTHING'); expect(params).toHaveLength(14); }); it('should return an empty array without querying when given no notes', async () => { const result = await createNotes([]); expect(result).toEqual([]); expect(mockQuery).not.toHaveBeenCalled(); }); it('should split large inputs into multiple statements under the bind-parameter limit', async () => { const many: NoteInput[] = Array.from({ length: 2500 }, (_, i) => ({ id: `note_${i}`, text: `text ${i}`, author: 'user_1', })); mockQuery.mockImplementation(async (_sql: string, params: unknown[]) => ({ rows: new Array(params.length / 7).fill(null).map((_, i) => ({ i })), })); const result = await createNotes(many); expect(mockQuery).toHaveBeenCalledTimes(3); expect(result).toHaveLength(2500); for (const [, params] of mockQuery.mock.calls as [string, unknown[]][]) { expect(params.length).toBeLessThan(65535); } }); }); });