From 10593a11a8fa1c0d3bb27b3cd4929a979113b78c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 09:41:53 +0200 Subject: [PATCH] test(integrations): cover the verdict ladder and the coverage guarantee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The load-bearing one is the panel-coverage test: render the panel with an envelope carrying every integration key and require each configured one to surface as a card or as a fallback row. Drop the fallback row and it goes red, which is the whole point — the previous shape of this bug was a provider on the wire that no screen rendered, and no card-shaped test could have caught it. Alongside it: the ladder table with the month-silent fixture that resolves to stalled, the 24-hour and 7-day boundaries asserted against literals rather than the constants they check, the per-metric stale flag firing only under a healthy provider, and a structural guard that the verdict-to-pill mapping is defined in exactly one module. The card suites migrate with the semantics: a transient failure now expects the warning pill everywhere, and the Apple Health suite trades its bespoke badge states for pill assertions. The e2e mocks gain the verdict shape, the nightscout entry, and an assertion that the card-less row is on the page. --- e2e/settings-integrations-mobile.spec.ts | 62 +++++- .../healthkit/__tests__/route.test.ts | 107 +++++++++ .../status/__tests__/route.test.ts | 105 ++++++++- .../integration-status-pill.test.tsx | 78 +++++++ .../__tests__/integrations-section.test.tsx | 45 ++-- .../__tests__/apple-health-card.test.tsx | 140 +++++++++--- .../connections-panel-coverage.test.tsx | 127 +++++++++++ .../__tests__/nightscout-card.test.tsx | 108 ++++++--- .../__tests__/oauth-provider-card.test.tsx | 193 +++++++++------- .../__tests__/pill-mapper.test.tsx | 82 +++++++ .../__tests__/metric-freshness.test.ts | 138 +++++++++++- .../__tests__/sync-verdict.test.ts | 207 ++++++++++++++++++ src/lib/mcp/__tests__/tools.test.ts | 36 +++ 13 files changed, 1245 insertions(+), 183 deletions(-) create mode 100644 src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx create mode 100644 src/components/settings/integrations/__tests__/pill-mapper.test.tsx create mode 100644 src/lib/integrations/__tests__/sync-verdict.test.ts diff --git a/e2e/settings-integrations-mobile.spec.ts b/e2e/settings-integrations-mobile.spec.ts index 22cf88593..926a78a66 100644 --- a/e2e/settings-integrations-mobile.spec.ts +++ b/e2e/settings-integrations-mobile.spec.ts @@ -53,6 +53,9 @@ test.describe("/settings/integrations Pixel-5 layout", () => { ); const recent = new Date(Date.now() - 12 * 60 * 1000).toISOString(); + const monthAgo = new Date( + Date.now() - 28 * 24 * 60 * 60 * 1000, + ).toISOString(); await page.route("**/api/integrations/status", (route) => route.fulfill({ @@ -81,6 +84,10 @@ test.describe("/settings/integrations Pixel-5 layout", () => { tokenExpired: false, scope: "user.info,user.metrics,user.activity", hasActivityScope: true, + syncHealth: { verdict: "fresh", since: null }, + metricFreshness: [ + { type: "WEIGHT", lastSeenAt: recent, stale: false }, + ], }, { integration: "moodlog", @@ -91,9 +98,13 @@ test.describe("/settings/integrations Pixel-5 layout", () => { consecutiveFailures: 0, configured: true, enabled: true, - legacyLastSyncedAt: recent, + legacyLastSyncedAt: monthAgo, webhookSecret: "ml_xxxxxxxxxxxxxxxxxxxxxxxxxxxx", entryCount: 42, + // Configured, card-less, and no longer attempting: the case the + // fallback row exists for. + syncHealth: { verdict: "stalled", since: monthAgo }, + metricFreshness: [], }, // v1.28.x — Strava OAuth card reads off this consolidated // envelope; without a `strava` entry the new card's query key @@ -108,6 +119,23 @@ test.describe("/settings/integrations Pixel-5 layout", () => { configured: false, available: false, hasOwnCredentials: false, + syncHealth: { verdict: "disconnected", since: null }, + metricFreshness: [], + }, + // Nightscout reads the envelope too since the card dropped its + // own status round-trip; without an entry its pill never resolves. + { + integration: "nightscout", + state: "disconnected", + lastSuccessAt: null, + lastAttemptAt: null, + lastError: null, + connected: false, + configured: false, + hasToken: false, + allowPrivateHost: false, + syncHealth: { verdict: "disconnected", since: null }, + metricFreshness: [], }, ], }, @@ -157,10 +185,27 @@ test.describe("/settings/integrations Pixel-5 layout", () => { // Wait for the pills to mount (queries resolve after first paint). // One pill per card: Withings, WHOOP, Fitbit, Google Health, Polar, - // Oura, Strava, Nightscout. (The Garmin info note is not an OAuth card and - // carries no pill.) + // Oura, Strava, Nightscout — plus the fallback row for the configured, + // card-less moodLog entry. (The Garmin info note is not an OAuth card and + // carries no pill; the Apple Health card's pill keeps its own testid.) const pills = page.locator('[data-testid="integration-status-pill"]'); - await expect(pills).toHaveCount(8, { timeout: 10_000 }); + await expect(pills).toHaveCount(9, { timeout: 10_000 }); + + // The acceptance case: a configured integration with no card of its own, + // which has stopped even attempting, is visible on the page. + const fallback = page.locator( + '[data-testid="integration-fallback-row"][data-integration="moodlog"]', + ); + await expect(fallback).toHaveCount(1); + await expect( + fallback.locator('[data-testid="integration-status-pill"]'), + ).toHaveAttribute("data-state", "stalled"); + + // Card anatomy is uniform now that Nightscout, the OAuth cards and Apple + // Health all carry the divider. + await expect( + page.locator('[data-testid="integration-card-divider"]'), + ).toHaveCount(9); // Withings is connected in this fixture; WHOOP is a BYO-keys card that // stays dormant until an operator adds credentials, so assert the one @@ -210,7 +255,12 @@ test.describe("Apple Health guidance", () => { status: 200, contentType: "application/json", body: JSON.stringify({ - data: { entries: [], lastSyncedAt: null }, + data: { + entries: [], + lastSyncedAt: null, + syncHealth: { verdict: "pending_first_sync", since: null }, + metricFreshness: [], + }, error: null, }), }), @@ -224,7 +274,7 @@ test.describe("Apple Health guidance", () => { await expect(card).toContainText("not a web or OAuth connection"); await expect(page.getByTestId("apple-health-status")).toHaveAttribute( "data-state", - "setup", + "pending-setup", ); const importLink = page.getByTestId("apple-health-import-link"); diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index 53d411dab..977316ea1 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -6,6 +6,9 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn(), update: vi.fn() }, // v1.4.43 W6 — audit-ledger breadcrumb for validation-failed paths. auditLog: { create: vi.fn() }, + // Per-metric freshness for the APPLE_HEALTH source. + measurement: { groupBy: vi.fn() }, + workout: { groupBy: vi.fn() }, }, toJson: (v: T) => v, })); @@ -34,6 +37,7 @@ vi.mock("next/headers", () => ({ import { GET, PATCH } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { APPLE_HEALTH_DATA_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -48,6 +52,8 @@ beforeEach(() => { } as never); vi.mocked(prisma.user.update).mockResolvedValue({} as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); + vi.mocked(prisma.workout.groupBy).mockResolvedValue([] as never); }); const callGet = GET as unknown as (req: NextRequest) => Promise; @@ -199,3 +205,104 @@ describe("PATCH /api/integrations/healthkit — 422 multi-issue (v1.4.43 W6)", ( expect(res.status).toBe(422); }); }); + +/** + * Apple Health is push-based and has no ledger row, so `lastSyncedAt` was the + * only signal the card had — and its mere presence painted green, whether the + * data arrived this morning or three weeks ago. These pin the seven-day + * threshold and the per-metric read that makes a silently revoked HealthKit + * permission visible inside an otherwise healthy connection. + */ +describe("GET /api/integrations/healthkit — sync health", () => { + const DAY = 24 * 60 * 60 * 1000; + const ago = (ms: number) => new Date(Date.now() - ms); + + type Body = { + lastSyncedAt: string | null; + syncHealth: { verdict: string; since: string | null }; + metricFreshness: Array<{ + type: string; + lastSeenAt: string; + stale: boolean; + }>; + }; + + async function read(): Promise { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeGetReq()); + expect(res.status).toBe(200); + return ((await res.json()) as { data: Body }).data; + } + + it("calls a recent delivery fresh", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(2 * DAY), + } as never); + expect((await read()).syncHealth.verdict).toBe("fresh"); + }); + + it("calls a pipe silent for over a week stale", async () => { + const lastSyncedAt = ago(APPLE_HEALTH_DATA_STALE_AFTER_MS + DAY); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: lastSyncedAt, + } as never); + const body = await read(); + expect(body.syncHealth.verdict).toBe("stale"); + expect(body.syncHealth.since).toBe(lastSyncedAt.toISOString()); + }); + + it("calls an account that never delivered pending, not disconnected", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: null, + } as never); + expect((await read()).syncHealth.verdict).toBe("pending_first_sync"); + }); + + it("flags one quiet metric inside an otherwise healthy connection", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(60 * 60 * 1000), + } as never); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ + { + source: "APPLE_HEALTH", + type: "RESPIRATORY_RATE", + _max: { measuredAt: ago(30 * DAY) }, + }, + { + source: "APPLE_HEALTH", + type: "PULSE", + _max: { measuredAt: ago(60 * 60 * 1000) }, + }, + ] as never); + + const body = await read(); + // Only the Apple Health source is read. + const groupByArgs = vi.mocked(prisma.measurement.groupBy).mock + .calls[0]?.[0] as unknown as { + where: { source: { in: string[] } }; + }; + expect(groupByArgs.where.source.in).toEqual(["APPLE_HEALTH"]); + const byType = Object.fromEntries( + body.metricFreshness.map((entry) => [entry.type, entry.stale]), + ); + expect(byType.RESPIRATORY_RATE).toBe(true); + expect(byType.PULSE).toBe(false); + }); + + it("degrades to no per-metric data rather than failing the config read", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(DAY), + } as never); + vi.mocked(prisma.measurement.groupBy).mockRejectedValue( + new Error("groupBy hiccup"), + ); + const body = await read(); + expect(body.metricFreshness).toEqual([]); + expect(body.syncHealth.verdict).toBe("fresh"); + }); +}); diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index fbfec1368..468a2adb4 100644 --- a/src/app/api/integrations/status/__tests__/route.test.ts +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -13,6 +13,8 @@ vi.mock("@/lib/db", () => ({ fitbitConnection: { findUnique: vi.fn(async () => null) }, googleHealthConnection: { findUnique: vi.fn(async () => null) }, moodEntry: { count: vi.fn(async () => 0) }, + measurement: { groupBy: vi.fn(async () => []) }, + workout: { groupBy: vi.fn(async () => []) }, }, })); @@ -31,10 +33,11 @@ const ledger: Record = { vi.mock("@/lib/integrations/status", () => ({ getIntegrationStatus: vi.fn(async (_u: string, integration: string) => ({ integration, - ...ledger, + ...(perIntegrationLedger[integration] ?? ledger), })), getPersistentFailureThreshold: () => 5, })); +const perIntegrationLedger: Record> = {}; vi.mock("@/lib/withings/client", () => ({ hasActivityScope: () => false })); vi.mock("@/lib/moodlog-secret", () => ({ readMoodLogSecret: () => null })); @@ -50,6 +53,7 @@ vi.mock("@/lib/oura/credentials", () => ({ import { GET } from "../route"; import { prisma } from "@/lib/db"; +import { ATTEMPT_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict"; const userFind = prisma.user.findUnique as ReturnType; const whoopFind = prisma.whoopConnection.findUnique as ReturnType; @@ -60,6 +64,10 @@ type Entry = { configured?: boolean; available?: boolean; hasOwnCredentials?: boolean; + hasToken?: boolean; + allowPrivateHost?: boolean; + syncHealth?: { verdict: string; since: string | null }; + metricFreshness?: Array<{ type: string; lastSeenAt: string; stale: boolean }>; }; async function fetchEntries(): Promise { @@ -71,6 +79,9 @@ async function fetchEntries(): Promise { describe("/api/integrations/status — Polar/Oura fold (04-M2)", () => { beforeEach(() => { vi.clearAllMocks(); + for (const key of Object.keys(perIntegrationLedger)) { + delete perIntegrationLedger[key]; + } polarAvailable.mockResolvedValue(true); ouraAvailable.mockResolvedValue(false); }); @@ -139,3 +150,95 @@ describe("/api/integrations/status — WHOOP identity ownership", () => { expect(whoop?.configured).toBe(true); }); }); + +/** + * The envelope is the unit of coverage for liveness. Every entry carries the + * verdict, whether or not a card renders it — a card-level verdict misses a + * card-less provider by construction, which is exactly how a month-dead sync + * stayed invisible. + */ +describe("/api/integrations/status — sync-health verdict on every entry", () => { + beforeEach(() => { + vi.clearAllMocks(); + for (const key of Object.keys(perIntegrationLedger)) { + delete perIntegrationLedger[key]; + } + polarAvailable.mockResolvedValue(true); + ouraAvailable.mockResolvedValue(true); + }); + + it("carries a syncHealth verdict on every entry", async () => { + userFind.mockResolvedValue({}); + const entries = await fetchEntries(); + expect(entries.length).toBeGreaterThan(0); + for (const entry of entries) { + expect(entry.syncHealth, entry.integration).toBeDefined(); + expect(typeof entry.syncHealth!.verdict).toBe("string"); + expect(Array.isArray(entry.metricFreshness)).toBe(true); + } + }); + + it("reports a configured, month-silent card-less provider as stalled", async () => { + // The live incident, on the wire: moodLog has no Settings card, so this + // entry is the only thing that can tell anyone the sync has stopped. + const lastAttemptAt = new Date( + Date.now() - 28 * 24 * 60 * 60 * 1000, + ).toISOString(); + perIntegrationLedger.moodlog = { + state: "error_transient", + lastSuccessAt: new Date( + Date.now() - 55 * 24 * 60 * 60 * 1000, + ).toISOString(), + lastAttemptAt, + lastError: null, + }; + userFind.mockResolvedValue({ + moodLogUrlEncrypted: "enc", + moodLogEnabled: true, + }); + + const moodlog = (await fetchEntries()).find( + (entry) => entry.integration === "moodlog", + )!; + expect(moodlog.configured).toBe(true); + expect(moodlog.syncHealth).toEqual({ + verdict: "stalled", + since: lastAttemptAt, + }); + }); + + it("keeps an integration that attempted within the window off the stalled arm", async () => { + perIntegrationLedger.moodlog = { + state: "error_transient", + lastSuccessAt: null, + lastAttemptAt: new Date( + Date.now() - (ATTEMPT_STALE_AFTER_MS - 60_000), + ).toISOString(), + lastError: null, + }; + userFind.mockResolvedValue({ moodLogUrlEncrypted: "enc" }); + + const moodlog = (await fetchEntries()).find( + (entry) => entry.integration === "moodlog", + )!; + expect(moodlog.syncHealth!.verdict).toBe("failing"); + }); + + it("includes a nightscout entry with the fields the card's form reads", async () => { + userFind.mockResolvedValue({ + nightscoutUrlEncrypted: "enc", + nightscoutTokenEncrypted: "enc", + nightscoutAllowPrivateHost: true, + }); + + const nightscout = (await fetchEntries()).find( + (entry) => entry.integration === "nightscout", + ); + expect(nightscout).toBeDefined(); + expect(nightscout!.connected).toBe(true); + expect(nightscout!.configured).toBe(true); + expect(nightscout!.hasToken).toBe(true); + expect(nightscout!.allowPrivateHost).toBe(true); + expect(nightscout!.syncHealth).toBeDefined(); + }); +}); diff --git a/src/components/settings/__tests__/integration-status-pill.test.tsx b/src/components/settings/__tests__/integration-status-pill.test.tsx index 63349ec19..390bee332 100644 --- a/src/components/settings/__tests__/integration-status-pill.test.tsx +++ b/src/components/settings/__tests__/integration-status-pill.test.tsx @@ -117,4 +117,82 @@ describe("IntegrationStatusPill", () => { expect(html).toContain("Pausiert"); expect(html).toContain("manuell wieder verbinden"); }); + + // The three states the server verdict added. `stale` and `stalled` both + // carry an inline timestamp, because "since when" is the whole point of + // them; `pending-setup` has nothing to timestamp yet. + it("renders the stale state in the warning tone with its timestamp", () => { + const html = render( + , + ); + expect(html).toContain('data-state="stale"'); + expect(html).toContain("no recent data"); + expect(html).toMatch(/9\s+d\s+ago/); + expect(html).toContain("bg-warning/15"); + }); + + it("renders the stalled state as a stopped sync, not an error", () => { + const html = render( + , + ); + expect(html).toContain('data-state="stalled"'); + expect(html).toContain("Sync stopped"); + expect(html).toMatch(/28\s+d\s+ago/); + // Warning tone, never destructive: the user cannot fix a cron that + // stopped by clicking reconnect. + expect(html).toContain("bg-warning/15"); + expect(html).not.toContain('data-variant="destructive"'); + }); + + it("renders the pending-setup state with no timestamp", () => { + const html = render( + , + ); + expect(html).toContain('data-state="pending-setup"'); + expect(html).toContain("Waiting for first data"); + expect(html).not.toContain("bg-warning/15"); + }); + + /** + * Red is reserved for "your action fixes this". A transient upstream failure + * is not that: telling the user to reconnect against a 503 sends them + * clicking at something they cannot repair. + */ + it("keeps the destructive variant for reauth alone", () => { + expect( + render(), + ).toContain('data-variant="destructive"'); + for (const state of [ + "warning", + "stale", + "stalled", + "parked", + "pending-setup", + "connected", + ] as const) { + expect( + render(), + state, + ).not.toContain('data-variant="destructive"'); + } + }); + + it("lets a card keep its own established testid", () => { + const html = render( + , + ); + expect(html).toContain('data-testid="apple-health-status"'); + }); }); diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx index 35c806dc6..cdea7f1bd 100644 --- a/src/components/settings/__tests__/integrations-section.test.tsx +++ b/src/components/settings/__tests__/integrations-section.test.tsx @@ -114,27 +114,27 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { configured: true, legacyLastSyncedAt: "2026-05-09T18:00:00.000Z", hasActivityScope: true, + syncHealth: { verdict: "fresh", since: null }, }, { + // Not configured here, so this entry claims no surface. The + // configured, card-less case has its own coverage test — this one is + // about one status display per CARD. integration: "moodlog", - state: "connected", - lastSuccessAt: "2026-05-09T17:00:00.000Z", - lastAttemptAt: "2026-05-09T17:00:00.000Z", + state: "disconnected", + lastSuccessAt: null, + lastAttemptAt: null, lastError: null, - configured: true, - enabled: true, - legacyLastSyncedAt: "2026-05-09T17:00:00.000Z", - entryCount: 42, - webhookSecret: "secret123", + configured: false, + syncHealth: { verdict: "disconnected", since: null }, }, ], }); const html = render(); - // Exactly one pill per card → 8 pills total (Withings, WHOOP, Fitbit, - // Google Health, Polar, Oura, Strava, Nightscout). The moodLog integration - // was removed; Polar + Oura (F4) were added in v1.17.0; Google Health in - // v1.27.0; Strava in v1.28.x. (The Garmin info note carries no pill.) + // Exactly one pill per card → 8 (Withings, WHOOP, Fitbit, Google Health, + // Polar, Oura, Strava, Nightscout). The Garmin info note carries no pill; + // the Apple Health card's pill keeps its own testid. expect(count(html, 'data-testid="integration-status-pill"')).toBe(8); // The redundant banner from v1.4.15 is gone. expect(html).not.toContain('data-testid="integration-status-banner"'); @@ -160,6 +160,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { configured: true, legacyLastSyncedAt: "2026-05-09T08:00:00.000Z", hasActivityScope: true, + syncHealth: { verdict: "failing", since: "2026-05-09T08:00:00.000Z" }, }, { integration: "moodlog", @@ -172,9 +173,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { }); const html = render(); - // Pill carries the "Error — reconnect" copy via the pill's - // `data-state="error"` marker. - expect(html).toContain('data-state="error"'); + // A 503 is not the user's to fix, so the pill carries the warning tone — + // red is reserved for "reconnect actually helps". + expect(html).toContain('data-state="warning"'); + expect(html).not.toContain('data-state="error"'); // Actionable error message still surfaces (we kept it because // the pill can't fit "Withings refresh error: 503 - upstream"). expect(html).toContain("Withings refresh error: 503 - upstream"); @@ -200,6 +202,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { legacyLastSyncedAt: "2026-05-08T12:00:00.000Z", tokenExpired: true, hasActivityScope: true, + syncHealth: { + verdict: "reauth_required", + since: "2026-05-09T18:00:00.000Z", + }, }, { integration: "moodlog", @@ -228,6 +234,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: "2026-05-08T10:00:00.000Z", lastAttemptAt: "2026-05-08T10:00:00.000Z", lastError: null, + syncHealth: { verdict: "disconnected", since: null }, }, ], }); @@ -258,6 +265,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { configured: true, legacyLastSyncedAt: "2026-05-08T12:00:00.000Z", hasActivityScope: true, + syncHealth: { verdict: "parked", since: "2026-05-09T18:00:00.000Z" }, }, { integration: "moodlog", @@ -294,15 +302,16 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { configured: true, legacyLastSyncedAt: "2026-05-09T18:00:00.000Z", hasActivityScope: true, + syncHealth: { verdict: "fresh", since: null }, }, ], }); const html = render(); // Every integration card includes the section divider data-testid so the - // header → body separation is visually consistent (Withings, WHOOP, - // Fitbit, Google Health). The moodLog integration was removed. - expect(count(html, 'data-testid="integration-card-divider"')).toBe(4); + // header → body separation is visually consistent: the four legacy cards, + // Apple Health, the three OAuth cards, and Nightscout. + expect(count(html, 'data-testid="integration-card-divider"')).toBe(9); }); // v1.17.1 — every integration card carries the same discreet "Setup guide" diff --git a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx index 1ad135a3e..db8911958 100644 --- a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx +++ b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx @@ -10,7 +10,17 @@ import fr from "../../../../../messages/fr.json"; import itMessages from "../../../../../messages/it.json"; import pl from "../../../../../messages/pl.json"; -let statusPayload: { lastSyncedAt: string | null } | undefined; +type Payload = { + lastSyncedAt: string | null; + syncHealth?: { verdict: string; since: string | null }; + metricFreshness?: Array<{ + type: string; + lastSeenAt: string; + stale: boolean; + }>; +}; + +let statusPayload: Payload | undefined; let statusLoading = false; let statusError = false; @@ -40,8 +50,10 @@ describe("", () => { }); it("explains that live sync uses the iOS app rather than OAuth", () => { - statusPayload = { lastSyncedAt: null }; - statusLoading = false; + statusPayload = { + lastSyncedAt: null, + syncHealth: { verdict: "pending_first_sync", since: null }, + }; const html = render(); @@ -53,35 +65,98 @@ describe("", () => { expect(html).not.toContain(">Connected<"); }); - it("keeps the status neutral when no Apple Health data has been observed", () => { - statusPayload = { lastSyncedAt: null }; - statusLoading = false; + /** + * The defect this card carried: any `lastSyncedAt` at all painted the green + * "data received" chip, so a pipe dead for three weeks looked exactly like + * one that delivered this morning. The verdict now decides. + */ + it("shows a week-old delivery as stale, not as data received", () => { + const eightDaysAgo = new Date( + Date.now() - 8 * 24 * 60 * 60 * 1000, + ).toISOString(); + statusPayload = { + lastSyncedAt: eightDaysAgo, + syncHealth: { verdict: "stale", since: eightDaysAgo }, + }; const html = render(); expect(html).toContain('data-testid="apple-health-status"'); - expect(html).toContain('data-state="setup"'); - expect(html).toContain("Set up in iOS app"); + expect(html).toContain('data-state="stale"'); + expect(html).toContain("no recent data"); + // And it says what to do about it. + expect(html).toContain('data-testid="apple-health-stale"'); expect(html).not.toContain('data-state="connected"'); }); - it("reports only observed data freshness without claiming a connection", () => { - statusPayload = { lastSyncedAt: "2026-07-20T10:30:00.000Z" }; - statusLoading = false; + it("shows a recent delivery as connected with its relative time", () => { + const anHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + statusPayload = { + lastSyncedAt: anHourAgo, + syncHealth: { verdict: "fresh", since: null }, + }; const html = render(); - expect(html).toContain('data-state="recent-data"'); - expect(html).toContain("Apple Health data received"); - expect(html).toContain("Last Apple Health data:"); - expect(html).toContain('dateTime="2026-07-20T10:30:00.000Z"'); - expect(html).not.toContain(">Connected<"); + expect(html).toContain('data-state="connected"'); + expect(html).toMatch(/1\s+h\s+ago/); + expect(html).not.toContain('data-testid="apple-health-stale"'); + }); + + it("waits for the first delivery rather than claiming a connection", () => { + statusPayload = { + lastSyncedAt: null, + syncHealth: { verdict: "pending_first_sync", since: null }, + }; + + const html = render(); + + expect(html).toContain('data-state="pending-setup"'); + expect(html).toContain("Waiting for first data"); expect(html).not.toContain('data-state="connected"'); }); + it("renders per-metric freshness so one dead permission is visible", () => { + statusPayload = { + lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + syncHealth: { verdict: "fresh", since: null }, + metricFreshness: [ + { + type: "RESPIRATORY_RATE", + lastSeenAt: new Date( + Date.now() - 30 * 24 * 60 * 60 * 1000, + ).toISOString(), + stale: true, + }, + { + type: "PULSE", + lastSeenAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + stale: false, + }, + ], + }; + + const html = render(); + + expect(html).toContain('data-slot="metric-freshness"'); + // The collapsed summary already names the problem — the signal must not + // hide behind a click. + expect(html).toContain("quiet"); + }); + + it("carries the shared card divider like every sibling", () => { + statusPayload = { + lastSyncedAt: null, + syncHealth: { verdict: "pending_first_sync", since: null }, + }; + expect(render()).toContain('data-testid="integration-card-divider"'); + }); + it("links to the existing one-shot Apple Health import fallback", () => { - statusPayload = { lastSyncedAt: null }; - statusLoading = false; + statusPayload = { + lastSyncedAt: null, + syncHealth: { verdict: "pending_first_sync", since: null }, + }; const html = render(); @@ -92,24 +167,24 @@ describe("", () => { expect(html).toContain("Open one-shot import"); }); - it("uses a non-committal checking state while status is loading", () => { + it("claims no status at all while the read is in flight", () => { statusPayload = undefined; statusLoading = true; const html = render(); - expect(html).toContain('data-state="checking"'); - expect(html).toContain("Checking Apple Health data…"); + expect(html).not.toContain('data-testid="apple-health-status"'); expect(html).not.toContain("Connected"); }); - it("does not turn a failed status read into a setup or connection claim", () => { + + it("treats a failed status read as an error line, not a status", () => { statusError = true; const html = render(); - expect(html).toContain('data-state="unavailable"'); expect(html).toContain("Apple Health status unavailable"); - expect(html).not.toContain('data-state="setup"'); + expect(html).toContain('data-testid="integration-error-message"'); + expect(html).not.toContain('data-testid="apple-health-status"'); expect(html).not.toContain("Connected"); }); @@ -117,12 +192,12 @@ describe("", () => { const requiredKeys = [ "title", "description", - "lastDataLabel", "setupTitle", "permissionStep", "backgroundStep", "importNote", "importAction", + "staleNote", ] as const; for (const catalog of [en, de, es, fr, itMessages, pl]) { @@ -131,20 +206,19 @@ describe("", () => { expect(appleHealth[key]).toBeTypeOf("string"); expect(appleHealth[key].length).toBeGreaterThan(0); } - expect(Object.keys(appleHealth.status).sort()).toEqual([ - "checking", - "dataReceived", - "setup", - "unavailable", - ]); + // The status copy the pill now owns is gone from the catalog; only the + // failed-read line survives here. + expect(Object.keys(appleHealth.status).sort()).toEqual(["unavailable"]); } }); it.each(["en", "de", "es", "fr", "it", "pl"] as const)( "renders localized Apple Health copy for %s without leaking keys", (locale) => { - statusPayload = { lastSyncedAt: null }; - statusLoading = false; + statusPayload = { + lastSyncedAt: null, + syncHealth: { verdict: "pending_first_sync", since: null }, + }; const html = render(locale); diff --git a/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx b/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx new file mode 100644 index 000000000..73f1c33f7 --- /dev/null +++ b/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx @@ -0,0 +1,127 @@ +/** + * Nothing on the envelope may render nowhere. + * + * A provider whose Settings card was removed kept its cron, its ledger row and + * its block on the status envelope, and lost the only surface that could have + * shown it dying. It then stopped syncing for a month and no screen anywhere + * said so — every audit that went looking worked from the cards, so a provider + * without a card appeared in no cohort at all. + * + * The panel's unit of coverage is therefore the envelope entry, not the card. + * This is the acceptance test in executable form: render the panel with an + * envelope carrying every integration key, and require that each configured + * one surfaces either as a bespoke card or as a fallback row. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/integrations", +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", role: "USER" }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +let envelope: unknown = null; +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ queryKey }: { queryKey: unknown[] }) => { + const key = Array.isArray(queryKey) ? queryKey.join("/") : ""; + if (key === "integrations/status") { + return { data: envelope, isLoading: false }; + } + return { data: null, isLoading: false, isError: false, refetch: vi.fn() }; + }, + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { ConnectionsPanel } from "../connections-panel"; +import { INTEGRATION_DISPLAY_NAMES } from "../integration-fallback-row"; + +const MONTH_AGO = new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString(); + +/** Every key the server can ship, all configured, all a month silent. */ +function fullEnvelope() { + return { + threshold: 3, + integrations: Object.keys(INTEGRATION_DISPLAY_NAMES).map((integration) => ({ + integration, + state: "error_transient", + lastSuccessAt: MONTH_AGO, + lastAttemptAt: MONTH_AGO, + lastError: null, + configured: true, + connected: true, + available: true, + syncHealth: { verdict: "stalled", since: MONTH_AGO }, + metricFreshness: [], + })), + }; +} + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("ConnectionsPanel — every envelope entry surfaces", () => { + it("renders a card or a fallback row for every configured entry", () => { + envelope = fullEnvelope(); + const html = render(); + + for (const integration of Object.keys(INTEGRATION_DISPLAY_NAMES)) { + const hasCard = html.includes(`data-testid="${integration}-card"`); + const hasFallback = html.includes(`data-integration="${integration}"`); + expect( + hasCard || hasFallback, + `${integration} renders nowhere on the connections panel`, + ).toBe(true); + } + }); + + it("gives the card-less provider a fallback row with the stalled pill", () => { + // moodLog is the live case: no card since its removal, still on the wire, + // still running an hourly cron against an upstream that stopped answering. + envelope = fullEnvelope(); + const html = render(); + + expect(html).toContain('data-testid="integration-fallback-row"'); + expect(html).toContain('data-integration="moodlog"'); + expect(html).toContain("moodLog"); + expect(html).toContain('data-state="stalled"'); + expect(html).toContain("Sync stopped"); + expect(html).toContain("Last attempt"); + }); + + it("renders no fallback row for a provider that is not configured", () => { + // Absence stays absence: an unconfigured provider is not a problem to + // report, so the panel says nothing about it. + envelope = { + threshold: 3, + integrations: [ + { + integration: "moodlog", + state: "disconnected", + lastSuccessAt: null, + lastAttemptAt: null, + lastError: null, + configured: false, + syncHealth: { verdict: "disconnected", since: null }, + metricFreshness: [], + }, + ], + }; + expect(render()).not.toContain('data-testid="integration-fallback-row"'); + }); +}); diff --git a/src/components/settings/integrations/__tests__/nightscout-card.test.tsx b/src/components/settings/integrations/__tests__/nightscout-card.test.tsx index 4259839dc..dc7c1f8a1 100644 --- a/src/components/settings/integrations/__tests__/nightscout-card.test.tsx +++ b/src/components/settings/integrations/__tests__/nightscout-card.test.tsx @@ -1,12 +1,12 @@ /** - * v1.17.0 — interaction-parity + correctness checks for the Nightscout card. + * Nightscout parity + correctness. * - * 1. A `parked` status renders the warning banner + reconnect button + * 1. A `parked` verdict renders the warning banner + reconnect button * (matching the WHOOP card's parked treatment, byte-for-byte classes). - * 2. A `connected` status renders the shared TestConnectionButton AND the + * 2. A healthy connection renders the shared TestConnectionButton AND the * connect→data link to /insights/blood-glucose. - * 3. The disconnect mutation invalidates `nightscoutStatus()` — the key the - * status query actually reads — so the pill refreshes after disconnect. + * 3. The card reads the consolidated envelope — it was the last card firing + * its own status round-trip — so a disconnect invalidates that one key. */ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -14,14 +14,12 @@ import { renderToStaticMarkup } from "react-dom/server"; import { queryKeys } from "@/lib/query-keys"; // Capture the disconnect mutation's onSuccess so the test can assert which -// query keys it invalidates. Status is driven off a per-test payload. -let statusPayload: unknown = null; +// query keys it invalidates. const invalidateSpy = vi.fn(); type OnSuccess = () => void; const capturedDisconnectOnSuccess: { fn: OnSuccess | null } = { fn: null }; vi.mock("@tanstack/react-query", () => ({ - useQuery: () => ({ data: statusPayload, isLoading: false }), useMutation: ({ onSuccess }: { onSuccess?: () => void }) => { // The Nightscout card declares exactly one mutation (disconnect). capturedDisconnectOnSuccess.fn = onSuccess ?? null; @@ -32,25 +30,38 @@ vi.mock("@tanstack/react-query", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import { NightscoutCard } from "../nightscout-card"; +import type { IntegrationStatusViewModel } from "../shared"; -function render() { +function render(viewModel?: Partial) { return renderToStaticMarkup( - + , ); } describe("NightscoutCard — parked + test + data-link + invalidation", () => { - it("renders the parked banner + reconnect button when state is parked", () => { - statusPayload = { + it("renders the parked banner + reconnect button when the verdict is parked", () => { + const html = render({ connected: true, configured: true, state: "parked", - lastSuccessAt: null, lastError: "Nightscout unreachable", - }; - const html = render(); + syncHealth: { verdict: "parked", since: null }, + }); expect(html).toContain('data-state="parked"'); expect(html).toContain('data-testid="nightscout-parked-banner"'); expect(html).toContain('data-testid="nightscout-resume-button"'); @@ -58,51 +69,78 @@ describe("NightscoutCard — parked + test + data-link + invalidation", () => { }); it("renders the test-connection button + data link when connected", () => { - statusPayload = { + const html = render({ connected: true, configured: true, - state: "connected", lastSuccessAt: "2026-06-01T00:00:00.000Z", - lastError: null, - }; - const html = render(); + syncHealth: { verdict: "fresh", since: null }, + }); expect(html).toContain("Test connection"); expect(html).toContain('data-testid="nightscout-data-link"'); expect(html).toContain('href="/insights/blood-glucose"'); }); - it("disconnect invalidates the status key the query reads (nightscoutStatus)", () => { - statusPayload = { connected: true, configured: true, state: "connected" }; + it("carries the shared card divider like every sibling", () => { + const html = render({ + connected: true, + configured: true, + syncHealth: { verdict: "fresh", since: null }, + }); + expect(html).toContain('data-testid="integration-card-divider"'); + }); + + it("renders the per-metric freshness disclosure with a stale row", () => { + const html = render({ + connected: true, + configured: true, + syncHealth: { verdict: "fresh", since: null }, + metricFreshness: [ + { + type: "BLOOD_GLUCOSE", + lastSeenAt: "2026-06-01T00:00:00.000Z", + stale: true, + }, + ], + }); + expect(html).toContain('data-slot="metric-freshness"'); + expect(html).toContain("quiet"); + }); + + it("disconnect invalidates the envelope the card actually reads", () => { invalidateSpy.mockClear(); capturedDisconnectOnSuccess.fn = null; - render(); + render({ + connected: true, + configured: true, + syncHealth: { verdict: "fresh", since: null }, + }); const onSuccess = capturedDisconnectOnSuccess.fn as (() => void) | null; expect(typeof onSuccess).toBe("function"); onSuccess?.(); const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey); - expect(invalidatedKeys).toContainEqual(queryKeys.nightscoutStatus()); + expect(invalidatedKeys).toContainEqual(queryKeys.integrationsStatus()); }); }); -/** - * v1.32.28 — sync now, matching the treatment the OAuth cards get. - */ describe("NightscoutCard — sync-now action", () => { it("offers the sync action on a connected instance", () => { - statusPayload = { + const html = render({ connected: true, configured: true, - state: "connected", - lastSuccessAt: null, - lastError: null, - }; - const html = render(); + syncHealth: { verdict: "fresh", since: null }, + }); expect(html).toContain('data-testid="nightscout-sync"'); expect(html).toContain("Sync now"); }); it("does not offer it before an instance is configured", () => { - statusPayload = { connected: false, configured: false }; - expect(render()).not.toContain('data-testid="nightscout-sync"'); + expect( + render({ + connected: false, + configured: false, + state: "disconnected", + syncHealth: { verdict: "disconnected", since: null }, + }), + ).not.toContain('data-testid="nightscout-sync"'); }); }); diff --git a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx index 33ee40a18..1e01d2f37 100644 --- a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx +++ b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx @@ -12,10 +12,9 @@ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { Watch } from "lucide-react"; -// Drive the card's own `useQuery` status read off a per-test payload. -let statusPayload: unknown = null; +// The card reads the consolidated envelope only — its per-card status fetch is +// gone, so every fixture is a view-model. vi.mock("@tanstack/react-query", () => ({ - useQuery: () => ({ data: statusPayload, isLoading: false }), useMutation: () => ({ mutate: vi.fn(), isPending: false }), useQueryClient: () => ({ invalidateQueries: vi.fn() }), })); @@ -23,12 +22,14 @@ vi.mock("@tanstack/react-query", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import { OAuthProviderCard } from "../oauth-provider-card"; +type ViewModel = Parameters[0]["viewModel"]; + function render({ credentials = false, viewModel, }: { credentials?: boolean; - viewModel?: Parameters[0]["viewModel"]; + viewModel?: ViewModel; } = {}) { return renderToStaticMarkup( @@ -47,15 +48,17 @@ function render({ describe("OAuthProviderCard — parked + test + data-link parity", () => { it("renders the parked banner + reconnect button when state is parked", () => { - statusPayload = { - connected: true, - configured: true, - available: true, - state: "parked", - lastSuccessAt: null, - lastError: "Polar grant expired", - }; - const html = render(); + const html = render({ + viewModel: { + connected: true, + configured: true, + available: true, + state: "parked", + lastSuccessAt: null, + lastError: "Polar grant expired", + syncHealth: { verdict: "parked", since: null }, + }, + }); expect(html).toContain('data-state="parked"'); expect(html).toContain('data-testid="polar-parked-banner"'); expect(html).toContain('data-testid="polar-resume-button"'); @@ -65,27 +68,6 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => { }); it("renders the test-connection button + data link when connected", () => { - statusPayload = { - connected: true, - configured: true, - available: true, - state: "connected", - lastSuccessAt: "2026-06-01T00:00:00.000Z", - lastError: null, - }; - const html = render(); - // The shared TestConnectionButton surfaces its "Test connection" label. - expect(html).toContain("Test connection"); - // connect→data link points at the provider's insight surface. - expect(html).toContain('data-testid="polar-data-link"'); - expect(html).toContain('href="/insights/sleep"'); - }); - - it("reads off the passed view-model instead of the per-card fetch (04-M2)", () => { - // The per-card useQuery mock returns null; the card must render the - // connected state from the supplied envelope view-model alone, proving the - // /api//status round-trip is no longer the source. - statusPayload = null; const html = render({ viewModel: { connected: true, @@ -94,6 +76,29 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => { state: "connected", lastSuccessAt: "2026-06-01T00:00:00.000Z", lastError: null, + syncHealth: { verdict: "fresh", since: null }, + }, + }); + // The shared TestConnectionButton surfaces its "Test connection" label. + expect(html).toContain("Test connection"); + // connect→data link points at the provider's insight surface. + expect(html).toContain('data-testid="polar-data-link"'); + expect(html).toContain('href="/insights/sleep"'); + }); + + it("reads off the passed view-model — the per-card fetch is gone", () => { + // There is no `useQuery` left in this component: the envelope is the only + // source. A per-provider status response carries no verdict, so falling + // back to it would have painted a status the server never resolved. + const html = render({ + viewModel: { + connected: true, + configured: true, + available: true, + state: "connected", + lastSuccessAt: "2026-06-01T00:00:00.000Z", + lastError: null, + syncHealth: { verdict: "fresh", since: null }, }, }); expect(html).toContain('data-testid="polar-data-link"'); @@ -101,12 +106,14 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => { }); it("does not render the data link or test button when disconnected", () => { - statusPayload = { - connected: false, - configured: false, - available: true, - }; - const html = render(); + const html = render({ + viewModel: { + connected: false, + configured: false, + available: true, + syncHealth: { verdict: "disconnected", since: null }, + }, + }); expect(html).not.toContain('data-testid="polar-data-link"'); expect(html).not.toContain("Test connection"); // The connect CTA stands in instead. @@ -116,35 +123,39 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => { describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () => { it("renders the credentials form only when the `credentials` prop is set", () => { - statusPayload = { + const viewModel: ViewModel = { connected: false, configured: false, available: true, hasOwnCredentials: false, + syncHealth: { verdict: "disconnected", since: null }, }; // Opt-in: the BYO client-id/secret form + save button appear. - const withForm = render({ credentials: true }); + const withForm = render({ credentials: true, viewModel }); expect(withForm).toContain('data-testid="polar-credentials"'); expect(withForm).toContain('id="polar-clientid"'); expect(withForm).toContain('id="polar-secret"'); // Default: no credential inputs (env-only behaviour preserved). - const withoutForm = render(); + const withoutForm = render({ viewModel }); expect(withoutForm).not.toContain('data-testid="polar-credentials"'); expect(withoutForm).not.toContain('id="polar-clientid"'); }); it("shows the saved-placeholder once the user has stored their own pair", () => { - statusPayload = { - connected: true, - configured: true, - available: true, - hasOwnCredentials: true, - state: "connected", - lastSuccessAt: null, - lastError: null, - }; - const html = render({ credentials: true }); + const html = render({ + credentials: true, + viewModel: { + connected: true, + configured: true, + available: true, + hasOwnCredentials: true, + state: "connected", + lastSuccessAt: null, + lastError: null, + syncHealth: { verdict: "pending_first_sync", since: null }, + }, + }); expect(html).toContain("Saved — enter new to replace"); }); }); @@ -152,29 +163,35 @@ describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () => describe("OAuthProviderCard — redirect-URI mini-guide (v1.29.x, UX audit H2)", () => { it("shows the callback URL guide before the user has BYO credentials", () => { process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; - statusPayload = { - connected: false, - configured: false, - available: true, - hasOwnCredentials: false, - }; - const html = render({ credentials: true }); + const html = render({ + credentials: true, + viewModel: { + connected: false, + configured: false, + available: true, + hasOwnCredentials: false, + syncHealth: { verdict: "disconnected", since: null }, + }, + }); expect(html).toContain('data-testid="polar-redirect-guide"'); expect(html).toContain('data-testid="polar-redirect-uri"'); expect(html).toContain("https://app.example/api/polar/callback"); }); it("hides the guide once the user has stored their own credentials", () => { - statusPayload = { - connected: true, - configured: true, - available: true, - hasOwnCredentials: true, - state: "connected", - lastSuccessAt: null, - lastError: null, - }; - const html = render({ credentials: true }); + const html = render({ + credentials: true, + viewModel: { + connected: true, + configured: true, + available: true, + hasOwnCredentials: true, + state: "connected", + lastSuccessAt: null, + lastError: null, + syncHealth: { verdict: "fresh", since: null }, + }, + }); expect(html).not.toContain('data-testid="polar-redirect-guide"'); }); }); @@ -187,25 +204,31 @@ describe("OAuthProviderCard — redirect-URI mini-guide (v1.29.x, UX audit H2)", */ describe("OAuthProviderCard — sync-now action", () => { it("offers the sync action on a connected provider", () => { - statusPayload = { - connected: true, - configured: true, - available: true, - state: "connected", - lastSuccessAt: null, - lastError: null, - }; - const html = render(); + const html = render({ + viewModel: { + connected: true, + configured: true, + available: true, + state: "connected", + lastSuccessAt: null, + lastError: null, + syncHealth: { verdict: "fresh", since: null }, + }, + }); expect(html).toContain('data-testid="polar-sync"'); expect(html).toContain("Sync now"); }); it("does not offer it before the provider is connected", () => { - statusPayload = { - connected: false, - configured: true, - available: true, - }; - expect(render()).not.toContain('data-testid="polar-sync"'); + expect( + render({ + viewModel: { + connected: false, + configured: true, + available: true, + syncHealth: { verdict: "disconnected", since: null }, + }, + }), + ).not.toContain('data-testid="polar-sync"'); }); }); diff --git a/src/components/settings/integrations/__tests__/pill-mapper.test.tsx b/src/components/settings/integrations/__tests__/pill-mapper.test.tsx new file mode 100644 index 000000000..9216a1b61 --- /dev/null +++ b/src/components/settings/integrations/__tests__/pill-mapper.test.tsx @@ -0,0 +1,82 @@ +/** + * One verdict, one pill mapping. + * + * There used to be three `pillStateFor` implementations — one per card family — + * and they disagreed with each other. The same ledger state painted red on the + * Withings card and orange on the Polar card, on the same page. The server now + * resolves the verdict once; this pins that the client projects it once, and + * that a fourth dialect cannot be minted without someone editing this file. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join, sep } from "node:path"; +import { globSync } from "node:fs"; + +import type { SyncVerdict } from "@/lib/integrations/sync-verdict"; +import { pillStateForVerdict } from "../shared"; + +describe("pillStateForVerdict — the verdict→pill table", () => { + const table: Array<[SyncVerdict | undefined, string]> = [ + ["fresh", "connected"], + ["stale", "stale"], + ["stalled", "stalled"], + ["failing", "warning"], + ["reauth_required", "error"], + ["parked", "parked"], + ["pending_first_sync", "pending-setup"], + ["disconnected", "disconnected"], + // Still loading: the card renders its neutral default rather than + // guessing at a status it has not been told. + [undefined, "disconnected"], + ]; + + it.each(table)("maps %s to the %s pill", (verdict, expected) => { + expect(pillStateForVerdict(verdict)).toBe(expected); + }); + + /** + * The decision this release makes and must not silently lose: **red means + * "your action fixes this"**. Reconnecting cannot repair an upstream 503 or + * a cron that stopped running, so neither of those may reach the destructive + * pill. + */ + it("reserves the error pill for the states a reconnect actually fixes", () => { + const red = table + .filter(([, state]) => state === "error") + .map(([verdict]) => verdict); + expect(red).toEqual(["reauth_required"]); + expect(pillStateForVerdict("failing")).not.toBe("error"); + expect(pillStateForVerdict("stalled")).not.toBe("error"); + }); +}); + +describe("structural guard — no second pill dialect", () => { + const SRC = join(process.cwd(), "src"); + + function sourceFiles(): string[] { + return globSync("**/*.{ts,tsx}", { cwd: SRC }) + .filter( + (p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"), + ) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .map((p) => p.split(sep).join("/")) + .sort(); + } + + /** + * The one module allowed to define a status→pill mapping. A tripwire, not a + * proof: it cannot show the mapping is right, only that nobody added a + * second one without editing this file. + */ + const MAPPER_HOME = "components/settings/integrations/shared.tsx"; + + it("defines the mapping in exactly one module", () => { + const definers = sourceFiles().filter((rel) => + /function\s+pillStateFor\w*\s*\(/.test( + readFileSync(join(SRC, rel), "utf8"), + ), + ); + expect(definers).toEqual([MAPPER_HOME]); + }); +}); diff --git a/src/lib/integrations/__tests__/metric-freshness.test.ts b/src/lib/integrations/__tests__/metric-freshness.test.ts index b41e54e20..96de8cf4b 100644 --- a/src/lib/integrations/__tests__/metric-freshness.test.ts +++ b/src/lib/integrations/__tests__/metric-freshness.test.ts @@ -1,24 +1,37 @@ /** - * F-SYNC-1 — per-metric-type last-value freshness. + * Per-metric-type last-value freshness. * * Pins that the helper distinguishes a silently-dead metric (its newest reading * frozen in the past) from a genuinely-current one, keyed by integration, from - * one grouped read over the live Measurement rows — the honest signal the - * per-integration "connected · 5 min ago" pill cannot carry. + * grouped reads over the live rows — the honest signal the per-integration + * "connected · 5 min ago" pill cannot carry. */ import { beforeEach, describe, expect, it, vi } from "vitest"; const groupByMock = vi.hoisted(() => vi.fn()); +const workoutGroupByMock = vi.hoisted(() => vi.fn()); vi.mock("@/lib/db", () => ({ - prisma: { measurement: { groupBy: groupByMock } }, + prisma: { + measurement: { groupBy: groupByMock }, + workout: { groupBy: workoutGroupByMock }, + }, })); import { + getSourceFreshness, getSourceMetricFreshness, INTEGRATION_MEASUREMENT_SOURCE, } from "../metric-freshness"; +import { + classifyMetricFreshness, + METRIC_QUIET_AFTER_MS, +} from "../sync-verdict"; -beforeEach(() => groupByMock.mockReset()); +beforeEach(() => { + groupByMock.mockReset(); + workoutGroupByMock.mockReset(); + workoutGroupByMock.mockResolvedValue([]); +}); describe("getSourceMetricFreshness", () => { it("maps grouped source/type rows to per-integration last-seen entries", async () => { @@ -79,4 +92,119 @@ describe("getSourceMetricFreshness", () => { groupByMock.mockResolvedValue([]); expect(await getSourceMetricFreshness("u1")).toEqual({}); }); + + it("carries the workout leg for a source that writes no measurements", async () => { + // Strava writes Workout rows only; without this leg its freshness list is + // permanently empty and its one real pipe is invisible. + groupByMock.mockResolvedValue([]); + workoutGroupByMock.mockResolvedValue([ + { + source: "STRAVA", + _max: { startedAt: new Date("2026-07-20T06:00:00.000Z") }, + }, + ]); + + const result = await getSourceMetricFreshness("u1"); + expect(result.strava).toEqual([ + { type: "WORKOUTS", lastSeenAt: "2026-07-20T06:00:00.000Z" }, + ]); + }); + + it("appends the workout leg beside a source's own measurements", async () => { + groupByMock.mockResolvedValue([ + { + source: "POLAR", + type: "SLEEP_DURATION", + _max: { measuredAt: new Date("2026-07-24T04:00:00.000Z") }, + }, + ]); + workoutGroupByMock.mockResolvedValue([ + { + source: "POLAR", + _max: { startedAt: new Date("2026-07-19T17:00:00.000Z") }, + }, + ]); + + expect((await getSourceMetricFreshness("u1")).polar).toEqual([ + { type: "SLEEP_DURATION", lastSeenAt: "2026-07-24T04:00:00.000Z" }, + { type: "WORKOUTS", lastSeenAt: "2026-07-19T17:00:00.000Z" }, + ]); + }); +}); + +describe("getSourceFreshness — one source, for Apple Health", () => { + it("reads only the requested source", async () => { + groupByMock.mockResolvedValue([ + { + source: "APPLE_HEALTH", + type: "RESPIRATORY_RATE", + _max: { measuredAt: new Date("2026-07-01T00:00:00.000Z") }, + }, + ]); + + expect(await getSourceFreshness("u1", "APPLE_HEALTH")).toEqual([ + { type: "RESPIRATORY_RATE", lastSeenAt: "2026-07-01T00:00:00.000Z" }, + ]); + expect(groupByMock.mock.calls[0][0].where.source.in).toEqual([ + "APPLE_HEALTH", + ]); + }); +}); + +describe("classifyMetricFreshness — the stale flag", () => { + const NOW = new Date("2026-07-25T12:00:00.000Z"); + const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString(); + const DAY = 24 * 60 * 60 * 1000; + + it("flags a quiet metric only while the provider itself reads healthy", () => { + const samples = [{ type: "WEIGHT", lastSeenAt: ago(20 * DAY) }]; + + // Healthy pipe, one silent metric: the dead-pipe signature. + expect(classifyMetricFreshness(samples, "fresh", NOW)[0].stale).toBe(true); + + // The whole connection has stopped: every metric ages together, so tinting + // them would only restate what the pill already says. + expect(classifyMetricFreshness(samples, "stalled", NOW)[0].stale).toBe( + false, + ); + expect(classifyMetricFreshness(samples, "failing", NOW)[0].stale).toBe( + false, + ); + expect(classifyMetricFreshness(samples, "stale", NOW)[0].stale).toBe(false); + }); + + it("exempts the types that are irregular by nature", () => { + // A VO2 max estimate every few weeks is normal; a workout gap is a rest + // week, not a broken pipe. + for (const type of [ + "VO2_MAX", + "IRREGULAR_RHYTHM_NOTIFICATION", + "WORKOUTS", + ]) { + const [entry] = classifyMetricFreshness( + [{ type, lastSeenAt: ago(60 * DAY) }], + "fresh", + NOW, + ); + expect(entry.stale, type).toBe(false); + // The honest timestamp still renders — exempt means untinted, not hidden. + expect(entry.lastSeenAt).toBe(ago(60 * DAY)); + } + }); + + it("draws the quiet line at fourteen days", () => { + // Deliberately generous: a weekly weigher must not be warned after a + // holiday, and a false "this is broken" costs more trust than a few days + // of latency on a real one. + expect(METRIC_QUIET_AFTER_MS).toBe(14 * DAY); + + const at = (ms: number) => + classifyMetricFreshness( + [{ type: "PULSE", lastSeenAt: ago(ms) }], + "fresh", + NOW, + )[0].stale; + expect(at(13 * DAY)).toBe(false); + expect(at(15 * DAY)).toBe(true); + }); }); diff --git a/src/lib/integrations/__tests__/sync-verdict.test.ts b/src/lib/integrations/__tests__/sync-verdict.test.ts new file mode 100644 index 000000000..df6c080d2 --- /dev/null +++ b/src/lib/integrations/__tests__/sync-verdict.test.ts @@ -0,0 +1,207 @@ +/** + * The sync-health ladder. + * + * The load-bearing row is `stalled`. A ledger frozen at `error_transient` with + * a month-old `lastAttemptAt` used to be indistinguishable from one retrying + * every hour, because nothing anywhere read the age of that timestamp. The + * fixture below is the live incident that exposed it: an integration that had + * not even TRIED for four weeks while claiming, in its own documented contract, + * that it "still attempts on the next run". + */ +import { describe, expect, it } from "vitest"; + +import { + APPLE_HEALTH_DATA_STALE_AFTER_MS, + ATTEMPT_STALE_AFTER_MS, + INTEGRATION_CADENCE, + POLLED_CADENCE, + PUSH_CADENCE, + resolveSyncVerdict, +} from "../sync-verdict"; + +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 DAY = 24 * HOUR; + +describe("resolveSyncVerdict — the ladder", () => { + it("calls a month-silent polled integration stalled, not failing", () => { + // The live incident: state frozen at error_transient since the last write, + // no attempt for four weeks, hourly cron cohort. + const lastAttemptAt = ago(28 * DAY); + const result = resolveSyncVerdict({ + configured: true, + state: "error_transient", + lastSuccessAt: ago(55 * DAY), + lastAttemptAt, + cadence: INTEGRATION_CADENCE.moodlog, + now: NOW, + }); + expect(result.verdict).toBe("stalled"); + expect(result.since).toBe(lastAttemptAt); + }); + + it("calls a recently-attempted failing integration failing", () => { + const lastSuccessAt = ago(3 * DAY); + expect( + resolveSyncVerdict({ + connected: true, + state: "error_transient", + lastSuccessAt, + lastAttemptAt: ago(2 * HOUR), + cadence: POLLED_CADENCE, + now: NOW, + }), + ).toEqual({ verdict: "failing", since: lastSuccessAt }); + }); + + it("draws the stalled line at 24 hours", () => { + // The threshold is a decision, not an implementation detail: ~24 missed + // hourly ticks, tolerant of a maintenance window, short enough that a + // stopped sync surfaces the next day. + expect(ATTEMPT_STALE_AFTER_MS).toBe(24 * HOUR); + + const base = { + connected: true, + state: "connected", + lastSuccessAt: null, + cadence: POLLED_CADENCE, + now: NOW, + }; + expect( + resolveSyncVerdict({ ...base, lastAttemptAt: ago(23 * HOUR) }).verdict, + ).toBe("fresh"); + expect( + resolveSyncVerdict({ ...base, lastAttemptAt: ago(25 * HOUR) }).verdict, + ).toBe("stalled"); + }); + + it("never calls a push source stalled — it has no attempt concept", () => { + // Same 28-day silence, push cadence: the honest reading is 'no data', not + // 'stopped trying', because nothing was ever going to try. + const lastSyncedAt = ago(28 * DAY); + expect( + resolveSyncVerdict({ + configured: true, + lastDataAt: lastSyncedAt, + legacyLastSyncedAt: lastSyncedAt, + cadence: PUSH_CADENCE, + now: NOW, + }), + ).toEqual({ verdict: "stale", since: lastSyncedAt }); + }); + + it("draws the Apple Health line at seven days", () => { + // Long enough not to false-alarm on a normal iOS background-delivery gap, + // short enough that a genuinely dead pipe is named within a week. + expect(APPLE_HEALTH_DATA_STALE_AFTER_MS).toBe(7 * DAY); + + const base = { configured: true, cadence: PUSH_CADENCE, now: NOW }; + expect( + resolveSyncVerdict({ ...base, lastDataAt: ago(6 * DAY) }).verdict, + ).toBe("fresh"); + expect( + resolveSyncVerdict({ ...base, lastDataAt: ago(8 * DAY) }).verdict, + ).toBe("stale"); + }); + + it("reports a never-attempted connection as pending, not connected", () => { + expect( + resolveSyncVerdict({ + connected: true, + configured: true, + // The synthetic "connected, never attempted" ledger snapshot. + state: "connected", + lastSuccessAt: null, + lastAttemptAt: null, + cadence: POLLED_CADENCE, + now: NOW, + }), + ).toEqual({ verdict: "pending_first_sync", since: null }); + }); + + it("puts parked and reauth ahead of every age-based arm", () => { + const lastAttemptAt = ago(40 * DAY); + expect( + resolveSyncVerdict({ + connected: true, + state: "parked", + lastAttemptAt, + lastSuccessAt: ago(60 * DAY), + cadence: POLLED_CADENCE, + now: NOW, + }), + ).toEqual({ verdict: "parked", since: lastAttemptAt }); + expect( + resolveSyncVerdict({ + connected: true, + state: "error_reauth", + lastAttemptAt, + lastSuccessAt: ago(60 * DAY), + cadence: POLLED_CADENCE, + now: NOW, + }), + ).toEqual({ verdict: "reauth_required", since: lastAttemptAt }); + }); + + it("keeps a deliberate disconnect out of the aging arms", () => { + // Credentials survive a disconnect. Without this arm the row would age into + // 'stalled' and tell the user their sync had stopped, when they stopped it. + expect( + resolveSyncVerdict({ + connected: false, + configured: true, + state: "disconnected", + lastSuccessAt: ago(90 * DAY), + lastAttemptAt: ago(90 * DAY), + cadence: POLLED_CADENCE, + now: NOW, + }), + ).toEqual({ verdict: "disconnected", since: null }); + }); + + it("reports an unconfigured, unconnected provider as disconnected", () => { + expect( + resolveSyncVerdict({ + configured: false, + cadence: POLLED_CADENCE, + now: NOW, + }), + ).toEqual({ verdict: "disconnected", since: null }); + }); + + it("falls back to the data-age arm for a polled source that keeps trying", () => { + // Defensive arm: attempts are current, but nothing new has landed. + const lastSuccessAt = ago(5 * DAY); + expect( + resolveSyncVerdict({ + connected: true, + state: "connected", + lastSuccessAt, + lastAttemptAt: ago(30 * 60 * 1000), + cadence: POLLED_CADENCE, + now: NOW, + }), + ).toEqual({ verdict: "stale", since: lastSuccessAt }); + }); + + it("calls a healthy hourly integration fresh", () => { + expect( + resolveSyncVerdict({ + connected: true, + state: "connected", + lastSuccessAt: ago(20 * 60 * 1000), + lastAttemptAt: ago(20 * 60 * 1000), + cadence: POLLED_CADENCE, + now: NOW, + }), + ).toEqual({ verdict: "fresh", since: null }); + }); + + it("puts every ledger-backed integration on the polled cadence", () => { + for (const cadence of Object.values(INTEGRATION_CADENCE)) { + expect(cadence.attemptStaleAfterMs).toBe(24 * HOUR); + } + }); +}); diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 31936bee4..6d1c1637d 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -592,6 +592,42 @@ describe("get_integration_status", () => { expect(JSON.stringify(p)).not.toContain("token revoked"); }); + it("reports a month-dead pull as stalled while `connected` still reads true", () => { + // `connected` is `state !== "disconnected"` and carries no liveness at all. + // Kept for compatibility; `verdict` is what an assistant should answer + // "why is my data stale?" from. + const lastAttemptAt = new Date( + Date.now() - 28 * 24 * 60 * 60 * 1000, + ).toISOString(); + vi.mocked(prisma.integrationStatus.findMany).mockResolvedValue([ + { integration: "moodlog" }, + ] as never); + vi.mocked(getIntegrationStatus).mockResolvedValue({ + integration: "moodlog", + state: "error_transient", + lastSuccessAt: new Date( + Date.now() - 55 * 24 * 60 * 60 * 1000, + ).toISOString(), + lastAttemptAt, + lastError: null, + consecutiveFailuresByKind: null, + }); + + return tool("get_integration_status") + .run(CTX, {}) + .then((raw) => { + const result = raw as { + providers: Array>; + }; + expect(result.providers[0]).toMatchObject({ + provider: "moodlog", + connected: true, + verdict: "stalled", + since: lastAttemptAt, + }); + }); + }); + it("returns { present: false } when nothing has ever synced", async () => { vi.mocked(prisma.integrationStatus.findMany).mockResolvedValue([] as never); const result = (await tool("get_integration_status").run(CTX, {})) as {