From 4e3b029c38ed1d04e2415c136a7d7509f816ad5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 01:10:44 +0200 Subject: [PATCH] test(integrations): pin the escalation ladder, leg-scoped clearing, and what the card says Mutation-checked against the production paths they cover: the escalation verdict, the leg guard in `recordSyncSuccess`, the streak anchor the verdict dates itself by, the status envelope threading it, and the pill's relative timestamp. Also give the four legacy per-provider status routes the verdict the consolidated envelope already carries. They answered `connected: true` plus a `state` string and nothing else, so a client reading one of them saw a live connection over a pipe that had delivered nothing for weeks. --- .../status/__tests__/route.test.ts | 38 ++++ src/app/api/integrations/status/route.ts | 42 ++-- src/app/api/nightscout/status/route.ts | 17 ++ src/app/api/oura/status/route.ts | 17 ++ src/app/api/polar/status/route.ts | 17 ++ src/app/api/strava/status/route.ts | 17 ++ .../integration-status-pill.test.tsx | 34 +++ src/lib/integrations/__tests__/status.test.ts | 209 ++++++++++++++++++ .../__tests__/sync-verdict.test.ts | 67 +++++- 9 files changed, 443 insertions(+), 15 deletions(-) diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index edba6a7c8..ec5d59e2e 100644 --- a/src/app/api/integrations/status/__tests__/route.test.ts +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -29,6 +29,7 @@ const ledger: Record = { lastSuccessAt: null, lastAttemptAt: null, lastError: null, + failingSince: null, }; vi.mock("@/lib/integrations/status", () => ({ getIntegrationStatus: vi.fn(async (_u: string, integration: string) => ({ @@ -239,3 +240,40 @@ describe("/api/integrations/status — sync-health verdict on every entry", () = expect(nightscout!.syncHealth).toBeDefined(); }); }); + +describe("/api/integrations/status — a provider that has delivered nothing for weeks", () => { + beforeEach(() => { + vi.clearAllMocks(); + for (const key of Object.keys(perIntegrationLedger)) { + delete perIntegrationLedger[key]; + } + userFind.mockResolvedValue({}); + whoopFind.mockResolvedValue(null); + polarAvailable.mockResolvedValue(true); + ouraAvailable.mockResolvedValue(false); + }); + + it("reports it as failing and dates the verdict from the streak, not the retry", async () => { + const failingSince = new Date( + Date.now() - 17 * 24 * 60 * 60 * 1000, + ).toISOString(); + perIntegrationLedger.nightscout = { + state: "error_transient", + // The hourly cron is still running and a sibling leg can still stamp a + // success, so neither of these tells the user anything. + lastSuccessAt: new Date(Date.now() - 20 * 60_000).toISOString(), + lastAttemptAt: new Date(Date.now() - 4 * 60_000).toISOString(), + failingSince, + lastError: "Nightscout sync HTTP 502", + }; + userFind.mockResolvedValue({ nightscoutUrlEncrypted: "enc" }); + + const nightscout = (await fetchEntries()).find( + (entry) => entry.integration === "nightscout", + )!; + expect(nightscout.syncHealth).toEqual({ + verdict: "failing", + since: failingSince, + }); + }); +}); diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index 9611e5c57..fc6b657fc 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -22,6 +22,7 @@ import { getIntegrationStatus, getPersistentFailureThreshold, type IntegrationKey, + type IntegrationStatusSnapshot, } from "@/lib/integrations/status"; import { getSourceMetricFreshness, @@ -155,6 +156,25 @@ export const GET = apiHandler(async () => { const now = Date.now(); const whoopConnected = !!whoopConn?.whoopUserId; + /** + * The ledger fields the envelope publishes, named one by one. + * + * `failingSince` is deliberately not among them. It is the input to + * `syncHealth.since`, which every consumer already reads, and putting both on + * the wire would be two names for one fact — the second of which nothing + * renders. + */ + function publish(snapshot: IntegrationStatusSnapshot) { + return { + integration: snapshot.integration, + state: snapshot.state, + lastSuccessAt: snapshot.lastSuccessAt, + lastAttemptAt: snapshot.lastAttemptAt, + lastError: snapshot.lastError, + consecutiveFailuresByKind: snapshot.consecutiveFailuresByKind, + }; + } + /** * The sync-health verdict + the per-metric staleness flags, resolved from * data already in hand — no additional query. Every entry carries it, @@ -227,7 +247,7 @@ export const GET = apiHandler(async () => { threshold: getPersistentFailureThreshold(), integrations: [ { - ...withingsStatus, + ...publish(withingsStatus), configured: withingsConfigured, connected: !!withingsConn, connectedAt: withingsConn?.createdAt?.toISOString() ?? null, @@ -251,7 +271,7 @@ export const GET = apiHandler(async () => { }), } satisfies IntegrationViewModel & WithingsExtras, { - ...whoopStatus, + ...publish(whoopStatus), configured: whoopConfigured, connected: whoopConnected, connectedAt: whoopConnected @@ -274,7 +294,7 @@ export const GET = apiHandler(async () => { }), } satisfies IntegrationViewModel & WhoopExtras, { - ...fitbitStatus, + ...publish(fitbitStatus), configured: fitbitConfigured, connected: fitbitConnected, connectedAt: fitbitConn?.createdAt?.toISOString() ?? null, @@ -291,7 +311,7 @@ export const GET = apiHandler(async () => { }), } satisfies IntegrationViewModel & FitbitExtras, { - ...googleHealthStatus, + ...publish(googleHealthStatus), configured: googleHealthConfigured, connected: googleHealthConnected, connectedAt: googleHealthConn?.createdAt?.toISOString() ?? null, @@ -314,7 +334,7 @@ export const GET = apiHandler(async () => { }), } satisfies IntegrationViewModel & GoogleHealthExtras, { - ...polarStatus, + ...publish(polarStatus), // `connected` = a stored access token; `configured` mirrors it (the // OAuth card has no separate "credentials saved but disconnected" view // beyond `hasOwnCredentials`). The card greys out the connect button @@ -331,7 +351,7 @@ export const GET = apiHandler(async () => { }), } satisfies IntegrationViewModel & OAuthProviderExtras, { - ...ouraStatus, + ...publish(ouraStatus), connected: ouraConnected, configured: ouraConnected, available: ouraAvailable, @@ -344,7 +364,7 @@ export const GET = apiHandler(async () => { }), } satisfies IntegrationViewModel & OAuthProviderExtras, { - ...stravaStatus, + ...publish(stravaStatus), connected: stravaConnected, configured: stravaConnected, available: stravaAvailable, @@ -359,7 +379,7 @@ export const GET = apiHandler(async () => { }), } satisfies IntegrationViewModel & OAuthProviderExtras, { - ...nightscoutStatus, + ...publish(nightscoutStatus), // Nightscout is a URL the self-hoster pastes once; `configured` (a // stored URL) is the connect marker, and a fully-public instance has // no token. The card reads these off the envelope now instead of its @@ -383,12 +403,6 @@ interface IntegrationViewModel { lastSuccessAt: string | null; lastAttemptAt: string | null; lastError: string | null; - /** - * Start of the current unbroken failure streak, or null when the provider is - * not failing. `syncHealth.since` is derived from it; the raw instant rides - * along so a client can render the age without re-deriving the verdict. - */ - failingSince: string | null; /** * The server-resolved liveness verdict. Present on EVERY entry, whether or * not a card renders it — the unit of coverage is the envelope entry, not diff --git a/src/app/api/nightscout/status/route.ts b/src/app/api/nightscout/status/route.ts index 38096cd59..f2cda6aa0 100644 --- a/src/app/api/nightscout/status/route.ts +++ b/src/app/api/nightscout/status/route.ts @@ -3,6 +3,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { getIntegrationStatus } from "@/lib/integrations/status"; +import { + INTEGRATION_CADENCE, + resolveSyncVerdict, +} from "@/lib/integrations/sync-verdict"; /** * Nightscout connection status for the current user (v1.17.0). @@ -45,5 +49,18 @@ export const GET = apiHandler(async () => { lastSuccessAt: status.lastSuccessAt, lastAttemptAt: status.lastAttemptAt, lastError: status.lastError, + // `connected: true` here means "a connection exists", not "it is working" + // — a pipe dead for a fortnight still answers true. The verdict is the + // liveness truth, resolved by the same rule the Settings envelope uses so + // this route cannot drift into a second opinion. + syncHealth: resolveSyncVerdict({ + connected: true, + configured: true, + state: status.state, + lastSuccessAt: status.lastSuccessAt, + lastAttemptAt: status.lastAttemptAt, + failingSinceAt: status.failingSince, + cadence: INTEGRATION_CADENCE["nightscout"], + }), }); }); diff --git a/src/app/api/oura/status/route.ts b/src/app/api/oura/status/route.ts index f3d4d17f5..ba3abf574 100644 --- a/src/app/api/oura/status/route.ts +++ b/src/app/api/oura/status/route.ts @@ -4,6 +4,10 @@ import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { getIntegrationStatus } from "@/lib/integrations/status"; +import { + INTEGRATION_CADENCE, + resolveSyncVerdict, +} from "@/lib/integrations/sync-verdict"; /** * v1.17.0 (F4) — Oura connection status for the current user. @@ -53,5 +57,18 @@ export const GET = apiHandler(async () => { lastSuccessAt: status.lastSuccessAt, lastAttemptAt: status.lastAttemptAt, lastError: status.lastError, + // `connected: true` here means "a connection exists", not "it is working" + // — a pipe dead for a fortnight still answers true. The verdict is the + // liveness truth, resolved by the same rule the Settings envelope uses so + // this route cannot drift into a second opinion. + syncHealth: resolveSyncVerdict({ + connected: true, + configured: true, + state: status.state, + lastSuccessAt: status.lastSuccessAt, + lastAttemptAt: status.lastAttemptAt, + failingSinceAt: status.failingSince, + cadence: INTEGRATION_CADENCE["oura"], + }), }); }); diff --git a/src/app/api/polar/status/route.ts b/src/app/api/polar/status/route.ts index 87d43742b..92cd26d79 100644 --- a/src/app/api/polar/status/route.ts +++ b/src/app/api/polar/status/route.ts @@ -4,6 +4,10 @@ import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { getPolarClientCredentials } from "@/lib/polar/credentials"; import { getIntegrationStatus } from "@/lib/integrations/status"; +import { + INTEGRATION_CADENCE, + resolveSyncVerdict, +} from "@/lib/integrations/sync-verdict"; /** * v1.17.0 (F4) — Polar connection status for the current user. @@ -54,5 +58,18 @@ export const GET = apiHandler(async () => { lastSuccessAt: status.lastSuccessAt, lastAttemptAt: status.lastAttemptAt, lastError: status.lastError, + // `connected: true` here means "a connection exists", not "it is working" + // — a pipe dead for a fortnight still answers true. The verdict is the + // liveness truth, resolved by the same rule the Settings envelope uses so + // this route cannot drift into a second opinion. + syncHealth: resolveSyncVerdict({ + connected: true, + configured: true, + state: status.state, + lastSuccessAt: status.lastSuccessAt, + lastAttemptAt: status.lastAttemptAt, + failingSinceAt: status.failingSince, + cadence: INTEGRATION_CADENCE["polar"], + }), }); }); diff --git a/src/app/api/strava/status/route.ts b/src/app/api/strava/status/route.ts index 64df49fe4..c80c14a71 100644 --- a/src/app/api/strava/status/route.ts +++ b/src/app/api/strava/status/route.ts @@ -4,6 +4,10 @@ import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { getStravaClientCredentials } from "@/lib/strava/credentials"; import { getIntegrationStatus } from "@/lib/integrations/status"; +import { + INTEGRATION_CADENCE, + resolveSyncVerdict, +} from "@/lib/integrations/sync-verdict"; /** * v1.28.x — Strava connection status for the current user. @@ -53,5 +57,18 @@ export const GET = apiHandler(async () => { lastSuccessAt: status.lastSuccessAt, lastAttemptAt: status.lastAttemptAt, lastError: status.lastError, + // `connected: true` here means "a connection exists", not "it is working" + // — a pipe dead for a fortnight still answers true. The verdict is the + // liveness truth, resolved by the same rule the Settings envelope uses so + // this route cannot drift into a second opinion. + syncHealth: resolveSyncVerdict({ + connected: true, + configured: true, + state: status.state, + lastSuccessAt: status.lastSuccessAt, + lastAttemptAt: status.lastAttemptAt, + failingSinceAt: status.failingSince, + cadence: INTEGRATION_CADENCE["strava"], + }), }); }); diff --git a/src/components/settings/__tests__/integration-status-pill.test.tsx b/src/components/settings/__tests__/integration-status-pill.test.tsx index 390bee332..466834869 100644 --- a/src/components/settings/__tests__/integration-status-pill.test.tsx +++ b/src/components/settings/__tests__/integration-status-pill.test.tsx @@ -196,3 +196,37 @@ describe("IntegrationStatusPill", () => { expect(html).toContain('data-testid="apple-health-status"'); }); }); + +describe("IntegrationStatusPill — a failing provider states its age", () => { + it("renders the failing state with how long it has been failing", () => { + const seventeenDaysAgo = new Date(Date.now() - 17 * 24 * 60 * 60 * 1000); + const html = render( + , + ); + // The label must not open with a claim of connection: the pipe has + // delivered nothing for a fortnight. + expect(html).not.toContain("Connected"); + expect(html).toContain("Sync failing"); + // And the age has to be on the pill. Without it, hour one and day + // seventeen render identically, which is how a dead sync stayed quiet. + expect(html).toMatch(/17\s*d\s+ago/); + expect(html).toContain('data-state="warning"'); + }); + + it("keeps the German label free of a connection claim too", () => { + const html = render( + , + "de", + ); + expect(html).not.toContain("Verbunden"); + expect(html).toContain("Sync fehlgeschlagen"); + }); +}); diff --git a/src/lib/integrations/__tests__/status.test.ts b/src/lib/integrations/__tests__/status.test.ts index e8a95ac1b..9be47a336 100644 --- a/src/lib/integrations/__tests__/status.test.ts +++ b/src/lib/integrations/__tests__/status.test.ts @@ -899,3 +899,212 @@ describe("sync-failure-recorded marker (§5)", () => { expect(isSyncFailureRecorded(frozen)).toBe(false); }); }); + +describe("transient-streak escalation", () => { + const DAY_MS = 24 * 60 * 60 * 1000; + + /** Seed the row the escalation reads: a streak of `transient` of a given age. */ + function seedStreak(opts: { transient: number; ageMs: number }) { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + consecutiveFailuresByKind: { + transient: opts.transient, + reauth_required: 0, + persistent: 0, + }, + persistentFailureStartedAt: null, + failingSinceAt: new Date(Date.now() - opts.ageMs), + alertedAt: null, + } as never); + vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ + alertedAt: new Date(), + } as never); + vi.mocked(prisma.user.findMany).mockResolvedValue([] as never); + } + + function upsertUpdate() { + return vi.mocked(prisma.integrationStatus.upsert).mock.calls[0]![0]! + .update as Record; + } + + it("records a transient failure as persistent once the streak outlives the window", async () => { + seedStreak({ transient: 3, ageMs: DAY_MS + 60_000 }); + + await recordSyncFailure({ + userId: "u1", + integration: "withings", + kind: "transient", + message: "Withings measure error: 503 - upstream", + }); + + // The transient bucket is frozen where the window closed; the persistent + // bucket — the only one the park ladder reads — starts counting. + expect(upsertUpdate().consecutiveFailuresByKind).toEqual({ + transient: 3, + reauth_required: 0, + persistent: 1, + }); + expect(upsertUpdate().persistentFailureStartedAt).toBeInstanceOf(Date); + expect(auditLog).toHaveBeenCalledWith( + "integrations.sync.failed", + expect.objectContaining({ + details: expect.objectContaining({ + kind: "persistent", + escalatedFrom: "transient", + }), + }), + ); + }); + + it("leaves a streak inside the window alone", async () => { + seedStreak({ transient: 9, ageMs: DAY_MS - 60_000 }); + + await recordSyncFailure({ + userId: "u1", + integration: "withings", + kind: "transient", + message: "Withings measure error: 503 - upstream", + }); + + expect(upsertUpdate().consecutiveFailuresByKind).toEqual({ + transient: 10, + reauth_required: 0, + persistent: 0, + }); + expect(upsertUpdate().persistentFailureStartedAt).toBeNull(); + expect(auditLog).toHaveBeenCalledWith( + "integrations.sync.failed", + expect.objectContaining({ + details: expect.objectContaining({ + kind: "transient", + escalatedFrom: null, + }), + }), + ); + }); + + it("needs the consecutive-failure threshold as well as the clock", async () => { + // A provider polled once a day: old streak, but only two attempts in it. + seedStreak({ transient: 1, ageMs: 5 * DAY_MS }); + + await recordSyncFailure({ + userId: "u1", + integration: "nightscout", + kind: "transient", + message: "Nightscout sync HTTP 502", + }); + + expect(upsertUpdate().consecutiveFailuresByKind).toEqual({ + transient: 2, + reauth_required: 0, + persistent: 0, + }); + expect(upsertUpdate().persistentFailureStartedAt).toBeNull(); + }); + + it("dates a fresh streak from this failure and never re-dates a running one", async () => { + const anchor = new Date(Date.now() - 3 * DAY_MS); + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + consecutiveFailuresByKind: { + transient: 1, + reauth_required: 0, + persistent: 0, + }, + persistentFailureStartedAt: null, + failingSinceAt: anchor, + alertedAt: new Date(), + } as never); + vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ + alertedAt: new Date(), + } as never); + + await recordSyncFailure({ + userId: "u1", + integration: "withings", + kind: "transient", + message: "Withings measure error: 503 - upstream", + }); + + expect(upsertUpdate().failingSinceAt).toEqual(anchor); + }); +}); + +describe("recordSyncSuccess — leg scoping", () => { + it("leaves a sibling leg's error standing and only records the attempt", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + state: "error_transient", + failingLeg: "sleep", + } as never); + vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce( + {} as never, + ); + + await recordSyncSuccess("u1", "withings", { leg: "ecg" }); + + expect(prisma.integrationStatus.upsert).not.toHaveBeenCalled(); + const args = vi.mocked(prisma.integrationStatus.update).mock.calls[0]![0]!; + // `lastSuccessAt` deliberately absent: it is what the card's + // "connected · X ago" reads, and the sleep pipe has delivered nothing. + expect(args.data).toEqual({ lastAttemptAt: expect.any(Date) }); + }); + + it("clears the row whole when the failing leg is the one that succeeded", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + state: "error_transient", + failingLeg: "sleep", + } as never); + vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( + {} as never, + ); + + await recordSyncSuccess("u1", "withings", { leg: "sleep" }); + + expect(prisma.integrationStatus.update).not.toHaveBeenCalled(); + const args = vi.mocked(prisma.integrationStatus.upsert).mock.calls[0]![0]!; + expect(args.update).toMatchObject({ + state: "connected", + lastError: null, + failingSinceAt: null, + failingLeg: null, + }); + }); + + it("clears the row whole when the failure was never attributed to a leg", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + state: "error_transient", + failingLeg: null, + } as never); + vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( + {} as never, + ); + + await recordSyncSuccess("u1", "oura", { leg: "vitals" }); + + expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce(); + }); + + it("clears the row whole when the caller names no leg (a full pass)", async () => { + vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( + {} as never, + ); + + await recordSyncSuccess("u1", "whoop"); + + // No probe read at all — an unnamed success is entitled to clear. + expect(prisma.integrationStatus.findUnique).not.toHaveBeenCalled(); + expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce(); + }); + + it("does not treat a healthy row's stale leg marker as a held error", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + state: "connected", + failingLeg: "sleep", + } as never); + vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( + {} as never, + ); + + await recordSyncSuccess("u1", "withings", { leg: "ecg" }); + + expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/integrations/__tests__/sync-verdict.test.ts b/src/lib/integrations/__tests__/sync-verdict.test.ts index c119ec25a..ef891110e 100644 --- a/src/lib/integrations/__tests__/sync-verdict.test.ts +++ b/src/lib/integrations/__tests__/sync-verdict.test.ts @@ -22,7 +22,8 @@ import { const NOW = new Date("2026-07-25T12:00:00.000Z"); const nowMs = NOW.getTime(); const ago = (ms: number) => new Date(nowMs - ms).toISOString(); -const HOUR = 60 * 60 * 1000; +const MINUTE = 60 * 1000; +const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; describe("resolveSyncVerdict — the ladder", () => { @@ -205,3 +206,67 @@ describe("resolveSyncVerdict — the ladder", () => { } }); }); + +describe("resolveSyncVerdict — the error verdicts date the streak", () => { + it("dates a failing provider from the streak start, not the last retry", () => { + // The shape that hid a fortnight of silence: the hourly retry keeps + // `lastAttemptAt` fresh, and a multi-leg provider can keep advancing + // `lastSuccessAt` from a healthy leg while another one is dead. + const failingSinceAt = ago(17 * DAY); + expect( + resolveSyncVerdict({ + connected: true, + state: "error_transient", + lastSuccessAt: ago(30 * MINUTE), + lastAttemptAt: ago(4 * MINUTE), + failingSinceAt, + cadence: POLLED_CADENCE, + now: NOW, + }), + ).toEqual({ verdict: "failing", since: failingSinceAt }); + }); + + it("falls back to the last success when the ledger has no streak anchor", () => { + // Rows written before the anchor existed, and never re-failed since. + const lastSuccessAt = ago(3 * DAY); + expect( + resolveSyncVerdict({ + connected: true, + state: "error_transient", + lastSuccessAt, + lastAttemptAt: ago(2 * HOUR), + failingSinceAt: null, + cadence: POLLED_CADENCE, + now: NOW, + }), + ).toEqual({ verdict: "failing", since: lastSuccessAt }); + }); + + it("dates a parked provider from the streak start", () => { + const failingSinceAt = ago(6 * DAY); + expect( + resolveSyncVerdict({ + connected: true, + state: "parked", + lastAttemptAt: ago(10 * MINUTE), + failingSinceAt, + cadence: POLLED_CADENCE, + now: NOW, + }), + ).toEqual({ verdict: "parked", since: failingSinceAt }); + }); + + it("dates a reauth-required provider from the streak start", () => { + const failingSinceAt = ago(9 * DAY); + expect( + resolveSyncVerdict({ + connected: true, + state: "error_reauth", + lastAttemptAt: ago(10 * MINUTE), + failingSinceAt, + cadence: POLLED_CADENCE, + now: NOW, + }), + ).toEqual({ verdict: "reauth_required", since: failingSinceAt }); + }); +});