diff --git a/src/app/api/cycle/feed-subscription/__tests__/delete-audit.test.ts b/src/app/api/cycle/feed-subscription/__tests__/delete-audit.test.ts deleted file mode 100644 index b1149a259..000000000 --- a/src/app/api/cycle/feed-subscription/__tests__/delete-audit.test.ts +++ /dev/null @@ -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: 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)(); - 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)(); - 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)(); - expect(res.status).toBe(200); - expect(m.audit).not.toHaveBeenCalled(); - }); -}); diff --git a/src/components/custom-metrics/__tests__/custom-metric-chart-color.browser.test.tsx b/src/components/custom-metrics/__tests__/custom-metric-chart-color.browser.test.tsx index c112e39db..64828318a 100644 --- a/src/components/custom-metrics/__tests__/custom-metric-chart-color.browser.test.tsx +++ b/src/components/custom-metrics/__tests__/custom-metric-chart-color.browser.test.tsx @@ -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 { @@ -28,7 +30,7 @@ async function lineStroke(color?: string): Promise { targetLow={null} targetHigh={null} decimals={1} - entries={ENTRIES as never} + entries={ENTRIES} {...(color ? { color } : {})} /> , diff --git a/src/components/day/__tests__/day-gki-tile.test.tsx b/src/components/day/__tests__/day-gki-tile.test.tsx index d48cbe9b6..04dfa82f2 100644 --- a/src/components/day/__tests__/day-gki-tile.test.tsx +++ b/src/components/day/__tests__/day-gki-tile.test.tsx @@ -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( - + , ); diff --git a/src/lib/jobs/__tests__/measurement-reminder-byhour.test.ts b/src/lib/jobs/__tests__/measurement-reminder-byhour.test.ts index bb4dee432..6cf8a6b90 100644 --- a/src/lib/jobs/__tests__/measurement-reminder-byhour.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder-byhour.test.ts @@ -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 = { + 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 }) => { + 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 = { - 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 }) => { - 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 = { - 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 }) => { - 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(); }); diff --git a/src/lib/measurement-reminders/__tests__/satisfy.test.ts b/src/lib/measurement-reminders/__tests__/satisfy.test.ts index 323f77572..895bcd6e1 100644 --- a/src/lib/measurement-reminders/__tests__/satisfy.test.ts +++ b/src/lib/measurement-reminders/__tests__/satisfy.test.ts @@ -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"), diff --git a/tests/integration/cycle-calendar-feed.test.ts b/tests/integration/cycle-calendar-feed.test.ts index 130569510..51ac0ca9b 100644 --- a/tests/integration/cycle-calendar-feed.test.ts +++ b/tests/integration/cycle-calendar-feed.test.ts @@ -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 () => {