test: drive the revoke race against Postgres and type the new fixtures

The revoke audit is now asserted in the integration race test where a
rotation commits while the turn-off waits, instead of a route test that
mocked the handler and the client. The other new tests type their
fixtures rather than casting them away.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 01:00:23 +01:00
1 parent 8aa8379861
commit 8b607ab501
6 files changed
+109 -252

No files matched your search

@@ -1,108 +0,0 @@
/**
* Turning the subscription off audits the feeds this request revoked, not
* the one it read first. A rotation that commits while the DELETE waits has
* already revoked that one and made a successor; the second pass revokes the
* successor, and the trail must name it.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const m = vi.hoisted(() => ({
requireGuardianAuth: vi.fn(),
findLiveFeed: vi.fn(),
updateMany: vi.fn(),
findMany: vi.fn(),
audit: vi.fn(async () => undefined),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireGuardianAuth: m.requireGuardianAuth,
assertRecentCookieProof: vi.fn(),
}));
vi.mock("@/lib/cycle/feed/status", () => ({
FEED_SUMMARY_SELECT: {},
cycleFeedStatus: vi.fn(),
findLiveFeed: m.findLiveFeed,
toFeedSummary: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
cycleCalendarFeed: { updateMany: m.updateMany, findMany: m.findMany },
},
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: m.audit }));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { DELETE } from "../route";
const OLD = {
id: "feed-old",
tokenPrefix: "hlf_old",
createdAt: new Date("2026-11-01T00:00:00Z"),
};
const SUCCESSOR = {
id: "feed-new",
tokenPrefix: "hlf_new",
createdAt: new Date("2026-11-10T00:00:00Z"),
};
beforeEach(() => {
vi.clearAllMocks();
m.requireGuardianAuth.mockResolvedValue({
authMethod: "cookie",
user: { id: "u1" },
});
m.findLiveFeed.mockResolvedValue(OLD);
});
describe("DELETE /api/cycle/feed-subscription", () => {
it("audits the successor a racing rotation made, not the feed read first", async () => {
// The rotation revoked OLD before the first pass, so the first pass
// revokes nothing and the second revokes the successor.
m.updateMany
.mockResolvedValueOnce({ count: 0 })
.mockResolvedValueOnce({ count: 1 });
m.findMany.mockResolvedValue([SUCCESSOR]);
const res = await (DELETE as unknown as () => Promise<Response>)();
expect(res.status).toBe(200);
const stamp = m.updateMany.mock.calls[0]![0].data.revokedAt as Date;
expect(m.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "u1", revokedAt: stamp },
}),
);
expect(m.audit).toHaveBeenCalledTimes(1);
expect(m.audit).toHaveBeenCalledWith(
"cycle.feed.revoke",
expect.objectContaining({
userId: "u1",
details: expect.objectContaining({ feedId: SUCCESSOR.id }),
}),
);
});
it("audits every feed the request revoked", async () => {
m.updateMany.mockResolvedValue({ count: 1 });
m.findMany.mockResolvedValue([OLD, SUCCESSOR]);
await (DELETE as unknown as () => Promise<Response>)();
const calls = m.audit.mock.calls as unknown as Array<
[string, { details: { feedId: string } }]
>;
expect(calls.map((c) => c[1].details.feedId)).toEqual([
OLD.id,
SUCCESSOR.id,
]);
});
it("writes no revoke row when another request already turned it off", async () => {
m.updateMany.mockResolvedValue({ count: 0 });
m.findMany.mockResolvedValue([]);
const res = await (DELETE as unknown as () => Promise<Response>)();
expect(res.status).toBe(200);
expect(m.audit).not.toHaveBeenCalled();
});
});
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { renderWithApp } from "../../../../tests/browser/render";
import { CustomMetricChart } from "../custom-metric-chart";
import type { CustomMetricEntryDto } from "../types";
/**
* The line takes the series colour it is given (the GKI draws in a chart
@@ -10,13 +11,14 @@ import { CustomMetricChart } from "../custom-metric-chart";
*/
const NOW = Date.now();
const ENTRIES = [3, 2, 1].map((daysAgo, i) => ({
const ENTRIES: CustomMetricEntryDto[] = [3, 2, 1].map((daysAgo, i) => ({
id: `e${i}`,
customMetricId: "",
value: 2 + i,
unit: "",
measuredAt: new Date(NOW - daysAgo * 86_400_000).toISOString(),
note: null,
createdAt: new Date(NOW).toISOString(),
}));
async function lineStroke(color?: string): Promise<string | null> {
@@ -28,7 +30,7 @@ async function lineStroke(color?: string): Promise<string | null> {
targetLow={null}
targetHigh={null}
decimals={1}
entries={ENTRIES as never}
entries={ENTRIES}
{...(color ? { color } : {})}
/>
</div>,
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { locales } from "@/lib/i18n/config";
import { locales, type Locale } from "@/lib/i18n/config";
/**
* The GKI tile sits in a two-column grid, about 150 px of text wide at phone
@@ -20,13 +20,13 @@ const POINT = {
glucoseMmol: 12.5,
};
async function render(locale: string) {
async function render(locale: Locale) {
const { I18nProvider } = await import("@/lib/i18n/context");
const { DayGkiTile } = await import("../day-gki-tile");
const messages = (await import(`../../../../messages/${locale}.json`))
.default;
return renderToStaticMarkup(
<I18nProvider initialLocale={locale as never} initialMessages={messages}>
<I18nProvider initialLocale={locale} initialMessages={messages}>
<DayGkiTile points={[POINT]} />
</I18nProvider>,
);
@@ -49,149 +49,95 @@ function at(tz: string, day: number, hour: number, minute = 0): Date {
return zonedWallClockToUtc({ year: 2026, month: 9, day, hour, minute }, tz);
}
/**
* Drive the tick every 15 minutes from day 14 00:00 to `untilDay` 00:00 in
* `tz`, with the twice-daily rule and an optional course end, and say when
* it sent. The row is the one the next tick reads.
*/
async function drive(tz: string, untilDay: number, endsOn: Date | null) {
const base = {
intervalDays: null,
rrule: "FREQ=DAILY;BYHOUR=7,19",
anchorDate: at(tz, 14, 0),
notifyHour: 7,
lastSatisfiedAt: null,
createdAt: at(tz, 13, 12),
endsOn,
};
const row: Record<string, unknown> = {
id: "r-bp",
userId: "u1",
origin: "COACH",
measurementType: null,
label: "BP",
location: null,
lastSkippedAt: null,
lastNotifiedAt: null,
enabled: true,
deletedAt: null,
...base,
nextDueAt: computeReminderNextDueAt(base, tz, at(tz, 14, 0)),
user: { id: "u1", timezone: tz, locale: "en" },
};
const prisma = {
measurementReminder: {
findMany: vi.fn(async () =>
(row.nextDueAt as Date | null) === null ? [] : [row],
),
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
Object.assign(row, data);
return row;
}),
updateMany: vi.fn(async (_args: { where: unknown }) => ({ count: 0 })),
},
measurement: { findFirst: vi.fn(async () => null) },
labResult: { findFirst: vi.fn(async () => null) },
};
const sent: string[] = [];
const dispatch = vi.fn(async () => ({
dispatched: true,
channelsAttempted: 1,
channelsSucceeded: 1,
}));
let last = at(tz, 14, 0);
for (
let t = at(tz, 14, 0).getTime();
t < at(tz, untilDay, 0).getTime();
t += 15 * 60_000
) {
const now = new Date(t);
last = now;
const before = dispatch.mock.calls.length;
await runMeasurementReminderTick(prisma as never, now, {
dispatch: dispatch as never,
isModuleEnabled: async () => true,
});
if (dispatch.mock.calls.length > before) {
const p = wallClockInTz(now, tz);
sent.push(
`${p.day} ${String(p.hour).padStart(2, "0")}:${String(p.minute).padStart(2, "0")}`,
);
}
}
return { sent, row, prisma, last };
}
describe.each(ZONES)("BYHOUR=7,19 in %s", (tz) => {
it("sends at 07:00 and at 19:00 on each day", async () => {
const base = {
intervalDays: null,
rrule: "FREQ=DAILY;BYHOUR=7,19",
anchorDate: at(tz, 14, 0),
notifyHour: 7,
lastSatisfiedAt: null,
createdAt: at(tz, 13, 12),
};
const row: Record<string, unknown> = {
id: "r-bp",
userId: "u1",
origin: "COACH",
measurementType: null,
label: "BP",
location: null,
lastSkippedAt: null,
lastNotifiedAt: null,
enabled: true,
endsOn: null,
...base,
nextDueAt: computeReminderNextDueAt(base, tz, at(tz, 14, 0)),
user: { id: "u1", timezone: tz, locale: "en" },
};
const prisma = {
measurementReminder: {
findMany: vi.fn(async () =>
(row.nextDueAt as Date | null) === null ? [] : [row],
),
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
Object.assign(row, data);
return row;
}),
updateMany: vi.fn(async () => ({ count: 0 })),
},
measurement: { findFirst: vi.fn(async () => null) },
labResult: { findFirst: vi.fn(async () => null) },
};
const sent: string[] = [];
const dispatch = vi.fn(async () => ({
dispatched: true,
channelsAttempted: 1,
channelsSucceeded: 1,
}));
for (
let t = at(tz, 14, 0).getTime();
t < at(tz, 16, 0).getTime();
t += 15 * 60_000
) {
const now = new Date(t);
const before = dispatch.mock.calls.length;
await runMeasurementReminderTick(prisma as never, now, {
dispatch: dispatch as never,
isModuleEnabled: async () => true,
});
if (dispatch.mock.calls.length > before) {
const p = wallClockInTz(now, tz);
sent.push(
`${p.day} ${String(p.hour).padStart(2, "0")}:${String(p.minute).padStart(2, "0")}`,
);
}
}
const { sent } = await drive(tz, 16, null);
expect(sent).toEqual(["14 07:00", "14 19:00", "15 07:00", "15 19:00"]);
});
it("stops after the last slot of a course window and leaves the row for the cleanup", async () => {
const endsOn = at(tz, 15, 12);
const base = {
intervalDays: null,
rrule: "FREQ=DAILY;BYHOUR=7,19",
anchorDate: at(tz, 14, 0),
notifyHour: 7,
lastSatisfiedAt: null,
createdAt: at(tz, 13, 12),
endsOn,
};
const row: Record<string, unknown> = {
id: "r-bp-course",
userId: "u1",
origin: "COACH",
measurementType: null,
label: "BP",
location: null,
lastSkippedAt: null,
lastNotifiedAt: null,
enabled: true,
deletedAt: null,
...base,
nextDueAt: computeReminderNextDueAt(base, tz, at(tz, 14, 0)),
user: { id: "u1", timezone: tz, locale: "en" },
};
const prisma = {
measurementReminder: {
findMany: vi.fn(async () =>
(row.nextDueAt as Date | null) === null ? [] : [row],
),
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
Object.assign(row, data);
return row;
}),
updateMany: vi.fn(async () => ({ count: 0 })),
},
measurement: { findFirst: vi.fn(async () => null) },
labResult: { findFirst: vi.fn(async () => null) },
};
const sent: string[] = [];
const dispatch = vi.fn(async () => ({
dispatched: true,
channelsAttempted: 1,
channelsSucceeded: 1,
}));
let last = at(tz, 14, 0);
for (
let t = at(tz, 14, 0).getTime();
t < at(tz, 18, 0).getTime();
t += 15 * 60_000
) {
const now = new Date(t);
last = now;
const before = dispatch.mock.calls.length;
await runMeasurementReminderTick(prisma as never, now, {
dispatch: dispatch as never,
isModuleEnabled: async () => true,
});
if (dispatch.mock.calls.length > before) {
const p = wallClockInTz(now, tz);
sent.push(
`${p.day} ${String(p.hour).padStart(2, "0")}:${String(p.minute).padStart(2, "0")}`,
);
}
}
const { sent, row, prisma, last } = await drive(tz, 18, at(tz, 15, 12));
expect(sent).toEqual(["14 07:00", "14 19:00", "15 07:00", "15 19:00"]);
// The course rolled off its end: no next slot, which is exactly the
// shape the expired-course cleanup tombstones on the following tick.
expect(row.nextDueAt).toBeNull();
expect((row.endsOn as Date).getTime()).toBeLessThan(last.getTime());
const cleanup = prisma.measurementReminder.updateMany.mock.calls.find(
(call: unknown[]) => JSON.stringify(call[0]).includes('"origin":"COACH"'),
(call) => JSON.stringify(call[0]).includes('"origin":"COACH"'),
);
expect(cleanup).toBeDefined();
});
@@ -13,6 +13,8 @@
*/
import { describe, expect, it, vi } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
import { satisfyReminder, skipReminder } from "../satisfy";
const TZ = "Europe/Berlin";
@@ -49,7 +51,9 @@ function makePrisma(updateManyCount = 1) {
}),
},
};
return { prisma, updates, events };
// The primitives touch only the two delegates above.
const client = prisma as unknown as PrismaClient;
return { prisma, client, updates, events };
}
function reminder(
@@ -270,9 +274,9 @@ describe("course window", () => {
const lastEvening = new Date("2026-06-15T17:30:00Z");
it("satisfy past the last slot leaves no next slot", async () => {
const { prisma, updates } = makePrisma();
const { client, updates } = makePrisma();
const result = await satisfyReminder(
prisma as never,
client,
reminder(course),
TZ,
lastEvening,
@@ -284,9 +288,9 @@ describe("course window", () => {
});
it("skip past the last slot leaves no next slot", async () => {
const { prisma, updates } = makePrisma();
const { client, updates } = makePrisma();
const result = await skipReminder(
prisma as never,
client,
reminder(course),
TZ,
lastEvening,
@@ -297,9 +301,9 @@ describe("course window", () => {
});
it("still rolls within the window", async () => {
const { prisma } = makePrisma();
const { client } = makePrisma();
const result = await satisfyReminder(
prisma as never,
client,
reminder(course),
TZ,
new Date("2026-06-15T05:30:00Z"),
+14 -1
View File
@@ -297,6 +297,7 @@ describe("turning the subscription on", () => {
it("keeps a subscription off when it is turned off while a rotation commits", async () => {
const owner = await makeUser("owner");
await mint(owner.id);
let successorId = "";
// The rotation holds the live row (revoked, successor written, not yet
// committed) while the turn-off waits on it.
const res = await rotateAround(
@@ -306,7 +307,7 @@ describe("turning the subscription on", () => {
where: { id: feedId },
data: { revokedAt: new Date() },
});
await tx.cycleCalendarFeed.create({
const successor = await tx.cycleCalendarFeed.create({
data: {
userId: owner.id,
createdById: owner.id,
@@ -314,6 +315,7 @@ describe("turning the subscription on", () => {
tokenPrefix: "hlf_successor",
},
});
successorId = successor.id;
},
() => manage("DELETE"),
);
@@ -323,6 +325,17 @@ describe("turning the subscription on", () => {
where: { userId: owner.id, revokedAt: null },
}),
).toBe(0);
// The rotation revoked the feed DELETE read first; the one this request
// turned off is the successor, and the trail says so.
const revokes = await db().auditLog.findMany({
where: { action: "cycle.feed.revoke", userId: owner.id },
select: { details: true },
});
expect(
revokes.map(
(r) => (JSON.parse(r.details ?? "{}") as { feedId?: string }).feedId,
),
).toEqual([successorId]);
});
it("carries an option changed during a rotation onto the new link", async () => {