Files
HealthLog/src/app/api/labs/__tests__/error-contract.test.ts
T
Marc-André Bombeck ab6c1cd917 fix(api): put a ceiling under the single-record writes
The batch endpoints have been capped at 60 calls a minute since they
were written. The per-record siblings a naive or older client loops
over — the measurement create, the mood entry, both medication-intake
writes, a lab result, a custom-metric entry, a cycle day-log, a
vaccination, an allergy, a biomarker and an encounter — had no ceiling
at all. That is backwards from the risk: the batch endpoint is the one
a well-behaved client uses, and the unlimited path is the one a broken
loop finds.

All eleven share one generous per-account bucket, `record-write`, at
300 requests a minute. Five writes a second sustained for a full minute
is beyond any hand-driven session and any sync burst, and every one of
these routes is already wrapped in `withIdempotency`, so an honest
retry storm is deduplicated before it is counted — the bucket exists to
cut off the pathological loop, not to shape traffic. It keys on the
ACTING account, the precedent the delegable medication routes set, so a
delegate burns their own allowance rather than locking the record's
owner out of their own log.

The eleven operations publish the bucket name and the cap instead of
the generic refusal, on the shared 429 that already declares
`Retry-After` and the rate-limit triple. A test pulls the key and the
window out of the query the limiter runs and holds the published
sentence to them, so the paragraph cannot drift away from the bucket it
describes.
2026-09-10 07:42:07 +02:00

177 lines
5.6 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
// The shared single-record write ceiling. Allowed here: this file is about
// what the handler does with a body it accepted, not about the bucket.
checkRecordWriteRateLimit: vi.fn(async () => ({
allowed: true,
limit: 300,
remaining: 299,
resetAt: Date.now() + 60_000,
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
labResult: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
findMany: vi.fn(),
count: vi.fn(),
},
biomarker: { findFirst: vi.fn() },
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
enqueueReminderSatisfy: vi.fn(),
}));
vi.mock("@/lib/logging/fire-and-forget", () => ({ fireAndForget: vi.fn() }));
vi.mock("@/lib/arrivals/emit-shared", () => ({ emitDataArrival: vi.fn() }));
vi.mock("@/lib/labs/biomarker-store", () => ({
resolveOrMintBiomarker: vi.fn(),
}));
vi.mock("@/lib/labs/store", () => ({
encryptNoteToBytes: vi.fn(),
decryptNoteFromBytes: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { PUT } from "../[id]/route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
const SESSION = {
session: { id: "s1", expiresAt: new Date(Date.now() + 60_000) },
user: { id: "u1", username: "user", role: "USER" as const },
};
const EXISTING = {
id: "lab-1",
userId: "u1",
biomarkerId: null,
analyte: "LDL",
unit: "mg/dL",
value: 50,
valueText: null,
referenceLow: 0,
referenceHigh: 100,
takenAt: new Date("2026-07-01T08:00:00.000Z"),
noteEncrypted: null,
deletedAt: null,
};
function postReq(body: unknown) {
return new NextRequest("http://localhost/api/labs", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function putReq(body: unknown) {
return new NextRequest("http://localhost/api/labs/lab-1", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const ctx = { params: Promise.resolve({ id: "lab-1" }) };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// v1.36.x — the create arm's validation breadcrumb goes through the
// `auditLog` helper, the only writer that stamps `actorUserId`. The route
// chains `.catch()` onto it, so the stub has to return a promise.
vi.mocked(auditLog).mockResolvedValue(undefined);
});
describe("lab result error contract", () => {
it("pins create validation", async () => {
const res = await POST(postReq({}));
expect(res.status).toBe(422);
expect((await res.json()).meta?.errorCode).toBe("labs.create.invalid");
});
it("pins a foreign or missing biomarker", async () => {
vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(null);
const res = await POST(
postReq({
biomarkerId: "bm-missing",
value: 10,
takenAt: "2026-07-01T08:00:00.000Z",
}),
);
expect(res.status).toBe(404);
expect((await res.json()).meta?.errorCode).toBe("labs.biomarker.notFound");
});
it("pins update not-found", async () => {
vi.mocked(prisma.labResult.findFirst).mockResolvedValue(null);
const res = await PUT(putReq({ value: 10 }), ctx);
expect(res.status).toBe(404);
expect((await res.json()).meta?.errorCode).toBe("labs.result.notFound");
});
it("pins update validation", async () => {
vi.mocked(prisma.labResult.findFirst).mockResolvedValue(EXISTING as never);
const res = await PUT(putReq({ takenAt: "bad-date" }), ctx);
expect(res.status).toBe(422);
expect((await res.json()).meta?.errorCode).toBe("labs.update.invalid");
});
it.each([
{
existing: { ...EXISTING, biomarkerId: "bm-1" },
update: { unit: "g/L" },
code: "labs.update.linkedFieldsImmutable",
},
{
existing: { ...EXISTING, value: null, valueText: "negative" },
update: { value: 1 },
code: "labs.update.qualitativeExpected",
},
{
existing: { ...EXISTING, value: 1, valueText: null },
update: { valueText: "negative" },
code: "labs.update.numericExpected",
},
{
existing: { ...EXISTING, referenceLow: 0, referenceHigh: 10 },
update: { referenceLow: 20 },
code: "labs.update.referenceRangeInvalid",
},
])("pins $code", async ({ existing, update, code }) => {
vi.mocked(prisma.labResult.findFirst).mockResolvedValue(existing as never);
const res = await PUT(putReq(update), ctx);
expect(res.status).toBe(422);
expect((await res.json()).meta?.errorCode).toBe(code);
expect(prisma.labResult.update).not.toHaveBeenCalled();
});
});