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