/** * v1.4.15 Phase B2 — IntegrationStatus helper tests. * * The persistence path is exercised by the integration suite under * `tests/integration/integration-status.test.ts` (real Postgres so the * upsert + concurrent-update story holds). This file is the unit-level * suite for the threshold + alerting state machine — Prisma is mocked so * we control every input and observe the exact sequence of calls. */ import { describe, it, expect, vi, beforeEach } from "vitest"; const { addWarningMock } = vi.hoisted(() => ({ addWarningMock: vi.fn(), })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), getEvent: () => ({ addWarning: addWarningMock }), })); vi.mock("@/lib/db", () => ({ prisma: { $executeRaw: vi.fn(), integrationStatus: { findUnique: vi.fn(), upsert: vi.fn(), update: vi.fn(), }, user: { findUnique: vi.fn(), findMany: vi.fn(), }, integrationWatch: { findFirst: vi.fn(), }, }, })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/notifications/dispatcher", () => ({ dispatchNotification: vi.fn(), })); vi.mock("@/lib/crypto", () => ({ // Deterministic encrypt/decrypt for assertion stability. encrypt: (s: string) => `enc(${s})`, decrypt: (s: string) => s.startsWith("enc(") && s.endsWith(")") ? s.slice(4, -1) : s, })); import { getIntegrationStatus, recordSyncFailure, recordSyncSuccess, markReauthRequired, markDisconnected, markReconnected, isReauthRequired, getPersistentFailureThreshold, resumeIntegrationFromPark, markSyncFailureRecorded, isSyncFailureRecorded, } from "../status"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; beforeEach(() => { vi.resetAllMocks(); vi.mocked(dispatchNotification).mockResolvedValue({ dispatched: true, channelsAttempted: 1, channelsSucceeded: 1, }); delete process.env.INTEGRATION_FAILURE_ALERT_THRESHOLD; }); describe("getPersistentFailureThreshold", () => { it("defaults to 3 when env var is unset", () => { expect(getPersistentFailureThreshold()).toBe(3); }); it("respects INTEGRATION_FAILURE_ALERT_THRESHOLD when set to a positive integer", () => { process.env.INTEGRATION_FAILURE_ALERT_THRESHOLD = "5"; expect(getPersistentFailureThreshold()).toBe(5); }); it("falls back to 3 when env var is non-numeric", () => { process.env.INTEGRATION_FAILURE_ALERT_THRESHOLD = "many"; expect(getPersistentFailureThreshold()).toBe(3); }); it("falls back to 3 when env var is zero or negative", () => { process.env.INTEGRATION_FAILURE_ALERT_THRESHOLD = "0"; expect(getPersistentFailureThreshold()).toBe(3); process.env.INTEGRATION_FAILURE_ALERT_THRESHOLD = "-2"; expect(getPersistentFailureThreshold()).toBe(3); }); }); describe("recordSyncSuccess", () => { it("upserts the row with state=connected, resets streak, clears alertedAt", async () => { vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( {} as never, ); await recordSyncSuccess("u1", "withings"); const args = vi.mocked(prisma.integrationStatus.upsert).mock.calls[0][0]; expect(args.where).toEqual({ userId_integration: { userId: "u1", integration: "withings" }, }); expect(args.update).toEqual({ state: "connected", lastSuccessAt: expect.any(Date), lastAttemptAt: expect.any(Date), lastError: null, // v1.4.43 W14 — success clears every per-kind bucket so the // next failure starts from zero in its own bucket. // v1.4.47 W1 — the legacy `consecutiveFailures` column was // dropped (migration 0077); the bucket reset is the only // counter write now. consecutiveFailuresByKind: { transient: 0, reauth_required: 0, persistent: 0, }, persistentFailureStartedAt: null, failingSinceAt: null, failingLegs: [], alertedAt: null, }); }); }); describe("recordSyncFailure — under threshold", () => { it("increments the counter and does NOT dispatch an admin alert", async () => { vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( {} as never, ); await recordSyncFailure({ userId: "u1", integration: "withings", kind: "transient", message: "Withings refresh error: 503 - upstream", }); expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce(); expect(auditLog).toHaveBeenCalledWith( "integrations.sync.failed", expect.objectContaining({ userId: "u1", details: expect.objectContaining({ integration: "withings", attemptNumber: 1, state: "error_transient", }), }), ); expect(dispatchNotification).not.toHaveBeenCalled(); }); }); describe("recordSyncFailure — at threshold", () => { it("does not page a streak the watchdog already announced for the same connection", async () => { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ consecutiveFailuresByKind: { transient: 2, reauth_required: 0, persistent: 0, }, persistentFailureStartedAt: null, alertedAt: null, } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ alertedAt: null, } as never); vi.mocked(prisma.integrationWatch.findFirst).mockResolvedValueOnce({ id: "watch-1", } as never); await recordSyncFailure({ userId: "u1", integration: "withings", kind: "transient", message: "Withings refresh error: 503", errorCode: "503", }); expect(prisma.integrationWatch.findFirst).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ userId: "u1", integration: "withings", incidentReason: { not: null }, alertedAt: { not: null }, }), }), ); expect(dispatchNotification).not.toHaveBeenCalled(); // Stamped as announced, so later failures of the streak do not ask again. expect(prisma.integrationStatus.update).toHaveBeenCalledWith( expect.objectContaining({ data: { alertedAt: expect.any(Date) }, }), ); }); it("dispatches an admin Telegram alert when failure count reaches default threshold (3)", async () => { // v1.4.47 W1 — the alert ladder reads Math.max(...buckets) so the // existing row must seed the bucket at N-1 before the in-memory // increment produces alertSignal = N. vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ consecutiveFailuresByKind: { transient: 2, reauth_required: 0, persistent: 0, }, persistentFailureStartedAt: null, alertedAt: null, } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ alertedAt: null, } as never); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ email: "user@example.com", } as never); vi.mocked(prisma.user.findMany).mockResolvedValueOnce([ { id: "admin-1" }, { id: "admin-2" }, ] as never); vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce( {} as never, ); await recordSyncFailure({ userId: "u1", integration: "withings", kind: "transient", message: "Withings refresh error: 503 - upstream service down", errorCode: "503", }); expect(dispatchNotification).toHaveBeenCalledTimes(2); const firstCallPayload = vi.mocked(dispatchNotification).mock.calls[0][0]; expect(firstCallPayload.eventType).toBe("SYSTEM_ALERT"); expect(firstCallPayload.userId).toBe("admin-1"); expect(firstCallPayload.title).toContain( "Withings sync failing for user@example.com", ); expect(firstCallPayload.message).toContain("3 times in a row"); expect(firstCallPayload.message).toContain("503"); expect(firstCallPayload.metadata).toEqual({ integration: "withings", affectedUserId: "u1", consecutiveFailures: 3, errorCode: "503", // The affected user is not the admin, so the tap opens the admin page. url: "/admin/integrations", }); // alertedAt is stamped to gate re-alerting for 24h. expect(prisma.integrationStatus.update).toHaveBeenCalledWith( expect.objectContaining({ where: { userId_integration: { userId: "u1", integration: "withings" }, }, data: expect.objectContaining({ alertedAt: expect.any(Date) }), }), ); }); it("does not stamp alertedAt when no admin channel is available", async () => { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ consecutiveFailuresByKind: { transient: 2, reauth_required: 0, persistent: 0, }, persistentFailureStartedAt: null, alertedAt: null, } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ alertedAt: null, } as never); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ email: "user@example.com", } as never); vi.mocked(prisma.user.findMany).mockResolvedValueOnce([ { id: "admin-1" }, ] as never); vi.mocked(dispatchNotification).mockResolvedValueOnce({ dispatched: false, channelsAttempted: 0, channelsSucceeded: 0, }); await recordSyncFailure({ userId: "u1", integration: "withings", kind: "transient", message: "Withings refresh error: 503 - upstream service down", errorCode: "503", }); expect(dispatchNotification).toHaveBeenCalledOnce(); expect(prisma.integrationStatus.update).not.toHaveBeenCalled(); expect(addWarningMock).toHaveBeenCalledWith( "No admin notification channel available for withings failure alert", ); }); it("stamps alertedAt after an attempted delivery that failed", async () => { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ consecutiveFailuresByKind: { transient: 2, reauth_required: 0, persistent: 0, }, persistentFailureStartedAt: null, alertedAt: null, } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ alertedAt: null, } as never); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ email: "user@example.com", } as never); vi.mocked(prisma.user.findMany).mockResolvedValueOnce([ { id: "admin-1" }, ] as never); vi.mocked(dispatchNotification).mockResolvedValueOnce({ dispatched: false, channelsAttempted: 1, channelsSucceeded: 0, }); vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce( {} as never, ); await recordSyncFailure({ userId: "u1", integration: "withings", kind: "transient", message: "Withings refresh error: 503 - upstream service down", errorCode: "503", }); expect(prisma.integrationStatus.update).toHaveBeenCalledWith({ where: { userId_integration: { userId: "u1", integration: "withings" }, }, data: { alertedAt: expect.any(Date) }, }); }); it("uses the custom threshold from INTEGRATION_FAILURE_ALERT_THRESHOLD", async () => { process.env.INTEGRATION_FAILURE_ALERT_THRESHOLD = "5"; // 4 < 5 → should not page yet. Seed the existing bucket at 3 so // the in-memory increment lands at 4. vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ consecutiveFailuresByKind: { transient: 3, reauth_required: 0, persistent: 0, }, persistentFailureStartedAt: null, alertedAt: null, } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ alertedAt: null, } as never); await recordSyncFailure({ userId: "u1", integration: "nightscout", kind: "transient", message: "Nightscout sync HTTP 502", }); expect(dispatchNotification).not.toHaveBeenCalled(); }); }); describe("recordSyncFailure — alert window", () => { it("does NOT dispatch a second alert within 24h on the same streak", async () => { const now = Date.now(); // Existing bucket at 3 → in-memory increment lands at 4, above // the default 3-strike threshold. The 24h alertedAt guard must // suppress the duplicate page. vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ consecutiveFailuresByKind: { transient: 3, reauth_required: 0, persistent: 0, }, persistentFailureStartedAt: null, alertedAt: new Date(now - 60 * 60 * 1000), // 1h ago } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ alertedAt: new Date(now - 60 * 60 * 1000), } as never); await recordSyncFailure({ userId: "u1", integration: "withings", kind: "transient", message: "Withings refresh error: 503 - upstream", }); expect(dispatchNotification).not.toHaveBeenCalled(); }); it("does NOT re-page the same streak once 24h have elapsed", async () => { // One page per streak. `alertedAt` is cleared by every path that ends a // streak, so a stamped value means this streak was already announced, // however long ago. The daily re-page this test used to pin turned a // week-long outage into seven identical messages; the integration // watchdog owns the rest of the incident, including its recovery note. vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ consecutiveFailuresByKind: { transient: 5, reauth_required: 0, persistent: 0, }, persistentFailureStartedAt: null, alertedAt: new Date(Date.now() - 25 * 60 * 60 * 1000), // 25h ago } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ alertedAt: new Date(Date.now() - 25 * 60 * 60 * 1000), } as never); await recordSyncFailure({ userId: "u1", integration: "withings", kind: "transient", message: "Withings refresh error: 503 - upstream", }); expect(dispatchNotification).not.toHaveBeenCalled(); }); }); describe("recordSyncFailure — reauth_required", () => { it("marks state=error_reauth and audits with kind=reauth_required", async () => { vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( {} as never, ); await recordSyncFailure({ userId: "u1", integration: "withings", kind: "reauth_required", message: "Withings refresh error: 100 - invalid_grant", errorCode: "100", }); const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock .calls[0][0]; expect(upsertArgs.update).toMatchObject({ state: "error_reauth" }); expect(auditLog).toHaveBeenCalledWith( "integrations.sync.failed", expect.objectContaining({ details: expect.objectContaining({ kind: "reauth_required", errorCode: "100", }), }), ); }); }); describe("recordSyncFailure — persistent (v1.4.42 W6)", () => { it("marks state=error_transient (next sync still runs) and audits with kind=persistent", async () => { // Contract-mismatch failures (Withings 293 invalid params) MUST // surface in the audit log with kind=persistent so operations can // grep for upstream contract bugs, but they MUST NOT park the // integration — the next sync should still run because the // mismatch can be one-sided (e.g. Withings introducing a new // required field that the client release fixes). vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( {} as never, ); await recordSyncFailure({ userId: "u1", integration: "withings", kind: "persistent", message: "Withings measure error: 293 - invalid params", errorCode: "293", }); const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock .calls[0][0]; expect(upsertArgs.update).toMatchObject({ state: "error_transient" }); expect(auditLog).toHaveBeenCalledWith( "integrations.sync.failed", expect.objectContaining({ details: expect.objectContaining({ kind: "persistent", errorCode: "293", }), }), ); }); it("at threshold, the admin alert message includes the 'persistent error' label", async () => { // Seed persistent bucket at 2 → in-memory increment lands at 3, // hitting the default threshold. vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ consecutiveFailuresByKind: { transient: 0, reauth_required: 0, persistent: 2, }, persistentFailureStartedAt: new Date(Date.now() - 60 * 60 * 1000), alertedAt: null, } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ alertedAt: null, } as never); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ email: "user@example.com", } as never); vi.mocked(prisma.user.findMany).mockResolvedValueOnce([ { id: "admin-1" }, ] as never); vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce( {} as never, ); await recordSyncFailure({ userId: "u1", integration: "withings", kind: "persistent", message: "Withings activity error: 293", errorCode: "293", }); expect(dispatchNotification).toHaveBeenCalledOnce(); const payload = vi.mocked(dispatchNotification).mock.calls[0][0]; expect(payload.message).toContain("persistent error"); expect(payload.message).toContain( "Action: investigate the upstream contract", ); }); }); describe("recordSyncFailure — no admin recipients", () => { it("surfaces the unavailable delivery and leaves alertedAt unstamped", async () => { // Seed bucket at 2 so the in-memory increment hits the threshold // and the no-admins branch is the one being exercised. vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ consecutiveFailuresByKind: { transient: 2, reauth_required: 0, persistent: 0, }, persistentFailureStartedAt: null, alertedAt: null, } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ alertedAt: null, } as never); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ email: "u@example.com", } as never); vi.mocked(prisma.user.findMany).mockResolvedValueOnce([] as never); await expect( recordSyncFailure({ userId: "u1", integration: "withings", kind: "transient", message: "Withings refresh error: 503 - upstream", }), ).resolves.toBeUndefined(); expect(dispatchNotification).not.toHaveBeenCalled(); expect(prisma.integrationStatus.update).not.toHaveBeenCalled(); expect(addWarningMock).toHaveBeenCalledWith( "No admin notification channel available for withings failure alert", ); }); }); describe("isReauthRequired", () => { it("returns true when state === error_reauth", async () => { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ state: "error_reauth", } as never); expect(await isReauthRequired("u1", "withings")).toBe(true); }); it("returns true when state === parked (v1.4.43 W14)", async () => { // A parked integration must short-circuit the sync entry-point // the same way a reauth-required one does — neither will succeed // without user / operator action. vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ state: "parked", } as never); expect(await isReauthRequired("u1", "withings")).toBe(true); }); it("returns false for transient errors and absent rows", async () => { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ state: "error_transient", } as never); expect(await isReauthRequired("u1", "withings")).toBe(false); vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce(null); expect(await isReauthRequired("u1", "withings")).toBe(false); }); }); describe("markReauthRequired / markDisconnected / markReconnected", () => { it("markReauthRequired: writes encrypted error + audit entry", async () => { vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( {} as never, ); await markReauthRequired("u1", "withings", "invalid grant"); expect(prisma.integrationStatus.upsert).toHaveBeenCalled(); expect(auditLog).toHaveBeenCalledWith( "integrations.reauth_required", expect.objectContaining({ userId: "u1" }), ); }); it("markDisconnected: state=disconnected, clears errors & alertedAt", async () => { vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( {} as never, ); await markDisconnected("u1", "nightscout"); const args = vi.mocked(prisma.integrationStatus.upsert).mock.calls[0][0]; expect(args.update).toEqual({ state: "disconnected", lastError: null, // v1.4.43 W14 — disconnect must clear the per-kind buckets too. // v1.4.47 W1 — legacy `consecutiveFailures` column dropped // (migration 0077); the bucket reset is the only counter write. consecutiveFailuresByKind: { transient: 0, reauth_required: 0, persistent: 0, }, persistentFailureStartedAt: null, failingSinceAt: null, failingLegs: [], alertedAt: null, }); }); it("markReconnected: state=connected, clears prior reauth state", async () => { vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( {} as never, ); await markReconnected("u1", "withings"); const args = vi.mocked(prisma.integrationStatus.upsert).mock.calls[0][0]; expect(args.update).toEqual({ state: "connected", lastError: null, // v1.4.43 W14 — reconnect clears the per-kind buckets so the // next sync starts on a clean slate. // v1.4.47 W1 — legacy `consecutiveFailures` column dropped // (migration 0077); the bucket reset is the only counter write. consecutiveFailuresByKind: { transient: 0, reauth_required: 0, persistent: 0, }, persistentFailureStartedAt: null, failingSinceAt: null, failingLegs: [], alertedAt: null, }); }); }); // ── v1.4.43 W14 — per-kind buckets + 24h park behaviour ──────────────── // // The four pins below lock in the B4 + B7 contract: // // 1. A persistent failure increments ONLY the persistent bucket; // an intervening transient failure does NOT reset the persistent // streak. // 2. The persistent-streak start timestamp is stamped on the FIRST // persistent failure of a streak and preserved across subsequent // persistent failures. // 3. Once the persistent streak has been running for >24h, the state // flips to `parked` and an `integrations.parked` audit row is // written. // 4. `resumeIntegrationFromPark` clears state back to `connected`, // zeroes every bucket, and writes an `integrations.resumed` audit // row (only when the row was actually parked — idempotent for // non-parked inputs). describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => { it("increments ONLY the matching bucket on each failure", async () => { // Existing row: transient bucket already at 2 from earlier hiccups. vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ consecutiveFailuresByKind: { transient: 2, reauth_required: 0, persistent: 0, }, persistentFailureStartedAt: null, alertedAt: null, } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ alertedAt: null, } as never); await recordSyncFailure({ userId: "u1", integration: "withings", kind: "persistent", message: "Withings measure error: 293 - invalid params", errorCode: "293", }); const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock .calls[0][0]; // transient stays at 2 (unchanged), persistent ticks to 1, // reauth_required stays at 0. expect(upsertArgs.update.consecutiveFailuresByKind).toEqual({ transient: 2, reauth_required: 0, persistent: 1, }); // First-ever persistent failure stamps the streak anchor. expect(upsertArgs.update.persistentFailureStartedAt).toBeInstanceOf(Date); }); it("preserves persistentFailureStartedAt across multiple persistent failures", async () => { const streakStart = new Date(Date.now() - 60 * 60 * 1000); // 1h ago vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ consecutiveFailuresByKind: { transient: 0, reauth_required: 0, persistent: 5, }, persistentFailureStartedAt: streakStart, alertedAt: new Date(Date.now() - 30 * 60 * 1000), // already alerted } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ alertedAt: new Date(Date.now() - 30 * 60 * 1000), } as never); await recordSyncFailure({ userId: "u1", integration: "withings", kind: "persistent", message: "Withings measure error: 293", errorCode: "293", }); const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock .calls[0][0]; // The anchor is preserved exactly — that's the whole point of // tracking the streak start instead of recomputing it from // updated_at. expect(upsertArgs.update.persistentFailureStartedAt).toBe(streakStart); expect(upsertArgs.update.state).toBe("error_transient"); // < 24h still expect(upsertArgs.update.consecutiveFailuresByKind).toEqual({ transient: 0, reauth_required: 0, persistent: 6, }); }); it("a transient failure does NOT reset the persistent bucket nor the streak anchor", async () => { const streakStart = new Date(Date.now() - 6 * 60 * 60 * 1000); // 6h ago vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ consecutiveFailuresByKind: { transient: 0, reauth_required: 0, persistent: 4, }, persistentFailureStartedAt: streakStart, alertedAt: null, } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ alertedAt: null, } as never); await recordSyncFailure({ userId: "u1", integration: "withings", kind: "transient", message: "Withings refresh error: 503", errorCode: "503", }); const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock .calls[0][0]; // Persistent bucket UNCHANGED — this is the v1.4.43 W14 fix. // Under the legacy single-counter behaviour the burst above would // have shown as "5 consecutive failures, state=error_transient" // and the persistent streak's age would have been masked. expect(upsertArgs.update.consecutiveFailuresByKind).toEqual({ transient: 1, reauth_required: 0, persistent: 4, }); expect(upsertArgs.update.persistentFailureStartedAt).toBe(streakStart); }); it("flips state to `parked` and writes an audit row once persistent streak > 24h", async () => { const streakStart = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25h ago vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ consecutiveFailuresByKind: { transient: 0, reauth_required: 0, persistent: 30, }, persistentFailureStartedAt: streakStart, alertedAt: new Date(Date.now() - 23 * 60 * 60 * 1000), } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ alertedAt: new Date(Date.now() - 23 * 60 * 60 * 1000), } as never); await recordSyncFailure({ userId: "u1", integration: "withings", kind: "persistent", message: "Withings measure error: 293", errorCode: "293", }); const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock .calls[0][0]; expect(upsertArgs.update.state).toBe("parked"); expect(auditLog).toHaveBeenCalledWith( "integrations.parked", expect.objectContaining({ userId: "u1", details: expect.objectContaining({ integration: "withings", reason: "persistent_24h", persistentFailureStartedAt: streakStart.toISOString(), }), }), ); }); it("seeds a fresh zero envelope when the row has no bucket payload yet", async () => { // v1.4.47 W1 — the legacy `consecutiveFailures` column was dropped // (migration 0077). A row that has never written a bucket payload // (`consecutiveFailuresByKind: null`) starts from a zero envelope // on the next failure; the matching bucket ticks to 1 in a single // write. vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ consecutiveFailuresByKind: null, persistentFailureStartedAt: null, alertedAt: null, } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( {} as never, ); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ email: "u@example.com", } as never); vi.mocked(prisma.user.findMany).mockResolvedValueOnce([] as never); vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce( {} as never, ); await recordSyncFailure({ userId: "u1", integration: "withings", kind: "reauth_required", message: "Withings refresh error: 100 - invalid_grant", errorCode: "100", }); const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock .calls[0][0]; expect(upsertArgs.update.consecutiveFailuresByKind).toEqual({ transient: 0, reauth_required: 1, persistent: 0, }); }); }); // ── v1.4.47 W1 — migration 0077 down-script reversibility ────────────── // // Migration 0077 drops `consecutive_failures` and documents a down-script // that restores the legacy column via // // SET "consecutive_failures" = COALESCE( // GREATEST( // ("consecutive_failures_by_kind"->>'transient')::int, // ("consecutive_failures_by_kind"->>'reauth_required')::int, // ("consecutive_failures_by_kind"->>'persistent')::int // ), // 0 // ) // // The recipe maps a bucket payload to a single integer using the SAME // `Math.max(...)` reducer the live alert ladder uses, so a rollback // lands every row at the post-v1.4.43 counter value (= the running // alert signal). This test locks in that contract at the JS level so // any future tweak to the SQL recipe has to match the in-memory // behaviour the writer and the alert ladder share. describe("v1.4.47 W1 — down-script restores legacy consecutiveFailures via Math.max", () => { /** * In-memory mirror of the migration's down-script projection. Returns * the integer the legacy `consecutive_failures` column would land at * for a given bucket payload. Kept inline so the test file is the * single source of truth for the recipe. */ function projectLegacyCounter( buckets: { transient: number; reauth_required: number; persistent: number; } | null, ): number { if (!buckets) return 0; return Math.max( buckets.transient, buckets.reauth_required, buckets.persistent, ); } it("zero buckets map to 0 (post-success or fresh row)", () => { expect( projectLegacyCounter({ transient: 0, reauth_required: 0, persistent: 0 }), ).toBe(0); }); it("a NULL bucket payload coerces to 0 (rollback against a never-written row)", () => { expect(projectLegacyCounter(null)).toBe(0); }); it("picks the max across kinds — a 28-deep persistent streak rolls back to 28", () => { // The v1.4.43 W14 production scenario: persistent burst at 28, a // single transient hiccup mid-streak at 2, no reauth. The legacy // column tracked the running streak total and would have been at // 28 (no live transient-driven increment after the persistent // streak anchored). The down-script restores the same 28. expect( projectLegacyCounter({ transient: 2, reauth_required: 0, persistent: 28, }), ).toBe(28); }); it("matches the live alert ladder's reducer (Math.max over Object.values)", () => { // The whole point of the recipe: a rollback restores the exact // integer the live alert ladder reads. If `recordSyncFailure` // pages on alertSignal = 7 today, the rolled-back column lands // at 7 too. const buckets = { transient: 1, reauth_required: 7, persistent: 3 }; const liveSignal = Math.max(...Object.values(buckets)); expect(projectLegacyCounter(buckets)).toBe(liveSignal); }); }); describe("resumeIntegrationFromPark", () => { it("clears a parked row back to connected with all buckets zeroed", async () => { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ state: "parked", } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( {} as never, ); const result = await resumeIntegrationFromPark("u1", "withings"); expect(result.wasParked).toBe(true); const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock .calls[0][0]; expect(upsertArgs.update).toEqual({ state: "connected", lastError: null, // v1.4.47 W1 — legacy `consecutiveFailures` column dropped // (migration 0077); the bucket reset is the only counter write. consecutiveFailuresByKind: { transient: 0, reauth_required: 0, persistent: 0, }, persistentFailureStartedAt: null, failingSinceAt: null, failingLegs: [], alertedAt: null, }); expect(auditLog).toHaveBeenCalledWith( "integrations.resumed", expect.objectContaining({ userId: "u1", details: expect.objectContaining({ integration: "withings" }), }), ); }); it("is idempotent — calling against a non-parked row does NOT write an audit row", async () => { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ state: "connected", } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( {} as never, ); const result = await resumeIntegrationFromPark("u1", "withings"); expect(result.wasParked).toBe(false); expect(auditLog).not.toHaveBeenCalled(); }); }); describe("sync-failure-recorded marker (§5)", () => { it("round-trips: a marked error reads back as recorded", () => { const err = new Error("boom"); expect(isSyncFailureRecorded(err)).toBe(false); const returned = markSyncFailureRecorded(err); // Returns the SAME object so `throw markSyncFailureRecorded(err)` works. expect(returned).toBe(err); expect(isSyncFailureRecorded(err)).toBe(true); }); it("the marker is non-enumerable (never serialised into logs / JSON)", () => { const err = markSyncFailureRecorded(new Error("boom")); expect(Object.keys(err)).not.toContain("healthlog.syncFailureRecorded"); expect(JSON.stringify({ ...err })).not.toContain("syncFailureRecorded"); }); it("is a no-op on non-objects and never reports them as recorded", () => { expect(markSyncFailureRecorded("boom")).toBe("boom"); expect(markSyncFailureRecorded(null)).toBe(null); expect(markSyncFailureRecorded(undefined)).toBe(undefined); expect(isSyncFailureRecorded("boom")).toBe(false); expect(isSyncFailureRecorded(null)).toBe(false); expect(isSyncFailureRecorded(undefined)).toBe(false); }); it("tolerates a frozen error without throwing (falls back to unmarked)", () => { const frozen = Object.freeze(new Error("frozen")); expect(() => markSyncFailureRecorded(frozen)).not.toThrow(); // Frozen can't carry the marker, so the boundary records it once — safe. expect(isSyncFailureRecorded(frozen)).toBe(false); }); }); describe("transient-streak escalation", () => { const DAY_MS = 24 * 60 * 60 * 1000; /** Seed the row the escalation reads: a streak of `transient` of a given age. */ function seedStreak(opts: { transient: number; ageMs: number }) { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ consecutiveFailuresByKind: { transient: opts.transient, reauth_required: 0, persistent: 0, }, persistentFailureStartedAt: null, failingSinceAt: new Date(Date.now() - opts.ageMs), alertedAt: null, } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ alertedAt: new Date(), } as never); vi.mocked(prisma.user.findMany).mockResolvedValue([] as never); } function upsertUpdate() { return vi.mocked(prisma.integrationStatus.upsert).mock.calls[0]![0]! .update as Record; } it("records a transient failure as persistent once the streak outlives the window", async () => { seedStreak({ transient: 3, ageMs: DAY_MS + 60_000 }); await recordSyncFailure({ userId: "u1", integration: "withings", kind: "transient", message: "Withings measure error: 503 - upstream", }); // The transient bucket is frozen where the window closed; the persistent // bucket — the only one the park ladder reads — starts counting. expect(upsertUpdate().consecutiveFailuresByKind).toEqual({ transient: 3, reauth_required: 0, persistent: 1, }); expect(upsertUpdate().persistentFailureStartedAt).toBeInstanceOf(Date); expect(auditLog).toHaveBeenCalledWith( "integrations.sync.failed", expect.objectContaining({ details: expect.objectContaining({ kind: "persistent", escalatedFrom: "transient", }), }), ); }); it("leaves a streak inside the window alone", async () => { seedStreak({ transient: 9, ageMs: DAY_MS - 60_000 }); await recordSyncFailure({ userId: "u1", integration: "withings", kind: "transient", message: "Withings measure error: 503 - upstream", }); expect(upsertUpdate().consecutiveFailuresByKind).toEqual({ transient: 10, reauth_required: 0, persistent: 0, }); expect(upsertUpdate().persistentFailureStartedAt).toBeNull(); expect(auditLog).toHaveBeenCalledWith( "integrations.sync.failed", expect.objectContaining({ details: expect.objectContaining({ kind: "transient", escalatedFrom: null, }), }), ); }); it("needs the consecutive-failure threshold as well as the clock", async () => { // A provider polled once a day: old streak, but only two attempts in it. seedStreak({ transient: 1, ageMs: 5 * DAY_MS }); await recordSyncFailure({ userId: "u1", integration: "nightscout", kind: "transient", message: "Nightscout sync HTTP 502", }); expect(upsertUpdate().consecutiveFailuresByKind).toEqual({ transient: 2, reauth_required: 0, persistent: 0, }); expect(upsertUpdate().persistentFailureStartedAt).toBeNull(); }); it("dates a fresh streak from this failure and never re-dates a running one", async () => { const anchor = new Date(Date.now() - 3 * DAY_MS); vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ consecutiveFailuresByKind: { transient: 1, reauth_required: 0, persistent: 0, }, persistentFailureStartedAt: null, failingSinceAt: anchor, alertedAt: new Date(), } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ alertedAt: new Date(), } as never); await recordSyncFailure({ userId: "u1", integration: "withings", kind: "transient", message: "Withings measure error: 503 - upstream", }); expect(upsertUpdate().failingSinceAt).toEqual(anchor); }); }); describe("recordSyncSuccess — leg scoping", () => { it("leaves a sibling leg's error standing and only records the attempt", async () => { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ state: "error_transient", failingLegs: ["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. // `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", failingLegs: ["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, 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", failingLegs: [], } 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", failingLegs: ["sleep"], } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( {} as never, ); await recordSyncSuccess("u1", "withings", { leg: "ecg" }); expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce(); }); it("drops a non-string member rather than coercing it into a leg name", async () => { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ state: "error_transient", failingLegs: ["sleep", 42, null], } as never); vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce( {} as never, ); await recordSyncSuccess("u1", "withings", { leg: "ecg" }); const args = vi.mocked(prisma.integrationStatus.update).mock.calls[0]![0]!; // Coercing `42` into `"42"` would invent a leg no caller can ever succeed // for, holding the row in error against a failure nobody recorded. expect(args.data).toMatchObject({ failingLegs: ["sleep"] }); }); 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"]); }); }); /** * v1.38.19 — the synthetic "no row" snapshot. * * `state: "connected"` used to be the value an account with no sync history * read back, which made the ledger field's default mean the opposite of its * name: a provider nobody had ever connected published "connected" on the * envelope, and the setup flow's connect step believed it. The honest value * for "there is no history here" is `unknown`; `syncHealth.verdict` stays the * only liveness truth. */ describe("getIntegrationStatus with no ledger row", () => { it("reads as unknown, not as connected", async () => { vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce( null as never, ); const snapshot = await getIntegrationStatus("u1", "whoop"); expect(snapshot.state).toBe("unknown"); expect(snapshot.lastSuccessAt).toBeNull(); expect(snapshot.lastAttemptAt).toBeNull(); }); });