mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
fix(labs): charge the shared record-write bucket on OCR commit
POST /api/labs/ocr/commit can write a whole report and mint markers, yet it was the one lab write without a ceiling. It now charges the same per-account record-write bucket as POST /api/labs and POST /api/biomarkers and answers 429 record_write.rate_limited once exhausted.
This commit is contained in:
1 parent
5d6216a59f
commit
f72ec02484
2 files changed
+45
No files matched your search
@@ -21,6 +21,15 @@ vi.mock("@/lib/db", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
const rateLimitMock = vi.hoisted(() => ({ allowed: true }));
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: rateLimitMock.allowed,
|
||||
remaining: rateLimitMock.allowed ? 299 : 0,
|
||||
resetAt: new Date(Date.now() + 60_000),
|
||||
})),
|
||||
}));
|
||||
const tzMock = vi.hoisted(() => ({ zone: "UTC" }));
|
||||
vi.mock("@/lib/tz/resolver", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/tz/resolver")>()),
|
||||
@@ -62,6 +71,7 @@ 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";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -90,6 +100,7 @@ function postReq(body: unknown): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
tzMock.zone = "UTC";
|
||||
rateLimitMock.allowed = true;
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(enqueueReminderSatisfy).mockResolvedValue(undefined as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
@@ -198,3 +209,24 @@ describe("POST /api/labs/ocr/commit dedup across the user's day", () => {
|
||||
expect(prisma.labResult.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/labs/ocr/commit write ceiling", () => {
|
||||
it("charges the shared per-account record-write bucket", async () => {
|
||||
const res = await POST(postReq({ rows: [numericRow(95)] }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(checkRecordWriteRateLimit).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("answers 429 record_write.rate_limited and writes nothing once exhausted", async () => {
|
||||
rateLimitMock.allowed = false;
|
||||
const res = await POST(postReq({ rows: [numericRow(95)] }));
|
||||
expect(res.status).toBe(429);
|
||||
const body = (await res.json()) as {
|
||||
error: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("record_write.rate_limited");
|
||||
expect(prisma.labResult.create).not.toHaveBeenCalled();
|
||||
expect(resolveOrMintBiomarker).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
@@ -30,6 +31,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
@@ -136,6 +138,17 @@ function inBatchKey(row: OcrCommitRow, tz: string): string {
|
||||
async function commitOcrRows(request: NextRequest) {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Shared per-account write ceiling, the same bucket every other lab write
|
||||
// charges (`POST /api/labs`, `POST /api/biomarkers`). A commit can write a
|
||||
// whole report's rows and mint markers on the way, so it was the one lab
|
||||
// write a looping client could repeat without any ceiling.
|
||||
const writeRl = await checkRecordWriteRateLimit(user.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 256 * 1024,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user