diff --git a/src/app/api/admin/rollups/recompute/route.ts b/src/app/api/admin/rollups/recompute/route.ts index 63f612c49..e947f635c 100644 --- a/src/app/api/admin/rollups/recompute/route.ts +++ b/src/app/api/admin/rollups/recompute/route.ts @@ -16,8 +16,9 @@ * * - `userId` — single user mode. Synchronously calls * `foldUserRollupHistory(userId)` (the full fold, a calendar year per - * statement) and returns the row count when the fold completes. The call is awaited inside the request, so - * the operator sees the duration in the response. + * statement, each statement under the boot job's budget) and returns the + * row count when the fold completes. The call is awaited inside the + * request, so the operator sees the duration in the response. * - `userId` omitted — calls `enqueueBootTimeRollupBackfill()` to * re-run the worker-boot discovery (`{ enqueued, skipped }` shape). * @@ -38,6 +39,7 @@ import { annotate } from "@/lib/logging/context"; import { enqueueBootTimeRollupBackfill, foldUserRollupHistory, + ROLLUP_FULL_BACKFILL_STATEMENT_BUDGET_MS, } from "@/lib/rollups/full-backfill"; interface RecomputeBody { @@ -70,7 +72,12 @@ export const POST = apiHandler(async (request: NextRequest) => { let result: Awaited>; try { - result = await foldUserRollupHistory(userId); + // The per-statement budget the boot job folds with: this mode is for + // the accounts the boot job could not finish, and under the database's + // ordinary limit their yearly statements time out here too. + result = await foldUserRollupHistory(userId, { + statementTimeoutMs: ROLLUP_FULL_BACKFILL_STATEMENT_BUDGET_MS, + }); } catch (err) { const message = err instanceof Error ? err.message : String(err); return apiError(message, 500); diff --git a/tests/integration/admin-rollups-recompute.test.ts b/tests/integration/admin-rollups-recompute.test.ts new file mode 100644 index 000000000..0ae49e279 --- /dev/null +++ b/tests/integration/admin-rollups-recompute.test.ts @@ -0,0 +1,129 @@ +/** + * `POST /api/admin/rollups/recompute` for one account, against Postgres. + * + * The single-account mode exists for the accounts the boot backfill could not + * finish, which are the large ones. It folds the account in the request, and + * it must give every statement the same budget the boot job gives it + * (`ROLLUP_FULL_BACKFILL_STATEMENT_BUDGET_MS`): under the database's ordinary + * statement limit the yearly fold of such an account times out, which is the + * very failure the operator is trying to get past. The fold itself runs for + * real; the wrapper only records what the route handed it. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ROLLUP_FULL_BACKFILL_STATEMENT_BUDGET_MS } from "@/lib/rollups/full-backfill"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const folds = vi.hoisted(() => ({ + calls: [] as Array<{ userId: string; statementTimeoutMs?: number }>, +})); + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rollups/full-backfill", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + foldUserRollupHistory: async ( + ...args: Parameters + ) => { + folds.calls.push({ + userId: args[0], + statementTimeoutMs: args[1]?.statementTimeoutMs, + }); + return actual.foldUserRollupHistory(...args); + }, + }; +}); + +const ADMIN = "user-admin-recompute"; +const TARGET = "user-recompute-target"; + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + cookieJar.clear(); + folds.calls.length = 0; + await prisma.user.createMany({ + data: [ + { + id: ADMIN, + username: "admin-recompute", + email: "admin-recompute@example.test", + role: "ADMIN", + }, + { + id: TARGET, + username: "recompute-target", + email: "recompute-target@example.test", + }, + ], + }); + const session = await prisma.session.create({ + data: { userId: ADMIN, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + await prisma.measurement.create({ + data: { + userId: TARGET, + type: "WEIGHT", + value: 80, + unit: "kg", + source: "MANUAL", + measuredAt: new Date(Date.now() - 30 * 86_400_000), + }, + }); +}); + +describe("POST /api/admin/rollups/recompute for one account", () => { + it("folds it with the boot job's statement budget", async () => { + const { POST } = await import("@/app/api/admin/rollups/recompute/route"); + const res = await POST( + new NextRequest("http://localhost/api/admin/rollups/recompute", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ userId: TARGET }), + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { mode: string; rowsUpserted: number }; + }; + expect(body.data.mode).toBe("single"); + expect(body.data.rowsUpserted).toBeGreaterThan(0); + + expect(folds.calls).toEqual([ + { + userId: TARGET, + statementTimeoutMs: ROLLUP_FULL_BACKFILL_STATEMENT_BUDGET_MS, + }, + ]); + }); +});