mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
fix(nutrients): audit every batch ingest call, not only the ones that wrote a row
nutrient.batch.ingest only reached the durable AuditLog table when inserted + updated > 0, so a batch where every entry was rejected (unit mismatch, out-of-range value, invalid day, a write failure) left the exact same empty trail as a phone that never called the endpoint at all. That made an empty nutrient_intake_days table unanswerable from the durable record - the question this surface exists to answer. Record the call unconditionally, the same shape measurement.batch.ingest and workout.batch.ingest already use (counts only, no per-entry health values, no user-typed content), plus an aggregate skip-reason breakdown so an operator can tell, from the row alone, that the phone posted and why nothing landed. HTTP status and the per-entry response shape are unchanged - a batch endpoint returning 200 with per-entry outcomes on a partial failure is the correct, documented contract and native clients already decode it.
This commit is contained in:
1 parent
a1c5d78f72
commit
845b89255d
2 files changed
+77
-10
No files matched your search
@@ -187,6 +187,49 @@ describe("POST /api/nutrients/batch (real Postgres)", () => {
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
// Before this, `nutrient.batch.ingest` audited only when
|
||||
// `inserted + updated > 0` — the ONLY durable record of the call — so a
|
||||
// batch where every entry was rejected left the exact same empty
|
||||
// AuditLog trail as a phone that never posted at all. An operator could
|
||||
// not tell the two apart from the durable record.
|
||||
it("records an audit-log row even when every entry is rejected", async () => {
|
||||
const { POST } = await import("@/app/api/nutrients/batch/route");
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(1), nutrient: "vitamin_d", unit: "mg", amount: 20 },
|
||||
{ day: recentDay(2), nutrient: "iron", unit: "mg", amount: 999 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const json = (await res.json()) as BatchEnvelope;
|
||||
expect(json.data.inserted).toBe(0);
|
||||
expect(json.data.updated).toBe(0);
|
||||
expect(json.data.skipped).toHaveLength(2);
|
||||
|
||||
const rows = await getPrismaClient().auditLog.findMany({
|
||||
where: { userId: OPTED_IN_USER, action: "nutrient.batch.ingest" },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
const details = JSON.parse(rows[0].details ?? "{}") as {
|
||||
processed: number;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
skippedByReason: Record<string, number>;
|
||||
};
|
||||
expect(details).toMatchObject({
|
||||
processed: 2,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
skipped: 2,
|
||||
});
|
||||
expect(details.skippedByReason).toMatchObject({
|
||||
unit_mismatch: 1,
|
||||
value_out_of_range: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses ingest 403 module.disabled for an account that never opted in", async () => {
|
||||
const { POST } = await import("@/app/api/nutrients/batch/route");
|
||||
cookieJar.clear();
|
||||
|
||||
Reference in new issue
Block a user