mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
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"),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user