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