Files
Experiments DB Dev fe4e89f497 feat(backend): PATCH /animals/:id/csv-config endpoint
Implement TDD for new route that accepts CSV configuration with validated buckets.
Route uses auditMiddleware to log config changes and delegates validation to
validateCsvConfig helper from Task 2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 07:49:00 -04:00

191 lines
6.7 KiB
JavaScript

const request = require('supertest');
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
const prisma = require('../src/lib/prisma');
const app = require('../src/app');
const EXP_ID = 'bbbbbbbb-0000-0000-0000-000000000001';
const ANIMAL = {
id: 'cccccccc-0000-0000-0000-000000000001',
experiment_id: EXP_ID,
animal_id_string: 'M-001',
animal_name: 'Whiskers',
_count: { daily_statuses: 0 },
};
beforeEach(() => jest.clearAllMocks());
describe('GET /api/animals', () => {
it('returns animals filtered by experimentId', async () => {
prisma.animal.findMany.mockResolvedValue([ANIMAL]);
const res = await request(app).get(`/api/animals?experimentId=${EXP_ID}`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].animal_id_string).toBe('M-001');
});
it('returns 422 for invalid UUID experimentId', async () => {
const res = await request(app).get('/api/animals?experimentId=not-a-uuid');
expect(res.status).toBe(422);
});
});
describe('GET /api/animals/:id', () => {
it('returns animal with daily_statuses', async () => {
prisma.animal.findUnique.mockResolvedValue({ ...ANIMAL, daily_statuses: [] });
const res = await request(app).get(`/api/animals/${ANIMAL.id}`);
expect(res.status).toBe(200);
expect(res.body.animal_name).toBe('Whiskers');
});
it('returns 404 for unknown animal', async () => {
prisma.animal.findUnique.mockResolvedValue(null);
const res = await request(app).get('/api/animals/00000000-0000-0000-0000-000000000000');
expect(res.status).toBe(404);
});
});
describe('POST /api/animals', () => {
it('creates animal with UUID and writes CREATE audit log', async () => {
prisma.animal.create.mockResolvedValue(ANIMAL);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.post('/api/animals')
.send({ experiment_id: EXP_ID, animal_id_string: 'M-001', animal_name: 'Whiskers' });
expect(res.status).toBe(201);
expect(res.body.id).toMatch(/^[0-9a-f-]{36}$/i);
expect(prisma.auditLog.create).toHaveBeenCalled();
});
it('returns 422 when experiment_id is missing', async () => {
const res = await request(app)
.post('/api/animals')
.send({ animal_id_string: 'M-001', animal_name: 'Whiskers' });
expect(res.status).toBe(422);
expect(res.body.details).toEqual(
expect.arrayContaining([expect.objectContaining({ field: 'experiment_id' })])
);
});
it('returns 422 when experiment_id is not a UUID', async () => {
const res = await request(app)
.post('/api/animals')
.send({ experiment_id: 'bad-id', animal_id_string: 'M-001', animal_name: 'Whiskers' });
expect(res.status).toBe(422);
});
it('returns 422 when animal_name is missing', async () => {
const res = await request(app)
.post('/api/animals')
.send({ experiment_id: EXP_ID, animal_id_string: 'M-001' });
expect(res.status).toBe(422);
});
});
describe('PUT /api/animals/:id', () => {
it('updates animal and writes UPDATE audit log', async () => {
const updated = { ...ANIMAL, animal_name: 'Mr Whiskers' };
prisma.animal.findUnique
.mockResolvedValueOnce(ANIMAL)
.mockResolvedValueOnce(updated);
prisma.animal.update.mockResolvedValue(updated);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.put(`/api/animals/${ANIMAL.id}`)
.send({ animal_name: 'Mr Whiskers' });
expect(res.status).toBe(200);
expect(res.body.animal_name).toBe('Mr Whiskers');
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'UPDATE' }) })
);
});
});
describe('DELETE /api/animals/:id', () => {
it('deletes animal and writes DELETE audit log', async () => {
prisma.animal.findUnique.mockResolvedValue(ANIMAL);
prisma.animal.delete.mockResolvedValue(ANIMAL);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app).delete(`/api/animals/${ANIMAL.id}`);
expect(res.status).toBe(204);
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'DELETE' }) })
);
});
});
describe('PATCH /api/animals/:id/csv-config', () => {
const VALID_CONFIG = {
buckets: [
{ name: 'Success', type: 'note', notes: ['s'] },
{ name: 'Failure', type: 'note', notes: ['f'] },
{ name: 'Other', type: 'note', notes: [] },
{ name: 'Lick latency', type: 'numerical', notes: ['L25', 'L30'] },
],
};
it('updates csv_analysis_config and writes audit log', async () => {
prisma.animal.findUnique.mockResolvedValue(ANIMAL);
prisma.animal.update.mockResolvedValue({ ...ANIMAL, csv_analysis_config: VALID_CONFIG });
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.patch(`/api/animals/${ANIMAL.id}/csv-config`)
.send(VALID_CONFIG);
expect(res.status).toBe(200);
expect(res.body.csv_analysis_config).toEqual(VALID_CONFIG);
expect(prisma.animal.update).toHaveBeenCalledWith({
where: { id: ANIMAL.id },
data: { csv_analysis_config: VALID_CONFIG },
});
});
it('returns 400 when buckets is missing', async () => {
const res = await request(app)
.patch(`/api/animals/${ANIMAL.id}/csv-config`)
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/buckets/);
});
it('returns 400 for empty bucket name', async () => {
const bad = { buckets: [...VALID_CONFIG.buckets, { name: ' ', type: 'note', notes: [] }] };
const res = await request(app)
.patch(`/api/animals/${ANIMAL.id}/csv-config`)
.send(bad);
expect(res.status).toBe(400);
});
it('returns 400 for duplicate names', async () => {
const bad = { buckets: [...VALID_CONFIG.buckets, { name: 'Success', type: 'note', notes: [] }] };
const res = await request(app)
.patch(`/api/animals/${ANIMAL.id}/csv-config`)
.send(bad);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/duplicate/i);
});
it('returns 400 for invalid type', async () => {
const bad = { buckets: [...VALID_CONFIG.buckets, { name: 'X', type: 'weird', notes: [] }] };
const res = await request(app)
.patch(`/api/animals/${ANIMAL.id}/csv-config`)
.send(bad);
expect(res.status).toBe(400);
});
it('returns 400 when missing default bucket', async () => {
const bad = { buckets: VALID_CONFIG.buckets.filter((b) => b.name !== 'Success') };
const res = await request(app)
.patch(`/api/animals/${ANIMAL.id}/csv-config`)
.send(bad);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Success/);
});
});