mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
fix(ingest): treat upsert_failed as a retry in the nutrient and cycle batches
The nutrient batch docs told a client to log and drop an `upsert_failed` entry. The route means the opposite: a write group failed on the server's side and nothing was stored. The docs now say to send the entry again on a later sync, and keep the three validation skips terminal. A retry under the same Idempotency-Key used to replay the cached failure, because the 200 carrying `upsert_failed` was cached like any other. Both the nutrient and the cycle bulk routes now mark that response `no-store`, as the measurement batch already does for `failed`, so the retry writes again. The cycle bulk route already answered its validation refusal with `cycle.bulk.invalid` and an over-cap batch with `cycle.bulk.too_large`. Both are now pinned by tests and named in the route's 422 response.
This commit is contained in:
1 parent
a7682a9fed
commit
0cc73d3c88
8 files changed
+181
-10
No files matched your search
@@ -203,3 +203,82 @@ describe("POST /api/cycle/day-logs/bulk — external-id stability floor", () =>
|
||||
expect(upsertCycleDayLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/cycle/day-logs/bulk — refusal codes", () => {
|
||||
/**
|
||||
* The native client files rows under a permanent-refusal register as soon
|
||||
* as it can tell the refusal is permanent. It can only tell from the code,
|
||||
* so both whole-batch 422s are pinned here, with the envelope they ride.
|
||||
*/
|
||||
async function refusal(body: unknown) {
|
||||
const res = await POST(postReq(body));
|
||||
return {
|
||||
status: res.status,
|
||||
body: (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details?: { issues: unknown[] };
|
||||
meta?: { errorCode?: string };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("names a malformed batch cycle.bulk.invalid, with every issue listed", async () => {
|
||||
const { status, body } = await refusal({
|
||||
entries: [{ date: "not-a-day" }, { date: "2026-13-40" }],
|
||||
});
|
||||
expect(status).toBe(422);
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.meta?.errorCode).toBe("cycle.bulk.invalid");
|
||||
expect(body.details?.issues.length).toBeGreaterThanOrEqual(2);
|
||||
expect(upsertCycleDayLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names a batch with no entries array cycle.bulk.invalid", async () => {
|
||||
const { status, body } = await refusal({});
|
||||
expect(status).toBe(422);
|
||||
expect(body.meta?.errorCode).toBe("cycle.bulk.invalid");
|
||||
});
|
||||
|
||||
it("names an over-cap batch cycle.bulk.too_large", async () => {
|
||||
const entries = Array.from({ length: 501 }, (_, i) => ({
|
||||
date: "2026-01-01",
|
||||
externalId: `e${i}`,
|
||||
}));
|
||||
const { status, body } = await refusal({ entries });
|
||||
expect(status).toBe(422);
|
||||
expect(body.meta?.errorCode).toBe("cycle.bulk.too_large");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/cycle/day-logs/bulk — a failed write is not cached", () => {
|
||||
it("marks a response carrying upsert_failed no-store and leaves a clean one alone", async () => {
|
||||
vi.mocked(upsertCycleDayLog).mockRejectedValueOnce(new Error("reset"));
|
||||
const failed = await POST(
|
||||
postReq({
|
||||
entries: [{ date: "2026-01-01", loggedAt: "2026-01-01T08:00:00.000Z" }],
|
||||
}),
|
||||
);
|
||||
expect(failed.status).toBe(200);
|
||||
const body = (await failed.json()) as {
|
||||
data: { entries: Array<{ status: string; reason?: string }> };
|
||||
};
|
||||
expect(body.data.entries[0]).toMatchObject({
|
||||
status: "skipped",
|
||||
reason: "upsert_failed",
|
||||
});
|
||||
expect(failed.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
|
||||
vi.mocked(upsertCycleDayLog).mockResolvedValueOnce({
|
||||
id: "d1",
|
||||
existed: false,
|
||||
changed: true,
|
||||
} as never);
|
||||
const clean = await POST(
|
||||
postReq({
|
||||
entries: [{ date: "2026-01-02", loggedAt: "2026-01-02T08:00:00.000Z" }],
|
||||
}),
|
||||
);
|
||||
expect(clean.headers.get("Cache-Control")).not.toBe("private, no-store");
|
||||
});
|
||||
});
|
||||
@@ -249,7 +249,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
const response = apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
@@ -257,4 +257,12 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
skipped,
|
||||
entries: results,
|
||||
});
|
||||
// `upsert_failed` is a write that did not happen, not a verdict on the
|
||||
// entry, and the client sends it again. `no-store` keeps the idempotency
|
||||
// layer from caching this answer, so a retry under the same key writes
|
||||
// instead of replaying the failure.
|
||||
if (results.some((r) => r.reason === "upsert_failed")) {
|
||||
response.headers.set("Cache-Control", "private, no-store");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
Reference in new issue
Block a user