Infrastructure build to support third-party app integrations
This commit is contained in:
217
backend/tests/notes.dao.test.ts
Normal file
217
backend/tests/notes.dao.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user