diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index 977316ea1..067113a7f 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -219,6 +219,8 @@ describe("GET /api/integrations/healthkit — sync health", () => { type Body = { lastSyncedAt: string | null; + lastSyncTrigger: string | null; + lastBackgroundSyncAt: string | null; syncHealth: { verdict: string; since: string | null }; metricFreshness: Array<{ type: string; @@ -293,6 +295,38 @@ describe("GET /api/integrations/healthkit — sync health", () => { expect(byType.PULSE).toBe(false); }); + // #586 — the reporter's question is whether the phone delivers on its own or + // only while the app is open. `lastSyncedAt` reads identically either way, so + // it cannot answer it; these two fields are what can. + it("publishes the trigger of the last batch and the last background arrival", async () => { + const backgroundAt = ago(3 * 60 * 60 * 1000); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(60 * 60 * 1000), + healthKitLastSyncTrigger: "background", + healthKitLastBackgroundSyncAt: backgroundAt, + } as never); + + const body = await read(); + expect(body.lastSyncTrigger).toBe("background"); + expect(body.lastBackgroundSyncAt).toBe(backgroundAt.toISOString()); + }); + + it("reports honest absence when the client has never declared a trigger", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(60 * 60 * 1000), + healthKitLastSyncTrigger: null, + healthKitLastBackgroundSyncAt: null, + } as never); + + const body = await read(); + // An older client that reports nothing, and a pipe that has never + // delivered in the background, both read as null — never as a guess. + expect(body.lastSyncTrigger).toBeNull(); + expect(body.lastBackgroundSyncAt).toBeNull(); + }); + it("degrades to no per-metric data rather than failing the config read", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ healthKitConfigJson: null, diff --git a/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts b/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts index bd44f7946..9ad2c0104 100644 --- a/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts +++ b/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts @@ -3,8 +3,8 @@ * batch ingest. * * It is DIAGNOSTIC-ONLY: recorded on the `measurement.batch.ingest` wide event - * so an operator can see, per batch, what woke the client (foreground app open, - * background refresh, or a push), and NOTHING else. These pin that contract: + * and kept on the account, where the Apple Health card reads it. These pin that + * contract: * * - a batch carrying `syncTrigger:"background"` is accepted and the value * rides the ingest annotation; @@ -12,7 +12,11 @@ * - an invalid value is a 422 (closed enum); * - the field never changes which rows are inserted / updated — the per-row * outcomes are byte-identical with and without it, so it cannot leak into - * dedup, attribution, or storage. + * dedup, attribution, or storage; + * - the trigger is stamped on the account, and a background or push trigger + * additionally dates the last background arrival. That second timestamp is + * the whole diagnostic (#586): `healthKitLastSyncedAt` alone reads the same + * whether the phone delivers on its own or only while the app is open. */ import { describe, expect, it, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -301,3 +305,68 @@ describe("POST /api/measurements/batch — syncTrigger diagnostic (iOS #66)", () expect(withoutBody.inserted).toBe(1); }); }); + +describe("POST /api/measurements/batch — HealthKit trigger persistence (#586)", () => { + function stampedData() { + const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as + { data: Record } | undefined; + return call?.data; + } + + it("dates the last background arrival when the batch says it woke in the background", async () => { + const res = await POST( + makeRequest({ + entries: [sampleEntry(SAMPLE_ID, 64)], + syncTrigger: "background", + }), + ); + expect(res.status).toBe(200); + expect(stampedData()).toMatchObject({ + healthKitLastSyncedAt: expect.any(Date), + healthKitLastSyncTrigger: "background", + healthKitLastBackgroundSyncAt: expect.any(Date), + }); + }); + + it("treats a push wake as delivery without the app too", async () => { + const res = await POST( + makeRequest({ + entries: [sampleEntry(SAMPLE_ID, 64)], + syncTrigger: "push", + }), + ); + expect(res.status).toBe(200); + expect(stampedData()).toMatchObject({ + healthKitLastSyncTrigger: "push", + healthKitLastBackgroundSyncAt: expect.any(Date), + }); + }); + + it("leaves the background timestamp untouched for a foreground batch", async () => { + const res = await POST( + makeRequest({ + entries: [sampleEntry(SAMPLE_ID, 64)], + syncTrigger: "foreground", + }), + ); + expect(res.status).toBe(200); + const data = stampedData(); + expect(data).toMatchObject({ healthKitLastSyncTrigger: "foreground" }); + // Not written at all — a sync the user triggered by opening the app is no + // evidence about background delivery, and overwriting the older honest + // timestamp with this one would erase the only answer the card has. + expect(data).not.toHaveProperty("healthKitLastBackgroundSyncAt"); + }); + + it("writes null over a stale trigger when the client declares none", async () => { + const res = await POST( + makeRequest({ entries: [sampleEntry(SAMPLE_ID, 64)] }), + ); + expect(res.status).toBe(200); + const data = stampedData(); + // The field describes THIS sync. Leaving the previous batch's trigger + // standing would attribute it to a batch that never declared one. + expect(data).toMatchObject({ healthKitLastSyncTrigger: null }); + expect(data).not.toHaveProperty("healthKitLastBackgroundSyncAt"); + }); +}); diff --git a/src/components/settings/__tests__/integration-status-pill.test.tsx b/src/components/settings/__tests__/integration-status-pill.test.tsx index 466834869..b9a2db562 100644 --- a/src/components/settings/__tests__/integration-status-pill.test.tsx +++ b/src/components/settings/__tests__/integration-status-pill.test.tsx @@ -230,3 +230,27 @@ describe("IntegrationStatusPill — a failing provider states its age", () => { expect(html).toContain("Sync fehlgeschlagen"); }); }); + +describe("IntegrationStatusPill — accessible name", () => { + /** + * A generic `aria-label` used to sit on the badge. Being a label, it + * REPLACED the accessible name with itself, so a screen reader announced the + * same words for a healthy connection as for a broken one — erasing the one + * distinction the pill exists to draw. + */ + it.each([ + ["connected", "Connected"], + ["error", "Error"], + ["parked", "Paused"], + ] as const)("lets the %s label be the accessible name", (state, label) => { + const html = render( + , + ); + expect(html).not.toContain("aria-label="); + expect(html).toContain(label); + }); +}); 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 db8911958..da4083c7f 100644 --- a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx +++ b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx @@ -12,6 +12,8 @@ import pl from "../../../../../messages/pl.json"; type Payload = { lastSyncedAt: string | null; + lastSyncTrigger?: "foreground" | "background" | "push" | null; + lastBackgroundSyncAt?: string | null; syncHealth?: { verdict: string; since: string | null }; metricFreshness?: Array<{ type: string; @@ -227,3 +229,69 @@ describe("", () => { }, ); }); + +describe(" — delivery diagnostic (#586)", () => { + beforeEach(() => { + statusPayload = undefined; + statusLoading = false; + statusError = false; + }); + + it("names how the last sync arrived and when data last came in on its own", () => { + statusPayload = { + lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + lastSyncTrigger: "background", + lastBackgroundSyncAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + syncHealth: { verdict: "fresh", since: null }, + }; + + const html = render(); + + expect(html).toContain('data-testid="apple-health-delivery"'); + expect(html).toContain("in the background"); + expect(html).toContain("Last background sync"); + }); + + it("says plainly that background delivery has never happened", () => { + statusPayload = { + lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + lastSyncTrigger: "foreground", + lastBackgroundSyncAt: null, + syncHealth: { verdict: "fresh", since: null }, + }; + + const html = render(); + + // The reporter's question, answered rather than left to inference: every + // sync so far arrived while the app was open. + expect(html).toContain("with the app open"); + expect(html).toContain("every sync so far arrived with the app open"); + }); + + it("reports a missing trigger as unreported instead of guessing one", () => { + statusPayload = { + lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + lastSyncTrigger: null, + lastBackgroundSyncAt: null, + syncHealth: { verdict: "fresh", since: null }, + }; + + const html = render(); + + expect(html).toContain( + '
trigger not reported by the app
', + ); + }); + + it("says there is nothing to report when no sync has ever arrived", () => { + statusPayload = { + lastSyncedAt: null, + syncHealth: { verdict: "pending_first_sync", since: null }, + }; + + const html = render(); + + expect(html).toContain("No sync has arrived yet"); + expect(html).not.toContain("Last background sync"); + }); +}); diff --git a/src/lib/integrations/__tests__/metric-freshness.test.ts b/src/lib/integrations/__tests__/metric-freshness.test.ts index 96de8cf4b..c5f3c0a5d 100644 --- a/src/lib/integrations/__tests__/metric-freshness.test.ts +++ b/src/lib/integrations/__tests__/metric-freshness.test.ts @@ -130,6 +130,36 @@ describe("getSourceMetricFreshness", () => { { type: "WORKOUTS", lastSeenAt: "2026-07-19T17:00:00.000Z" }, ]); }); + + it.each(["whoop", "fitbit", "google-health"] as const)( + "carries the workout leg for %s, which writes workouts too", + async (integration) => { + const source = { + whoop: "WHOOP", + fitbit: "FITBIT", + "google-health": "GOOGLE_HEALTH", + }[integration]; + + // These three were missing from the workout table, so a card could + // report every measurement type fresh while the workout leg had been + // silent for weeks — the disclosure had no row that could go quiet. + groupByMock.mockResolvedValue([ + { + source, + type: "SLEEP_DURATION", + _max: { measuredAt: new Date("2026-07-24T04:00:00.000Z") }, + }, + ]); + workoutGroupByMock.mockResolvedValue([ + { source, _max: { startedAt: new Date("2026-06-01T09:00:00.000Z") } }, + ]); + + expect((await getSourceMetricFreshness("u1"))[integration]).toEqual([ + { type: "SLEEP_DURATION", lastSeenAt: "2026-07-24T04:00:00.000Z" }, + { type: "WORKOUTS", lastSeenAt: "2026-06-01T09:00:00.000Z" }, + ]); + }, + ); }); describe("getSourceFreshness — one source, for Apple Health", () => { diff --git a/src/lib/integrations/__tests__/status.test.ts b/src/lib/integrations/__tests__/status.test.ts index 9be47a336..50cfb4291 100644 --- a/src/lib/integrations/__tests__/status.test.ts +++ b/src/lib/integrations/__tests__/status.test.ts @@ -109,7 +109,7 @@ describe("recordSyncSuccess", () => { }, persistentFailureStartedAt: null, failingSinceAt: null, - failingLeg: null, + failingLegs: [], alertedAt: null, }); }); @@ -496,7 +496,7 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => { }, persistentFailureStartedAt: null, failingSinceAt: null, - failingLeg: null, + failingLegs: [], alertedAt: null, }); }); @@ -521,7 +521,7 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => { }, persistentFailureStartedAt: null, failingSinceAt: null, - failingLeg: null, + failingLegs: [], alertedAt: null, }); }); @@ -840,7 +840,7 @@ describe("resumeIntegrationFromPark", () => { }, persistentFailureStartedAt: null, failingSinceAt: null, - failingLeg: null, + failingLegs: [], alertedAt: null, }); @@ -1032,7 +1032,7 @@ describe("recordSyncSuccess — leg scoping", () => { it("leaves a sibling leg's error standing and only records the attempt", async () => { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ state: "error_transient", - failingLeg: "sleep", + failingLegs: ["sleep"], } as never); vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce( {} as never, @@ -1044,13 +1044,59 @@ describe("recordSyncSuccess — leg scoping", () => { const args = vi.mocked(prisma.integrationStatus.update).mock.calls[0]![0]!; // `lastSuccessAt` deliberately absent: it is what the card's // "connected · X ago" reads, and the sleep pipe has delivered nothing. - expect(args.data).toEqual({ lastAttemptAt: expect.any(Date) }); + // `lastError` is absent too — sleep still owns the message on the row. + expect(args.data).toEqual({ + lastAttemptAt: expect.any(Date), + failingLegs: ["sleep"], + }); + }); + + it("drops only its own leg when several are failing at once", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + state: "error_transient", + failingLegs: ["sleep", "activity", "ecg"], + } as never); + vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce( + {} as never, + ); + + await recordSyncSuccess("u1", "withings", { leg: "activity" }); + + expect(prisma.integrationStatus.upsert).not.toHaveBeenCalled(); + const args = vi.mocked(prisma.integrationStatus.update).mock.calls[0]![0]!; + // The single slot could hold one of these three. Two legs are still + // failing, and both are still on the row. + expect(args.data).toMatchObject({ failingLegs: ["sleep", "ecg"] }); + // `activity` did not write the message the row holds — `ecg` did, and it + // is still failing — so the message stays. + expect(args.data).not.toHaveProperty("lastError"); + }); + + it("clears the error message when the leg that wrote it recovers first", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + state: "error_transient", + failingLegs: ["sleep", "ecg"], + } as never); + vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce( + {} as never, + ); + + // `ecg` is last, so it failed most recently and owns `lastError`. + await recordSyncSuccess("u1", "withings", { leg: "ecg" }); + + const args = vi.mocked(prisma.integrationStatus.update).mock.calls[0]![0]!; + // Keeping the message would leave the row describing a leg that is now + // healthy while `sleep` is the one actually failing. + expect(args.data).toMatchObject({ + failingLegs: ["sleep"], + lastError: null, + }); }); it("clears the row whole when the failing leg is the one that succeeded", async () => { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ state: "error_transient", - failingLeg: "sleep", + failingLegs: ["sleep"], } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( {} as never, @@ -1064,14 +1110,14 @@ describe("recordSyncSuccess — leg scoping", () => { state: "connected", lastError: null, failingSinceAt: null, - failingLeg: null, + failingLegs: [], }); }); it("clears the row whole when the failure was never attributed to a leg", async () => { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ state: "error_transient", - failingLeg: null, + failingLegs: [], } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( {} as never, @@ -1097,7 +1143,7 @@ describe("recordSyncSuccess — leg scoping", () => { it("does not treat a healthy row's stale leg marker as a held error", async () => { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ state: "connected", - failingLeg: "sleep", + failingLegs: ["sleep"], } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( {} as never, @@ -1107,4 +1153,102 @@ describe("recordSyncSuccess — leg scoping", () => { expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce(); }); + + it("reads a malformed leg column as unattributed rather than guessing", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + state: "error_transient", + // Not an array of strings — a legacy shape, or a hand-edited row. + failingLegs: { sleep: true }, + } as never); + vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( + {} as never, + ); + + await recordSyncSuccess("u1", "withings", { leg: "ecg" }); + + // Unattributed means any success clears — the pre-existing semantics for + // a failure that named no leg, not an invented leg name. + expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce(); + }); +}); + +describe("recordSyncFailure — leg set", () => { + function writtenUpdate() { + return vi.mocked(prisma.integrationStatus.upsert).mock.calls[0]![0]! + .update as Record; + } + + it("adds a second failing leg instead of overwriting the first", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + consecutiveFailuresByKind: { + transient: 1, + reauth_required: 0, + persistent: 0, + }, + persistentFailureStartedAt: null, + failingSinceAt: new Date("2026-01-01T00:00:00Z"), + failingLegs: ["sleep"], + alertedAt: null, + } as never); + + await recordSyncFailure({ + userId: "u1", + integration: "withings", + kind: "transient", + message: "Withings activity error: 503 - upstream", + leg: "activity", + }); + + expect(writtenUpdate().failingLegs).toEqual(["sleep", "activity"]); + }); + + it("moves a re-failing leg to the end so it owns the message it just wrote", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + consecutiveFailuresByKind: { + transient: 2, + reauth_required: 0, + persistent: 0, + }, + persistentFailureStartedAt: null, + failingSinceAt: new Date("2026-01-01T00:00:00Z"), + failingLegs: ["sleep", "activity"], + alertedAt: null, + } as never); + + await recordSyncFailure({ + userId: "u1", + integration: "withings", + kind: "transient", + message: "Withings sleep error: 503 - upstream", + leg: "sleep", + }); + + // No duplicate, and `sleep` is last because it wrote `lastError`. + expect(writtenUpdate().failingLegs).toEqual(["activity", "sleep"]); + }); + + it("leaves the set alone when the failure names no leg", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + consecutiveFailuresByKind: { + transient: 1, + reauth_required: 0, + persistent: 0, + }, + persistentFailureStartedAt: null, + failingSinceAt: new Date("2026-01-01T00:00:00Z"), + failingLegs: ["sleep"], + alertedAt: null, + } as never); + + await recordSyncFailure({ + userId: "u1", + integration: "withings", + kind: "transient", + message: "Withings error: 503 - upstream", + }); + + // An unattributed failure carries nothing to record; erasing `sleep` would + // let the sleep leg's next success clear an error it did not fix. + expect(writtenUpdate().failingLegs).toEqual(["sleep"]); + }); }); diff --git a/tests/integration/integration-sync-escalation.test.ts b/tests/integration/integration-sync-escalation.test.ts index fee900389..2fc3f37f8 100644 --- a/tests/integration/integration-sync-escalation.test.ts +++ b/tests/integration/integration-sync-escalation.test.ts @@ -257,7 +257,7 @@ describe("leg-scoped success", () => { expect(current!.state).toBe("error_transient"); expect(current!.lastError).toBe(afterFailure!.lastError); expect(current!.consecutiveFailuresByKind).toMatchObject({ transient: 1 }); - expect(current!.failingLeg).toBe("sleep"); + expect(current!.failingLegs).toEqual(["sleep"]); expect(current!.failingSinceAt!.getTime()).toBe(streakStart.getTime()); // Not advanced: it is what the card's "connected · X ago" reads, and the // sleep pipe has delivered nothing. @@ -280,7 +280,7 @@ describe("leg-scoped success", () => { const current = await row("withings"); expect(current!.state).toBe("connected"); expect(current!.lastError).toBeNull(); - expect(current!.failingLeg).toBeNull(); + expect(current!.failingLegs).toEqual([]); expect(current!.failingSinceAt).toBeNull(); expect(current!.lastSuccessAt).toBeInstanceOf(Date); expect(current!.consecutiveFailuresByKind).toMatchObject({ @@ -342,7 +342,7 @@ describe("leg-scoped success", () => { const current = await row("whoop"); expect(current!.state).toBe("connected"); expect(current!.lastError).toBeNull(); - expect(current!.failingLeg).toBeNull(); + expect(current!.failingLegs).toEqual([]); expect(current!.failingSinceAt).toBeNull(); }); @@ -356,3 +356,125 @@ describe("leg-scoped success", () => { expect(current!.failingSinceAt).toBeNull(); }); }); + +describe("two legs failing in overlapping windows", () => { + /** + * The case a single `failing_leg` slot could not hold, and the one the + * v1.33.0 notes named in public rather than fixed. + * + * Sleep fails, then activity fails. With one slot, activity's failure + * overwrote sleep's, so activity recovering cleared the row whole — the + * error, the buckets and the streak anchor — while sleep was still dead. The + * card went green over a data class that had stopped arriving weeks earlier, + * and the strike ladder restarted from zero every time the healthy leg ticked. + */ + it("remembers the earlier leg after the later one recovers", async () => { + await failTransient("withings", "sleep"); + const afterSleep = await row("withings"); + const streakStart = afterSleep!.failingSinceAt!; + + await failTransient("withings", "activity"); + // Both legs are on the row. The slot could only ever have held one. + expect((await row("withings"))!.failingLegs).toEqual(["sleep", "activity"]); + + // Activity comes back. Sleep has not. + await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "activity" }); + + const current = await row("withings"); + expect(current!.failingLegs).toEqual(["sleep"]); + // The error still stands, dated from sleep's first failure, with both + // strikes intact — this is the ladder that could never climb. + expect(current!.state).toBe("error_transient"); + expect(current!.failingSinceAt!.getTime()).toBe(streakStart.getTime()); + expect(current!.consecutiveFailuresByKind).toMatchObject({ transient: 2 }); + // Still nothing has been delivered by the sleep leg, so the field the card + // reads as "connected · X ago" must not have moved. + expect(current!.lastSuccessAt).toBeNull(); + }); + + it("drops the error message once the leg that wrote it recovers", async () => { + await failTransient("withings", "sleep"); + await recordSyncFailure({ + userId: TEST_USER_ID, + integration: "withings", + kind: "transient", + message: "activity write failed: upstream 503", + leg: "activity", + }); + // Activity failed last, so its message is the one the row holds. + expect((await row("withings"))!.lastError).not.toBeNull(); + + await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "activity" }); + + const current = await row("withings"); + // Keeping activity's message would leave the row describing a leg that has + // recovered while sleep is the one still failing. No message is honest + // here; a guessed one would not be. + expect(current!.lastError).toBeNull(); + expect(current!.failingLegs).toEqual(["sleep"]); + expect(current!.state).toBe("error_transient"); + }); + + it("keeps the message when a leg that did not write it recovers", async () => { + await recordSyncFailure({ + userId: TEST_USER_ID, + integration: "withings", + kind: "transient", + message: "activity write failed: upstream 503", + leg: "activity", + }); + await failTransient("withings", "sleep"); + const sleepMessage = (await row("withings"))!.lastError; + + // Activity recovers, but sleep failed last and owns the message. + await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "activity" }); + + const current = await row("withings"); + expect(current!.lastError).toBe(sleepMessage); + expect(current!.failingLegs).toEqual(["sleep"]); + }); + + it("clears the row only once every failing leg has come back", async () => { + await failTransient("withings", "sleep"); + await failTransient("withings", "activity"); + await failTransient("withings", "ecg"); + + await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "ecg" }); + await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "sleep" }); + expect((await row("withings"))!.state).toBe("error_transient"); + expect((await row("withings"))!.failingLegs).toEqual(["activity"]); + + await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "activity" }); + + const current = await row("withings"); + expect(current!.state).toBe("connected"); + expect(current!.failingLegs).toEqual([]); + expect(current!.failingSinceAt).toBeNull(); + expect(current!.lastError).toBeNull(); + expect(current!.lastSuccessAt).toBeInstanceOf(Date); + expect(current!.consecutiveFailuresByKind).toMatchObject({ + transient: 0, + reauth_required: 0, + persistent: 0, + }); + }); + + it("does not duplicate a leg that keeps failing", async () => { + await failTransient("withings", "sleep"); + await failTransient("withings", "activity"); + await failTransient("withings", "sleep"); + + // Sleep moves to the end because it wrote the message the row now holds. + expect((await row("withings"))!.failingLegs).toEqual(["activity", "sleep"]); + }); + + it("does not let an unattributed failure erase a named leg", async () => { + await failTransient("withings", "sleep"); + // A recorder that caught the error at a boundary and cannot say which leg + // it belongs to. Overwriting the set here would let the sleep leg's next + // success clear an error it did not fix. + await failTransient("withings"); + + expect((await row("withings"))!.failingLegs).toEqual(["sleep"]); + }); +});