ee2cf19b74
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>
167 lines
6.9 KiB
JavaScript
167 lines
6.9 KiB
JavaScript
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,
|
|
});
|
|
});
|
|
});
|