test(integrations): pin the escalation ladder, leg-scoped clearing, and what the card says

Mutation-checked against the production paths they cover: the escalation
verdict, the leg guard in `recordSyncSuccess`, the streak anchor the verdict
dates itself by, the status envelope threading it, and the pill's relative
timestamp.

Also give the four legacy per-provider status routes the verdict the
consolidated envelope already carries. They answered `connected: true` plus a
`state` string and nothing else, so a client reading one of them saw a live
connection over a pipe that had delivered nothing for weeks.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 14:19:13 +02:00
1 parent 527dd6c1fa
commit 4e3b029c38
9 files changed
+443 -15

No files matched your search

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