From fe4e89f49704ad32d9986384395c745dcfa46d8a Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Fri, 1 May 2026 07:49:00 -0400 Subject: [PATCH] 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 --- backend/src/routes/animals.js | 21 +++++++++++ backend/tests/animals.test.js | 70 +++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/backend/src/routes/animals.js b/backend/src/routes/animals.js index 0c645b8..e6d03b2 100644 --- a/backend/src/routes/animals.js +++ b/backend/src/routes/animals.js @@ -3,6 +3,7 @@ const { body, query } = require('express-validator'); const prisma = require('../lib/prisma'); const auditMiddleware = require('../middleware/auditMiddleware'); const { validateRequest } = require('../middleware/errorHandler'); +const { validateCsvConfig } = require('../lib/csvConfigValidation'); const animalValidation = [ body('experiment_id').notEmpty().withMessage('experiment_id is required').matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('experiment_id must be a valid UUID'), @@ -112,4 +113,24 @@ router.delete( } ); +// PATCH /api/animals/:id/csv-config — replace entire CSV-analysis bucket config +router.patch( + '/:id/csv-config', + auditMiddleware('animals', prisma.animal), + async (req, res, next) => { + try { + const err = validateCsvConfig(req.body); + if (err) return res.status(400).json({ error: err }); + + const animal = await prisma.animal.update({ + where: { id: req.params.id }, + data: { csv_analysis_config: req.body }, + }); + res.json(animal); + } catch (e) { + next(e); + } + } +); + module.exports = router; diff --git a/backend/tests/animals.test.js b/backend/tests/animals.test.js index ac96d31..b0328df 100644 --- a/backend/tests/animals.test.js +++ b/backend/tests/animals.test.js @@ -118,3 +118,73 @@ describe('DELETE /api/animals/:id', () => { ); }); }); + +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/); + }); +});