fix(nutrients): evict the dashboard snapshot on a landed batch row

POST /api/nutrients/batch performed zero cache invalidation, so an
Apple-Health-synced water/nutrient total kept the dashboard's tile
serving the pre-sync figure until the analytics fresh TTL lapsed
(~3 min) — the manual water POST already hard-evicts on its own write.

Calls invalidateUserDashboardSnapshot(user.id) once at least one row
inserted or updated, mirroring the manual route's own posture so a
synced total reflects on the very next dashboard read.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 06:47:32 +02:00
1 parent 56a32c528e
commit 1d559d7ed0
2 files changed
+46

No files matched your search

@@ -46,6 +46,9 @@ vi.mock("@/lib/idempotency", () => ({
fn(...args),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserDashboardSnapshot: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -65,6 +68,7 @@ import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -315,6 +319,40 @@ describe("POST /api/nutrients/batch — write semantics", () => {
expect(prisma.nutrientIntakeDay.update).not.toHaveBeenCalled();
});
// v1.30.3 (QA F9) — a landed row must evict the dashboard snapshot so
// the water/nutrient tile reflects the new total on the very next read,
// matching the manual water POST's own posture (`nutrients/water/route.ts`).
it("evicts the dashboard snapshot when a row lands", async () => {
const day = recentDay();
const res = await POST(
postReq({
entries: [{ day, nutrient: "vitamin_d", unit: "ug", amount: 22.5 }],
}),
);
expect(res.status).toBe(200);
expect(invalidateUserDashboardSnapshot).toHaveBeenCalledTimes(1);
expect(invalidateUserDashboardSnapshot).toHaveBeenCalledWith("user-1");
});
it("does NOT evict the dashboard snapshot when nothing lands (every entry skipped)", async () => {
const res = await POST(
postReq({
entries: [
{
day: recentDay(),
nutrient: "vitamin_d",
unit: "mg", // wrong unit vs the catalog's canonical "ug" — skipped
amount: 22.5,
},
],
}),
);
const body = (await res.json()) as BatchResponse;
expect(body.data.inserted).toBe(0);
expect(body.data.updated).toBe(0);
expect(invalidateUserDashboardSnapshot).not.toHaveBeenCalled();
});
it("reports updated (never duplicate) when the composite key already exists", async () => {
const day = recentDay();
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([
+8
View File
@@ -40,6 +40,7 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
import {
MAX_NUTRIENT_ENTRIES_PER_BATCH,
@@ -324,6 +325,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
skipped: skipped.length,
},
});
// v1.30.3 (QA F9) — a landed row changes the dashboard's water/nutrient
// tile; without this the snapshot kept serving the pre-sync figure
// until the analytics fresh TTL lapsed (~3 min). Mirrors the manual
// water POST's own hard-evict (`nutrients/water/route.ts`) so an
// Apple-Health-synced total reflects on the very next dashboard read,
// same as a manually logged one.
invalidateUserDashboardSnapshot(user.id);
}
annotate({