mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0 * test: stabilize water capture re-entry
This commit is contained in:
1 parent
b01822be48
commit
62eb431d66
372 files changed
+25178
-8561
No files matched your search
@@ -96,7 +96,9 @@ export const PUT = apiHandler(
|
||||
where: { id, deletedAt: null },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Lab result not found", 404);
|
||||
return apiError("Lab result not found", 404, {
|
||||
errorCode: "labs.result.notFound",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
@@ -124,7 +126,9 @@ export const PUT = apiHandler(
|
||||
.catch(() => {
|
||||
/* swallow — the 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "labs.update.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const d = parsed.data;
|
||||
@@ -147,6 +151,7 @@ export const PUT = apiHandler(
|
||||
return apiError(
|
||||
"analyte / unit / panel / reference range are resolved from the linked biomarker and cannot be edited on a linked reading",
|
||||
422,
|
||||
{ errorCode: "labs.update.linkedFieldsImmutable" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,12 +164,14 @@ export const PUT = apiHandler(
|
||||
return apiError(
|
||||
"This is a qualitative reading — edit its result text, not a numeric value",
|
||||
422,
|
||||
{ errorCode: "labs.update.qualitativeExpected" },
|
||||
);
|
||||
}
|
||||
if (!rowIsQualitative && d.valueText !== undefined) {
|
||||
return apiError(
|
||||
"This is a numeric reading — edit its value, not a qualitative result",
|
||||
422,
|
||||
{ errorCode: "labs.update.numericExpected" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,7 +204,9 @@ export const PUT = apiHandler(
|
||||
effectiveBound(d.referenceHigh, existing.referenceHigh),
|
||||
)
|
||||
) {
|
||||
return apiError("referenceLow must not exceed referenceHigh", 422);
|
||||
return apiError("referenceLow must not exceed referenceHigh", 422, {
|
||||
errorCode: "labs.update.referenceRangeInvalid",
|
||||
});
|
||||
}
|
||||
if (d.note !== undefined) {
|
||||
data.noteEncrypted = d.note ? encryptNoteToBytes(d.note) : null;
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
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 { 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);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -167,7 +167,9 @@ async function postLabResult(request: NextRequest) {
|
||||
.catch(() => {
|
||||
/* swallow — the 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "labs.create.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -209,7 +211,9 @@ async function postLabResult(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
if (!found) {
|
||||
return apiError("Biomarker not found", 404);
|
||||
return apiError("Biomarker not found", 404, {
|
||||
errorCode: "labs.biomarker.notFound",
|
||||
});
|
||||
}
|
||||
biomarker = found;
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user