159 lines
4.5 KiB
TypeScript
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);
|
|
});
|
|
});
|