test: add analysisOnly regression tests; fix cache isolation in tests

New file tests/experimentDailyStatuses.test.js — 9 tests covering
GET /api/experiments/:id/daily-statuses:
  - 404 on unknown experiment
  - all statuses returned when no filter
  - analysisOnly=true keeps only non-null analysis_summary records
  - analysisOnly=true returns [] when none have analysis_summary
  - the exact null/object mix that caused the production 500 now passes
  - analysisOnly=false (explicit) returns all records
  - ?date= param is forwarded to Prisma correctly
  - empty-array response on zero statuses
  - returned records include expected fields

Fixed tests/calendar.test.js: existing tests were silently broken by the
cache added in the perf commit. Both test files now mock lib/cache to
always miss, so each test exercises the real route logic in isolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Experiments DB Dev
2026-04-23 15:11:21 -04:00
parent d125d2e365
commit ee2cf19b74
2 changed files with 168 additions and 0 deletions
+2
View File
@@ -1,6 +1,8 @@
const request = require('supertest'); const request = require('supertest');
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma')); 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 prisma = require('../src/lib/prisma');
const app = require('../src/app'); const app = require('../src/app');
@@ -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,
});
});
});