mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(labs): dedup OCR commit rows in-memory, not via DB visibility
The per-row commit loop ran isDuplicate() then create() sequentially, so two identical rows in one confirmed document only collapsed because row 1 autocommitted before row 2's live query. Track written keys in a per-request seen-set keyed on analyte+day+value so in-batch dedup no longer depends on DB visibility. Note the mint-then-create orphan on a mid-row failure is benign and self-healing, matching the manual path.
This commit is contained in:
1 parent
d6cb2f756f
commit
8c19dc51a5
2 files changed
+162
-1
No files matched your search
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* v1.18.9 — POST /api/labs/ocr/commit.
|
||||
*
|
||||
* Focus: dedup correctness. A row that duplicates a live reading is skipped,
|
||||
* and — the regression this guards — two identical rows inside ONE confirmed
|
||||
* document collapse to a single write even though neither is yet visible to a
|
||||
* live query. The in-memory seen-set makes in-batch dedup independent of DB
|
||||
* visibility, rather than relying on the prior row autocommitting first.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
labResult: {
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
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().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/labs/biomarker-store", () => ({
|
||||
resolveOrMintBiomarker: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/labs/serialise", () => ({
|
||||
serialiseLabResult: (created: { id: string }) => ({ id: created.id }),
|
||||
}));
|
||||
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 { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
const TAKEN_AT = "2026-06-10T08:00:00.000Z";
|
||||
|
||||
function numericRow(value: number) {
|
||||
return {
|
||||
analyte: "Glucose",
|
||||
value,
|
||||
unit: "mg/dL",
|
||||
takenAt: TAKEN_AT,
|
||||
};
|
||||
}
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/labs/ocr/commit", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(enqueueReminderSatisfy).mockResolvedValue(undefined as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
// No live reading collides at commit time — the in-batch path is what's
|
||||
// under test, so the DB-visibility check returns empty for every row.
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
|
||||
let nextId = 0;
|
||||
vi.mocked(prisma.labResult.create).mockImplementation((() =>
|
||||
Promise.resolve({ id: `lr-${++nextId}` })) as never);
|
||||
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
|
||||
id: "bm-1",
|
||||
name: "Glucose",
|
||||
unit: "mg/dL",
|
||||
panel: null,
|
||||
lowerBound: null,
|
||||
upperBound: null,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/labs/ocr/commit dedup", () => {
|
||||
it("collapses identical in-document rows to a single write", async () => {
|
||||
const res = await POST(postReq({ rows: [numericRow(95), numericRow(95)] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { inserted: unknown[]; skipped: Array<{ reason: string }> };
|
||||
};
|
||||
|
||||
// Only the first row writes; the second is skipped as an in-batch
|
||||
// duplicate even though the first never became visible to a live query.
|
||||
expect(prisma.labResult.create).toHaveBeenCalledTimes(1);
|
||||
expect(body.data.inserted).toHaveLength(1);
|
||||
expect(body.data.skipped).toEqual([
|
||||
{ analyte: "Glucose", reason: "duplicate" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes distinct values for the same analyte and day", async () => {
|
||||
const res = await POST(
|
||||
postReq({ rows: [numericRow(95), numericRow(110)] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { inserted: unknown[]; skipped: unknown[] };
|
||||
};
|
||||
expect(prisma.labResult.create).toHaveBeenCalledTimes(2);
|
||||
expect(body.data.inserted).toHaveLength(2);
|
||||
expect(body.data.skipped).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,25 @@ async function isDuplicate(
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable in-batch dedup key for a confirmed row: analyte (lower+trim) + UTC day
|
||||
* + the value dimension that `isDuplicate` matches on. Two rows in ONE document
|
||||
* that resolve to the same key are the same reading; only the first is written.
|
||||
* Mirrors the `isDuplicate` match semantics so in-batch dedup no longer depends
|
||||
* on a prior row autocommitting before the next row's live query runs.
|
||||
*/
|
||||
function inBatchKey(row: OcrCommitRow): string {
|
||||
const analyte = row.analyte.trim().toLowerCase();
|
||||
const dayKey = new Date(row.takenAt).toISOString().slice(0, 10);
|
||||
const valuePart =
|
||||
row.value !== undefined
|
||||
? `n:${row.value}`
|
||||
: row.valueText !== undefined
|
||||
? `t:${row.valueText.trim().toLowerCase()}`
|
||||
: "∅";
|
||||
return `${analyte}|${dayKey}|${valuePart}`;
|
||||
}
|
||||
|
||||
async function commitOcrRows(request: NextRequest) {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
@@ -102,9 +121,15 @@ async function commitOcrRows(request: NextRequest) {
|
||||
|
||||
const inserted: ReturnType<typeof serialiseLabResult>[] = [];
|
||||
const skipped: OcrSkippedRowDto[] = [];
|
||||
// Tracks the keys already written in THIS request so an in-document duplicate
|
||||
// is caught even before the prior row is visible to a live query. The
|
||||
// mint-then-create on a mid-row failure can leave an orphan biomarker, which
|
||||
// is benign and self-healing — the manual lab-write path mints the same way.
|
||||
const writtenInBatch = new Set<string>();
|
||||
|
||||
for (const row of parsed.data.rows) {
|
||||
if (await isDuplicate(user.id, row)) {
|
||||
const key = inBatchKey(row);
|
||||
if (writtenInBatch.has(key) || (await isDuplicate(user.id, row))) {
|
||||
skipped.push({ analyte: row.analyte.trim(), reason: "duplicate" });
|
||||
continue;
|
||||
}
|
||||
@@ -138,6 +163,7 @@ async function commitOcrRows(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
writtenInBatch.add(key);
|
||||
inserted.push(serialiseLabResult(created, biomarker));
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user