diff --git a/backend/tests/calendar.test.js b/backend/tests/calendar.test.js index 2c3574b..576b302 100644 --- a/backend/tests/calendar.test.js +++ b/backend/tests/calendar.test.js @@ -1,6 +1,8 @@ const request = require('supertest'); jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma')); +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'); diff --git a/backend/tests/experimentDailyStatuses.test.js b/backend/tests/experimentDailyStatuses.test.js new file mode 100644 index 0000000..be34634 --- /dev/null +++ b/backend/tests/experimentDailyStatuses.test.js @@ -0,0 +1,166 @@ +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, + }); + }); +});