mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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.
177 lines
5.6 KiB
TypeScript
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();
|
|
});
|
|
});
|