const request = require('supertest'); jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma')); // Always miss the cache in unit tests so each test exercises the real route logic. jest.mock('../src/lib/cache', () => ({ get: jest.fn(() => undefined), set: jest.fn(), del: jest.fn(), delByPrefix: jest.fn() })); const prisma = require('../src/lib/prisma'); const app = require('../src/app'); const EXP_ID = 'aaaaaaaa-0000-0000-0000-000000000001'; const EXPERIMENT = { id: EXP_ID, title: 'DS Test Study', created_at: new Date('2024-01-01').toISOString(), }; function makeStatus(overrides = {}) { return { id: overrides.id ?? 'dddddddd-0000-0000-0000-000000000001', animal_id: overrides.animalId ?? 'bbbbbbbb-0000-0000-0000-000000000001', date: new Date(overrides.date ?? '2026-04-01'), analysis_summary: overrides.analysis_summary ?? null, experiment_description: overrides.experiment_description ?? null, vitals: overrides.vitals ?? null, treatment: overrides.treatment ?? null, notes: overrides.notes ?? null, custom_fields: overrides.custom_fields ?? {}, }; } beforeEach(() => { jest.clearAllMocks(); }); // ── GET /api/experiments/:id/daily-statuses ──────────────────────────────────── describe('GET /api/experiments/:id/daily-statuses', () => { it('returns 404 when experiment does not exist', async () => { prisma.experiment.findUnique.mockResolvedValue(null); const res = await request(app).get(`/api/experiments/${EXP_ID}/daily-statuses`); expect(res.status).toBe(404); expect(res.body.error).toMatch(/not found/i); }); it('returns all statuses when no filters are provided', async () => { prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT); const statuses = [ makeStatus({ id: 'id-1', analysis_summary: { total: 10, counts: { Success: 8 }, success_rate: 0.8 } }), makeStatus({ id: 'id-2', analysis_summary: null }), makeStatus({ id: 'id-3', analysis_summary: null }), ]; prisma.dailyStatus.findMany.mockResolvedValue(statuses); const res = await request(app).get(`/api/experiments/${EXP_ID}/daily-statuses`); expect(res.status).toBe(200); expect(res.body).toHaveLength(3); }); // ── analysisOnly filter — this is the scenario that caused a 500 ────────── it('analysisOnly=true returns only statuses with non-null analysis_summary', async () => { prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT); const summary = { total: 20, counts: { Success: 15, Failure: 5 }, success_rate: 0.75 }; prisma.dailyStatus.findMany.mockResolvedValue([ makeStatus({ id: 'id-1', analysis_summary: summary }), makeStatus({ id: 'id-2', analysis_summary: null }), // must be excluded makeStatus({ id: 'id-3', analysis_summary: null }), // must be excluded makeStatus({ id: 'id-4', analysis_summary: summary }), ]); const res = await request(app) .get(`/api/experiments/${EXP_ID}/daily-statuses?analysisOnly=true`); expect(res.status).toBe(200); expect(res.body).toHaveLength(2); expect(res.body.every((s) => s.analysis_summary !== null)).toBe(true); expect(res.body.map((s) => s.id)).toEqual(['id-1', 'id-4']); }); it('analysisOnly=true returns empty array when no statuses have analysis_summary', async () => { prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT); prisma.dailyStatus.findMany.mockResolvedValue([ makeStatus({ id: 'id-1', analysis_summary: null }), makeStatus({ id: 'id-2', analysis_summary: null }), ]); const res = await request(app) .get(`/api/experiments/${EXP_ID}/daily-statuses?analysisOnly=true`); expect(res.status).toBe(200); expect(res.body).toEqual([]); }); it('analysisOnly=true does not 500 when Prisma returns mixed null / object values', async () => { // This is the exact shape that caused the production 500 — the fix must not // rely on a Prisma WHERE clause (which errors on Json? null filtering in v5). prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT); prisma.dailyStatus.findMany.mockResolvedValue([ makeStatus({ id: 'id-null', analysis_summary: null }), makeStatus({ id: 'id-value', analysis_summary: { total: 5, counts: {}, success_rate: null } }), ]); const res = await request(app) .get(`/api/experiments/${EXP_ID}/daily-statuses?analysisOnly=true`); expect(res.status).toBe(200); expect(res.body).toHaveLength(1); expect(res.body[0].id).toBe('id-value'); }); it('analysisOnly=false (explicit) returns all statuses', async () => { prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT); const statuses = [ makeStatus({ id: 'id-1', analysis_summary: { total: 1, counts: {}, success_rate: null } }), makeStatus({ id: 'id-2', analysis_summary: null }), ]; prisma.dailyStatus.findMany.mockResolvedValue(statuses); const res = await request(app) .get(`/api/experiments/${EXP_ID}/daily-statuses?analysisOnly=false`); expect(res.status).toBe(200); expect(res.body).toHaveLength(2); }); // ── date filter ──────────────────────────────────────────────────────────── it('passes date range to Prisma when ?date= is provided', async () => { prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT); prisma.dailyStatus.findMany.mockResolvedValue([]); await request(app) .get(`/api/experiments/${EXP_ID}/daily-statuses?date=2026-04-23`); const whereArg = prisma.dailyStatus.findMany.mock.calls[0][0].where; expect(whereArg.date).toBeDefined(); expect(whereArg.date.gte).toEqual(new Date('2026-04-23T00:00:00.000Z')); }); it('returns 200 with empty array when no statuses exist', async () => { prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT); prisma.dailyStatus.findMany.mockResolvedValue([]); const res = await request(app).get(`/api/experiments/${EXP_ID}/daily-statuses`); expect(res.status).toBe(200); expect(res.body).toEqual([]); }); it('returned records include the expected fields', async () => { prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT); const summary = { total: 3, counts: { Success: 2 }, success_rate: 0.67 }; prisma.dailyStatus.findMany.mockResolvedValue([ makeStatus({ id: 'id-1', vitals: '120/80', analysis_summary: summary }), ]); const res = await request(app).get(`/api/experiments/${EXP_ID}/daily-statuses`); expect(res.status).toBe(200); const rec = res.body[0]; expect(rec).toMatchObject({ id: 'id-1', vitals: '120/80', analysis_summary: summary, }); }); });