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 {