Files
kongruity/backend/tests/ingest.routes.test.ts

159 lines
4.5 KiB
TypeScript

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);
});
});