feat(admin): pulse day-mean dry run, apply step and admin card

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 00:58:48 +02:00
1 parent 877920f5f7
commit 9f17cef8e4
17 files changed
+915 -16

No files matched your search

@@ -0,0 +1,275 @@
/**
* `GET/POST /api/admin/maintenance/pulse-day-mean` — the pulse day-mean
* method's dry run and apply step. Admin only; apply needs a finished dry run
* from the last seven days, and nothing starts while a run is active.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: vi.fn(),
appSettings: { findUnique: vi.fn(), upsert: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, POST } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
activePulseDayMeanMethod,
setActivePulseDayMeanMethod,
} from "@/lib/measurements/day-statistic";
const ADMIN = {
session: { id: "s-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" as const },
};
const USER = {
session: { id: "s-2", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "bob", role: "USER" as const },
};
const URL = "http://localhost/api/admin/maintenance/pulse-day-mean";
const get = () =>
(GET as unknown as (r: NextRequest) => Promise<Response>)(
new NextRequest(URL),
);
const post = (body: unknown) =>
(POST as unknown as (r: NextRequest) => Promise<Response>)(
new NextRequest(URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
const NOW = new Date("2026-10-16T12:00:00Z");
const send = vi.fn();
function settings(dryRun: unknown, method = "hour_mean_v1") {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
pulseDayMeanMethod: method,
pulseDayMeanAppliedAt: null,
pulseDayMeanDryRunJson: dryRun,
} as never);
}
function activeJobs(n: number) {
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ n }] as never);
}
const RESULT = {
accounts: 1,
daysCompared: 30,
daysChangedOver1Bpm: 2,
maxChangeBpm: 3.4,
distribution: [
{ bucket: "0-1", days: 28 },
{ bucket: "1-2", days: 1 },
{ bucket: "2-5", days: 1 },
{ bucket: "5+", days: 0 },
],
perAccount: [{ account: "#1", days: 30, changedOver1Bpm: 2 }],
};
function doneAt(iso: string) {
return {
status: "done",
startedAt: iso,
finishedAt: iso,
result: RESULT,
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(NOW);
setActivePulseDayMeanMethod("hour_mean_v1");
vi.mocked(getSession).mockResolvedValue(ADMIN as never);
vi.mocked(getGlobalBoss).mockReturnValue({ send } as never);
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({} as never);
send.mockResolvedValue("job-1");
activeJobs(0);
});
afterEach(() => {
vi.useRealTimers();
setActivePulseDayMeanMethod("hour_mean_v1");
});
describe("admin only", () => {
it("refuses anyone but an admin, before reading anything", async () => {
vi.mocked(getSession).mockResolvedValue(USER as never);
expect((await get()).status).toBe(403);
expect((await post({ action: "dry_run" })).status).toBe(403);
expect(prisma.appSettings.findUnique).not.toHaveBeenCalled();
expect(send).not.toHaveBeenCalled();
});
});
describe("GET", () => {
it("answers the method and the last dry run", async () => {
settings(doneAt("2026-10-15T08:00:00.000Z"));
const body = await (await get()).json();
expect(body.data).toEqual({
method: "hour_mean_v1",
appliedAt: null,
lastDryRun: doneAt("2026-10-15T08:00:00.000Z"),
});
});
it("reads a run marked running with no live job behind it as failed", async () => {
settings({
status: "running",
startedAt: "2026-10-16T11:00:00.000Z",
finishedAt: null,
result: null,
});
activeJobs(0);
const body = await (await get()).json();
expect(body.data.lastDryRun.status).toBe("failed");
});
it("drops a stored value of the wrong shape instead of passing it on", async () => {
settings({ status: "done", userId: "u-1" });
const body = await (await get()).json();
expect(body.data.lastDryRun).toBeNull();
});
});
describe("POST dry_run", () => {
it("marks the dry run queued and enqueues it", async () => {
settings(null);
const res = await post({ action: "dry_run" });
expect(res.status).toBe(202);
expect((await res.json()).data).toEqual({ enqueued: true });
expect(send).toHaveBeenCalledWith(
"pulse-day-mean",
{ action: "dry_run" },
expect.objectContaining({ retryLimit: 0, singletonKey: "pulse-day-mean" }),
);
expect(prisma.appSettings.upsert).toHaveBeenCalledWith(
expect.objectContaining({
update: {
pulseDayMeanDryRunJson: expect.objectContaining({ status: "queued" }),
},
}),
);
});
it("answers 409 busy while a run is active, and starts nothing", async () => {
activeJobs(1);
const res = await post({ action: "dry_run" });
expect(res.status).toBe(409);
expect((await res.json()).meta.errorCode).toBe(
"maintenance.pulseDayMean.busy",
);
expect(send).not.toHaveBeenCalled();
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
});
it("puts the last dry run back when the send loses the singleton", async () => {
const previous = doneAt("2026-10-15T08:00:00.000Z");
settings(previous);
send.mockResolvedValue(null);
const res = await post({ action: "dry_run" });
expect(res.status).toBe(409);
expect(vi.mocked(prisma.appSettings.upsert).mock.lastCall?.[0]).toEqual(
expect.objectContaining({ update: { pulseDayMeanDryRunJson: previous } }),
);
});
});
describe("POST apply", () => {
it("is refused without a dry run", async () => {
settings(null);
const res = await post({ action: "apply" });
expect(res.status).toBe(409);
expect((await res.json()).meta.errorCode).toBe(
"maintenance.pulseDayMean.dryRunRequired",
);
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
expect(send).not.toHaveBeenCalled();
});
it("is refused with a dry run older than seven days", async () => {
settings(doneAt("2026-10-09T11:59:00.000Z"));
expect((await post({ action: "apply" })).status).toBe(409);
});
it("is refused with a dry run that failed", async () => {
settings({ ...doneAt("2026-10-15T08:00:00.000Z"), status: "failed" });
expect((await post({ action: "apply" })).status).toBe(409);
});
it("is refused 409 busy while a run is active", async () => {
settings(doneAt("2026-10-15T08:00:00.000Z"));
activeJobs(1);
const res = await post({ action: "apply" });
expect((await res.json()).meta.errorCode).toBe(
"maintenance.pulseDayMean.busy",
);
});
it("switches the method, enqueues the rebuild and audits it", async () => {
settings(doneAt("2026-10-15T08:00:00.000Z"));
const res = await post({ action: "apply" });
expect(res.status).toBe(202);
expect((await res.json()).data).toEqual({ enqueued: true });
expect(prisma.appSettings.upsert).toHaveBeenCalledWith(
expect.objectContaining({
update: {
pulseDayMeanMethod: "weighted_hour_mean_v2",
pulseDayMeanAppliedAt: NOW,
},
}),
);
expect(send).toHaveBeenCalledWith(
"pulse-day-mean",
{ action: "rebuild" },
expect.anything(),
);
expect(activePulseDayMeanMethod()).toBe("weighted_hour_mean_v2");
expect(auditLog).toHaveBeenCalledWith(
"admin.pulseDayMean.apply",
expect.objectContaining({
userId: "admin-1",
details: { method: "weighted_hour_mean_v2", rebuildEnqueued: true },
}),
);
});
});
describe("POST body", () => {
it("is strict", async () => {
expect((await post({ action: "apply", force: true })).status).toBe(422);
expect((await post({ action: "rebuild" })).status).toBe(422);
expect(send).not.toHaveBeenCalled();
});
it("answers 503 without a background worker", async () => {
vi.mocked(getGlobalBoss).mockReturnValue(null as never);
expect((await post({ action: "dry_run" })).status).toBe(503);
});
});
@@ -26,7 +26,6 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
hasFreshDryRun,
isPulseDayMeanRunActive,
PULSE_DAY_MEAN_QUEUE,
PULSE_DAY_MEAN_SEND_OPTIONS,
@@ -39,7 +38,10 @@ import {
setActivePulseDayMeanMethod,
type PulseDayMeanMethod,
} from "@/lib/measurements/day-statistic";
import { pulseDayMeanActionSchema } from "@/lib/validations/pulse-day-mean";
import {
hasFreshDryRun,
pulseDayMeanActionSchema,
} from "@/lib/validations/pulse-day-mean";
export const dynamic = "force-dynamic";