mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
test(medication-compliance-rollups): hook, reader, and tz parity
Unit-covers the writer's `(scheduled, taken, skipped)` upsert, the zero-event delete, idempotency across repeated invocations, the tz-anchored UTC window math, and the Berlin vs Los Angeles bucket parity for an instant that lands on different local days. Reader tests pin the trailing-window zero-fill plus the per-medication fold-to-day. Boot-backfill discovery tests pin the singleton-keyed enqueue. Route tests extend the existing harness with the new coverage probe — rollup-tier read on coverage, live fallback on miss — without touching the byte-stable response shape.
This commit is contained in:
1 parent
dfdf6be598
commit
7657b848f0
2 files changed
+455
No files matched your search
@@ -9,10 +9,20 @@ vi.mock("@/lib/db", () => ({
|
||||
update: vi.fn(),
|
||||
},
|
||||
medication: { update: vi.fn() },
|
||||
medicationComplianceRollup: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
@@ -52,6 +62,15 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
// v1.4.39 W-MED — default the coverage probe to "uncovered" so the
|
||||
// legacy compliance test still exercises the live-fallback branch
|
||||
// and finds the legacy mocked intake events.
|
||||
vi.mocked(prisma.medicationComplianceRollup.findFirst).mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
vi.mocked(prisma.medicationComplianceRollup.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
});
|
||||
|
||||
describe("GET /api/medications/intake", () => {
|
||||
@@ -154,6 +173,7 @@ describe("POST /api/medications/intake", () => {
|
||||
id: "e1",
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: new Date("2026-05-18T10:00:00.000Z"),
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({
|
||||
id: "e1",
|
||||
@@ -168,3 +188,56 @@ describe("POST /api/medications/intake", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("v1.4.39 W-MED — compliance rollup read swap", () => {
|
||||
it("reads the rollup tier when coverage is present", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Coverage probe returns "at least one row" so the route reads
|
||||
// from the rollup tier rather than falling back to the live
|
||||
// aggregator.
|
||||
vi.mocked(prisma.medicationComplianceRollup.findFirst).mockResolvedValue({
|
||||
day: "2026-05-18",
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationComplianceRollup.findMany).mockResolvedValue([
|
||||
{ day: "2026-05-18", scheduled: 3, taken: 2, skipped: 1 },
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/medications/intake?scope=compliance&days=7",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: Array<{ date: string; scheduled: number; taken: number }>;
|
||||
};
|
||||
expect(body.data).toHaveLength(7);
|
||||
const today = body.data[body.data.length - 1];
|
||||
expect(today.scheduled).toBe(3);
|
||||
expect(today.taken).toBe(2);
|
||||
// The legacy live aggregator must not have run when coverage is hot.
|
||||
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the live aggregator on coverage miss", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationComplianceRollup.findFirst).mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
scheduledFor: new Date(),
|
||||
takenAt: new Date(),
|
||||
skipped: false,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/medications/intake?scope=compliance&days=7",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.findMany).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* v1.4.39 W-MED — unit tests for the medication-compliance rollup tier.
|
||||
*
|
||||
* `prisma` is mocked at the module boundary so the test can pin the
|
||||
* intake-event fan-out + the rollup upsert / read shape without
|
||||
* standing up a real Postgres. Integration coverage (real Postgres +
|
||||
* the FK cascade behaviour) lives next to the route test suite when
|
||||
* the W-MED phase grows them; this file proves the helpers'
|
||||
* contract.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
intakeFindMany: vi.fn(),
|
||||
rollupUpsert: vi.fn(),
|
||||
rollupDeleteMany: vi.fn(),
|
||||
rollupFindMany: vi.fn(),
|
||||
rollupFindFirst: vi.fn(),
|
||||
queryRaw: vi.fn(),
|
||||
bossSend: vi.fn(),
|
||||
getGlobalBossMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medicationIntakeEvent: {
|
||||
findMany: mocks.intakeFindMany,
|
||||
},
|
||||
medicationComplianceRollup: {
|
||||
upsert: mocks.rollupUpsert,
|
||||
deleteMany: mocks.rollupDeleteMany,
|
||||
findMany: mocks.rollupFindMany,
|
||||
findFirst: mocks.rollupFindFirst,
|
||||
},
|
||||
$queryRaw: mocks.queryRaw,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: () => mocks.getGlobalBossMock(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
dayKeyForScheduledFor,
|
||||
recomputeMedicationComplianceForDay,
|
||||
recomputeMedicationComplianceForEvent,
|
||||
readMedicationCompliance,
|
||||
hasMedicationComplianceCoverage,
|
||||
recomputeUserMedicationCompliance,
|
||||
enqueueBootTimeMedicationComplianceBackfill,
|
||||
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
|
||||
} from "../compliance-rollups";
|
||||
|
||||
const {
|
||||
intakeFindMany,
|
||||
rollupUpsert,
|
||||
rollupDeleteMany,
|
||||
rollupFindMany,
|
||||
rollupFindFirst,
|
||||
queryRaw,
|
||||
bossSend,
|
||||
getGlobalBossMock,
|
||||
} = mocks;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
intakeFindMany.mockResolvedValue([]);
|
||||
rollupUpsert.mockResolvedValue({});
|
||||
rollupDeleteMany.mockResolvedValue({ count: 0 });
|
||||
rollupFindMany.mockResolvedValue([]);
|
||||
rollupFindFirst.mockResolvedValue(null);
|
||||
queryRaw.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("dayKeyForScheduledFor", () => {
|
||||
it("anchors the dayKey on the user's tz wall clock", () => {
|
||||
// 2026-05-17T23:30:00Z — Berlin local is 2026-05-18 01:30 (CEST).
|
||||
const ts = new Date("2026-05-17T23:30:00.000Z");
|
||||
expect(dayKeyForScheduledFor(ts, "Europe/Berlin")).toBe("2026-05-18");
|
||||
// Los Angeles is UTC-7 (PDT) — still 2026-05-17 16:30 local.
|
||||
expect(dayKeyForScheduledFor(ts, "America/Los_Angeles")).toBe("2026-05-17");
|
||||
});
|
||||
|
||||
it("falls back to the server default when tz is null", () => {
|
||||
const ts = new Date("2026-05-17T22:00:00.000Z");
|
||||
expect(dayKeyForScheduledFor(ts, null)).toBe(
|
||||
dayKeyForScheduledFor(ts, "Europe/Berlin"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recomputeMedicationComplianceForDay", () => {
|
||||
it("upserts a row reflecting the (scheduled, taken, skipped) counts", async () => {
|
||||
intakeFindMany.mockResolvedValue([
|
||||
{ takenAt: new Date(), skipped: false }, // taken
|
||||
{ takenAt: null, skipped: true }, // skipped
|
||||
{ takenAt: null, skipped: false }, // pending
|
||||
]);
|
||||
|
||||
await recomputeMedicationComplianceForDay(
|
||||
"user-1",
|
||||
"med-1",
|
||||
"2026-05-18",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
|
||||
expect(rollupUpsert).toHaveBeenCalledTimes(1);
|
||||
const call = rollupUpsert.mock.calls[0][0];
|
||||
expect(call.where).toEqual({
|
||||
userId_medicationId_day: {
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
day: "2026-05-18",
|
||||
},
|
||||
});
|
||||
expect(call.create).toMatchObject({
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
day: "2026-05-18",
|
||||
scheduled: 3,
|
||||
taken: 1,
|
||||
skipped: 1,
|
||||
});
|
||||
expect(call.update).toMatchObject({
|
||||
scheduled: 3,
|
||||
taken: 1,
|
||||
skipped: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes the rollup row when the user-day window holds zero events", async () => {
|
||||
intakeFindMany.mockResolvedValue([]);
|
||||
|
||||
await recomputeMedicationComplianceForDay(
|
||||
"user-1",
|
||||
"med-1",
|
||||
"2026-05-18",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
|
||||
expect(rollupDeleteMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", medicationId: "med-1", day: "2026-05-18" },
|
||||
});
|
||||
expect(rollupUpsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is idempotent across repeated invocations", async () => {
|
||||
intakeFindMany.mockResolvedValue([
|
||||
{ takenAt: new Date(), skipped: false },
|
||||
]);
|
||||
|
||||
await recomputeMedicationComplianceForDay(
|
||||
"user-1",
|
||||
"med-1",
|
||||
"2026-05-18",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
await recomputeMedicationComplianceForDay(
|
||||
"user-1",
|
||||
"med-1",
|
||||
"2026-05-18",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
|
||||
expect(rollupUpsert).toHaveBeenCalledTimes(2);
|
||||
const first = rollupUpsert.mock.calls[0][0].create;
|
||||
const second = rollupUpsert.mock.calls[1][0].create;
|
||||
expect(first).toEqual(second);
|
||||
});
|
||||
|
||||
it("queries with a tz-anchored UTC window", async () => {
|
||||
intakeFindMany.mockResolvedValue([
|
||||
{ takenAt: new Date(), skipped: false },
|
||||
]);
|
||||
|
||||
await recomputeMedicationComplianceForDay(
|
||||
"user-1",
|
||||
"med-1",
|
||||
"2026-05-18",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
|
||||
const where = intakeFindMany.mock.calls[0][0].where;
|
||||
// Berlin 2026-05-18 00:00 → UTC 2026-05-17 22:00 (CEST is UTC+2).
|
||||
expect(where.scheduledFor.gte.toISOString()).toBe(
|
||||
"2026-05-17T22:00:00.000Z",
|
||||
);
|
||||
// Window closes 24h later — Berlin 2026-05-19 00:00 → UTC 2026-05-18 22:00.
|
||||
expect(where.scheduledFor.lt.toISOString()).toBe(
|
||||
"2026-05-18T22:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("buckets the same UTC instant on different days for Berlin vs LA", async () => {
|
||||
// 2026-05-17T23:30:00Z is 2026-05-18 in Berlin but 2026-05-17 in LA.
|
||||
intakeFindMany.mockResolvedValue([
|
||||
{ takenAt: new Date(), skipped: false },
|
||||
]);
|
||||
|
||||
await recomputeMedicationComplianceForDay(
|
||||
"user-1",
|
||||
"med-1",
|
||||
"2026-05-18",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
const berlinCall = intakeFindMany.mock.calls[0][0].where;
|
||||
|
||||
intakeFindMany.mockClear();
|
||||
rollupUpsert.mockClear();
|
||||
|
||||
await recomputeMedicationComplianceForDay(
|
||||
"user-2",
|
||||
"med-2",
|
||||
"2026-05-17",
|
||||
"America/Los_Angeles",
|
||||
);
|
||||
const laCall = intakeFindMany.mock.calls[0][0].where;
|
||||
|
||||
// Berlin 2026-05-18 starts at UTC 2026-05-17 22:00.
|
||||
expect(berlinCall.scheduledFor.gte.toISOString()).toBe(
|
||||
"2026-05-17T22:00:00.000Z",
|
||||
);
|
||||
// LA 2026-05-17 starts at UTC 2026-05-17 07:00 (PDT is UTC-7).
|
||||
expect(laCall.scheduledFor.gte.toISOString()).toBe(
|
||||
"2026-05-17T07:00:00.000Z",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recomputeMedicationComplianceForEvent", () => {
|
||||
it("derives the dayKey from scheduledFor + tz then dispatches", async () => {
|
||||
intakeFindMany.mockResolvedValue([
|
||||
{ takenAt: new Date(), skipped: false },
|
||||
]);
|
||||
// 2026-05-17T23:30:00Z → Berlin day 2026-05-18.
|
||||
await recomputeMedicationComplianceForEvent({
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: new Date("2026-05-17T23:30:00.000Z"),
|
||||
tz: "Europe/Berlin",
|
||||
});
|
||||
const call = rollupUpsert.mock.calls[0][0];
|
||||
expect(call.create.day).toBe("2026-05-18");
|
||||
});
|
||||
|
||||
it("swallows recompute errors so the parent write never blocks", async () => {
|
||||
intakeFindMany.mockRejectedValue(new Error("DB melted"));
|
||||
|
||||
await expect(
|
||||
recomputeMedicationComplianceForEvent({
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: new Date("2026-05-17T12:00:00.000Z"),
|
||||
tz: "Europe/Berlin",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readMedicationCompliance", () => {
|
||||
it("returns a trailing-window zero-filled bucket array", async () => {
|
||||
rollupFindMany.mockResolvedValue([
|
||||
{ day: "2026-05-18", scheduled: 2, taken: 1, skipped: 1 },
|
||||
]);
|
||||
|
||||
const now = new Date("2026-05-18T12:00:00.000Z");
|
||||
const buckets = await readMedicationCompliance(
|
||||
"user-1",
|
||||
3,
|
||||
"Europe/Berlin",
|
||||
now,
|
||||
);
|
||||
|
||||
expect(buckets).toHaveLength(3);
|
||||
expect(buckets[buckets.length - 1]).toEqual({
|
||||
date: "2026-05-18",
|
||||
scheduled: 2,
|
||||
taken: 1,
|
||||
});
|
||||
expect(buckets[0].scheduled).toBe(0);
|
||||
expect(buckets[0].taken).toBe(0);
|
||||
expect(buckets.map((b) => b.date)).toEqual([
|
||||
"2026-05-16",
|
||||
"2026-05-17",
|
||||
"2026-05-18",
|
||||
]);
|
||||
});
|
||||
|
||||
it("folds per-medication rows into one per-day total", async () => {
|
||||
rollupFindMany.mockResolvedValue([
|
||||
{ day: "2026-05-18", scheduled: 1, taken: 1, skipped: 0 },
|
||||
{ day: "2026-05-18", scheduled: 2, taken: 0, skipped: 1 },
|
||||
]);
|
||||
const now = new Date("2026-05-18T12:00:00.000Z");
|
||||
const buckets = await readMedicationCompliance(
|
||||
"user-1",
|
||||
1,
|
||||
"Europe/Berlin",
|
||||
now,
|
||||
);
|
||||
expect(buckets[0].scheduled).toBe(3);
|
||||
expect(buckets[0].taken).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasMedicationComplianceCoverage", () => {
|
||||
it("returns true when at least one row exists inside the window", async () => {
|
||||
rollupFindFirst.mockResolvedValue({ day: "2026-05-17" });
|
||||
await expect(
|
||||
hasMedicationComplianceCoverage(
|
||||
"user-1",
|
||||
7,
|
||||
"Europe/Berlin",
|
||||
new Date("2026-05-18T12:00:00.000Z"),
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the window is empty", async () => {
|
||||
rollupFindFirst.mockResolvedValue(null);
|
||||
await expect(
|
||||
hasMedicationComplianceCoverage("user-1", 7, "Europe/Berlin"),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recomputeUserMedicationCompliance", () => {
|
||||
it("upserts one rollup per (medication, day) pair returned by discovery", async () => {
|
||||
queryRaw.mockResolvedValue([
|
||||
{ medication_id: "med-1", day: "2026-05-17" },
|
||||
{ medication_id: "med-1", day: "2026-05-18" },
|
||||
{ medication_id: "med-2", day: "2026-05-18" },
|
||||
]);
|
||||
intakeFindMany.mockResolvedValue([
|
||||
{ takenAt: new Date(), skipped: false },
|
||||
]);
|
||||
|
||||
const result = await recomputeUserMedicationCompliance(
|
||||
"user-1",
|
||||
30,
|
||||
"Europe/Berlin",
|
||||
);
|
||||
|
||||
expect(result.rowsUpserted).toBe(3);
|
||||
expect(rollupUpsert).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enqueueBootTimeMedicationComplianceBackfill", () => {
|
||||
it("is a no-op when no boss is attached", async () => {
|
||||
getGlobalBossMock.mockReturnValue(null);
|
||||
const result = await enqueueBootTimeMedicationComplianceBackfill();
|
||||
expect(result).toEqual({ enqueued: 0, skipped: 0, error: null });
|
||||
expect(queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enqueues one job per uncovered user", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockResolvedValue([{ id: "user-1" }, { id: "user-2" }]);
|
||||
bossSend.mockResolvedValueOnce("job-1");
|
||||
bossSend.mockResolvedValueOnce(null); // coalesced
|
||||
const result = await enqueueBootTimeMedicationComplianceBackfill();
|
||||
expect(result.enqueued).toBe(1);
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(result.error).toBeNull();
|
||||
expect(bossSend).toHaveBeenCalledWith(
|
||||
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
expect.objectContaining({
|
||||
singletonKey: "medication-compliance-boot-backfill|user-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user