From 76e62283fa38273e866c7711412ffcb1096ec00b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 22 May 2026 08:59:46 +0200 Subject: [PATCH] test(integrations): align status tests with dropped legacy counter + lock in down-script recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five test surfaces updated to match the v1.4.47 W1 drop: - `src/lib/integrations/__tests__/status.test.ts` * `recordSyncSuccess`, `markDisconnected`, `markReconnected`, `resumeIntegrationFromPark` upsert assertions drop the `consecutiveFailures: 0` field. * Alert-ladder tests now seed the existing-row mock with a bucket payload at N-1 so the in-memory increment lands at N — the writer no longer consults the upsert return's `consecutiveFailures` for the threshold check. * The "back-fills from legacy single counter" case is replaced with a "seeds a fresh zero envelope when the row has no bucket payload yet" case (the back-fill path is gone). * New `down-script restores legacy consecutiveFailures via Math.max` block locks in the migration 0076 rollback recipe at the JS level: an in-memory `projectLegacyCounter` mirrors the SQL `GREATEST(transient, reauth_required, persistent)` projection and is asserted against zero, null, the 28-deep production-shape persistent streak, and parity with the live alert ladder's `Math.max(...Object.values(buckets))`. * Stale `consecutiveFailures: N` properties on Prisma mock returns are stripped (the writer no longer reads them). - `src/lib/integrations/__tests__/admin-alert.test.ts` * `parkIntegrationAtReauth` "creates a fresh row at counter=0" now asserts the zero bucket envelope rather than the dropped `consecutiveFailures: 0` literal. * The "not.toHaveProperty consecutiveFailures" assertion gains a sibling for `consecutiveFailuresByKind` — the bucket is the live counter now, and `parkIntegrationAtReauth` must still leave it untouched on a re-park. - `src/components/settings/__tests__/integrations-section.test.tsx` * The six fixture envelopes drop the now-removed `consecutiveFailures: N` field (the view-model dropped the property in this release). - `tests/integration/integration-status.test.ts` * Five `row.consecutiveFailures` assertions are migrated to `row.consecutiveFailuresByKind` matchers carrying the same counter value bucketed by failure kind. * The parked-state fixture's `integrationStatus.create` call no longer sets the dropped column. --- .../__tests__/integrations-section.test.tsx | 12 - .../__tests__/admin-alert.test.ts | 14 +- src/lib/integrations/__tests__/status.test.ts | 221 ++++++++++++++---- tests/integration/integration-status.test.ts | 30 ++- 4 files changed, 211 insertions(+), 66 deletions(-) diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx index fc8193e9d..605b5a54c 100644 --- a/src/components/settings/__tests__/integrations-section.test.tsx +++ b/src/components/settings/__tests__/integrations-section.test.tsx @@ -139,7 +139,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: "2026-05-09T18:00:00.000Z", lastAttemptAt: "2026-05-09T18:00:00.000Z", lastError: null, - consecutiveFailures: 0, }, { integration: "moodlog", @@ -147,7 +146,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: "2026-05-09T17:00:00.000Z", lastAttemptAt: "2026-05-09T17:00:00.000Z", lastError: null, - consecutiveFailures: 0, }, ], }); @@ -187,7 +185,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: "2026-05-09T08:00:00.000Z", lastAttemptAt: "2026-05-09T18:00:00.000Z", lastError: "Withings refresh error: 503 - upstream", - consecutiveFailures: 2, }, { integration: "moodlog", @@ -195,7 +192,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: null, lastAttemptAt: null, lastError: null, - consecutiveFailures: 0, }, ], }); @@ -229,7 +225,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: "2026-05-08T12:00:00.000Z", lastAttemptAt: "2026-05-09T18:00:00.000Z", lastError: "Withings refresh error: 100 - invalid_grant", - consecutiveFailures: 1, }, { integration: "moodlog", @@ -237,7 +232,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: null, lastAttemptAt: null, lastError: null, - consecutiveFailures: 0, }, ], }); @@ -265,7 +259,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: null, lastAttemptAt: null, lastError: null, - consecutiveFailures: 0, }, { integration: "moodlog", @@ -273,7 +266,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: "2026-05-08T10:00:00.000Z", lastAttemptAt: "2026-05-08T10:00:00.000Z", lastError: null, - consecutiveFailures: 0, }, ], }); @@ -300,7 +292,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: "2026-05-08T12:00:00.000Z", lastAttemptAt: "2026-05-09T18:00:00.000Z", lastError: "Withings activity error: 293", - consecutiveFailures: 30, }, { integration: "moodlog", @@ -308,7 +299,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: null, lastAttemptAt: null, lastError: null, - consecutiveFailures: 0, }, ], }); @@ -339,7 +329,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: "2026-05-09T18:00:00.000Z", lastAttemptAt: "2026-05-09T18:00:00.000Z", lastError: null, - consecutiveFailures: 0, }, { integration: "moodlog", @@ -347,7 +336,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: "2026-05-09T17:00:00.000Z", lastAttemptAt: "2026-05-09T17:00:00.000Z", lastError: null, - consecutiveFailures: 0, }, ], }); diff --git a/src/lib/integrations/__tests__/admin-alert.test.ts b/src/lib/integrations/__tests__/admin-alert.test.ts index 36354eb21..846e149e7 100644 --- a/src/lib/integrations/__tests__/admin-alert.test.ts +++ b/src/lib/integrations/__tests__/admin-alert.test.ts @@ -218,7 +218,12 @@ describe("parkIntegrationAtReauth — silent scope-skip park (v1.4.27 F20)", () lastError: "enc(Withings connection is missing the user.activity scope.)", }); // The whole point of the helper: counter is not in the update set. + // v1.4.47 W1 — the legacy `consecutiveFailures` column was dropped + // (migration 0076); the per-kind bucket is the live counter now. + // Neither field appears in the update payload — the existing + // bucket values are preserved exactly. expect(upsertArgs.update).not.toHaveProperty("consecutiveFailures"); + expect(upsertArgs.update).not.toHaveProperty("consecutiveFailuresByKind"); // No admin alert fired. expect(dispatchNotification).not.toHaveBeenCalled(); @@ -272,6 +277,9 @@ describe("parkIntegrationAtReauth — silent scope-skip park (v1.4.27 F20)", () // First-ever sync attempt on a legacy connection: no row in the // table. Helper must create the row at counter=0 so a later // genuine transient burst still has the full 3-strike runway. + // v1.4.47 W1 — counter is now the per-kind bucket payload (the + // legacy `consecutiveFailures` column was dropped in migration + // 0076); a zero envelope is the equivalent seed. vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce(null); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( {} as never, @@ -290,7 +298,11 @@ describe("parkIntegrationAtReauth — silent scope-skip park (v1.4.27 F20)", () userId: "u-new", integration: "withings", state: "error_reauth", - consecutiveFailures: 0, + consecutiveFailuresByKind: { + transient: 0, + reauth_required: 0, + persistent: 0, + }, }); expect(dispatchNotification).not.toHaveBeenCalled(); }); diff --git a/src/lib/integrations/__tests__/status.test.ts b/src/lib/integrations/__tests__/status.test.ts index 3d91d6d0e..4f7a5ae80 100644 --- a/src/lib/integrations/__tests__/status.test.ts +++ b/src/lib/integrations/__tests__/status.test.ts @@ -95,9 +95,11 @@ describe("recordSyncSuccess", () => { lastSuccessAt: expect.any(Date), lastAttemptAt: expect.any(Date), lastError: null, - consecutiveFailures: 0, - // v1.4.43 W14 — success clears every per-kind bucket too so - // the next failure starts from zero in its own bucket. + // 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 0076); the bucket reset is the only + // counter write now. consecutiveFailuresByKind: { transient: 0, reauth_required: 0, @@ -111,9 +113,9 @@ describe("recordSyncSuccess", () => { describe("recordSyncFailure — under threshold", () => { it("increments the counter and does NOT dispatch an admin alert", async () => { - vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ - consecutiveFailures: 1, - } as never); + vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( + {} as never, + ); await recordSyncFailure({ userId: "u1", @@ -140,8 +142,19 @@ describe("recordSyncFailure — under threshold", () => { describe("recordSyncFailure — at threshold", () => { 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({ - consecutiveFailures: 3, alertedAt: null, } as never); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ @@ -192,9 +205,18 @@ describe("recordSyncFailure — at threshold", () => { it("uses the custom threshold from INTEGRATION_FAILURE_ALERT_THRESHOLD", async () => { process.env.INTEGRATION_FAILURE_ALERT_THRESHOLD = "5"; - // 4 < 5 → should not page yet + // 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({ - consecutiveFailures: 4, alertedAt: null, } as never); await recordSyncFailure({ @@ -210,10 +232,21 @@ describe("recordSyncFailure — at threshold", () => { describe("recordSyncFailure — alert window", () => { it("does NOT dispatch a second alert within 24h on the same streak", async () => { const now = Date.now(); - vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ - consecutiveFailures: 4, + // 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", @@ -226,10 +259,19 @@ describe("recordSyncFailure — alert window", () => { }); it("DOES dispatch a fresh alert after 24h have elapsed", async () => { - vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ - consecutiveFailures: 6, + // Existing bucket at 5 → in-memory increment lands at 6. + 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); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ email: "u@example.com", } as never); @@ -253,9 +295,9 @@ describe("recordSyncFailure — alert window", () => { describe("recordSyncFailure — reauth_required", () => { it("marks state=error_reauth and audits with kind=reauth_required", async () => { - vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ - consecutiveFailures: 1, - } as never); + vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( + {} as never, + ); await recordSyncFailure({ userId: "u1", integration: "withings", @@ -286,9 +328,9 @@ describe("recordSyncFailure — persistent (v1.4.42 W6)", () => { // 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({ - consecutiveFailures: 1, - } as never); + vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( + {} as never, + ); await recordSyncFailure({ userId: "u1", integration: "withings", @@ -311,8 +353,18 @@ describe("recordSyncFailure — persistent (v1.4.42 W6)", () => { }); 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({ - consecutiveFailures: 3, alertedAt: null, } as never); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ @@ -342,8 +394,18 @@ describe("recordSyncFailure — persistent (v1.4.42 W6)", () => { describe("recordSyncFailure — admin Telegram skipped silently when no admins", () => { it("logs a wide-event warning but does NOT throw when no admin users exist", 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({ - consecutiveFailures: 3, alertedAt: null, } as never); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ @@ -418,8 +480,9 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => { expect(args.update).toEqual({ state: "disconnected", lastError: null, - consecutiveFailures: 0, // v1.4.43 W14 — disconnect must clear the per-kind buckets too. + // v1.4.47 W1 — legacy `consecutiveFailures` column dropped + // (migration 0076); the bucket reset is the only counter write. consecutiveFailuresByKind: { transient: 0, reauth_required: 0, @@ -439,9 +502,10 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => { expect(args.update).toEqual({ state: "connected", lastError: null, - consecutiveFailures: 0, // 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 0076); the bucket reset is the only counter write. consecutiveFailuresByKind: { transient: 0, reauth_required: 0, @@ -475,7 +539,6 @@ 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({ - consecutiveFailures: 2, consecutiveFailuresByKind: { transient: 2, reauth_required: 0, @@ -485,7 +548,6 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => { alertedAt: null, } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ - consecutiveFailures: 3, alertedAt: null, } as never); @@ -513,7 +575,6 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => { 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({ - consecutiveFailures: 5, consecutiveFailuresByKind: { transient: 0, reauth_required: 0, @@ -523,7 +584,6 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => { alertedAt: new Date(Date.now() - 30 * 60 * 1000), // already alerted } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ - consecutiveFailures: 6, alertedAt: new Date(Date.now() - 30 * 60 * 1000), } as never); @@ -552,7 +612,6 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => { 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({ - consecutiveFailures: 4, consecutiveFailuresByKind: { transient: 0, reauth_required: 0, @@ -562,7 +621,6 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => { alertedAt: null, } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ - consecutiveFailures: 5, alertedAt: null, } as never); @@ -591,7 +649,6 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => { 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({ - consecutiveFailures: 30, consecutiveFailuresByKind: { transient: 0, reauth_required: 0, @@ -601,7 +658,6 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => { alertedAt: new Date(Date.now() - 23 * 60 * 60 * 1000), } as never); vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ - consecutiveFailures: 31, alertedAt: new Date(Date.now() - 23 * 60 * 60 * 1000), } as never); @@ -629,18 +685,20 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => { ); }); - it("back-fills the bucket from the legacy single counter on a pre-migration row", async () => { - // Legacy row: counter is 3, no bucket JSON yet (predates 0075). + 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 0076). 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({ - consecutiveFailures: 3, consecutiveFailuresByKind: null, persistentFailureStartedAt: null, alertedAt: null, } as never); - vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ - consecutiveFailures: 4, - alertedAt: null, - } as never); + vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( + {} as never, + ); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ email: "u@example.com", } as never); @@ -659,21 +717,89 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => { const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock .calls[0][0]; - // The 3 legacy failures get bucketed into the CURRENT failure's - // kind (reauth_required) — that's the kind the row was last - // tagged with, and back-filling into a different kind would - // under-count the existing streak. v1.4.43 W10 senior-dev M-1 — - // the back-fill branch additionally increments the bucket so the - // post-write bucket count matches the legacy column's post-write - // value (legacy: 3 → 4, bucket: 0 → 3 → 4). expect(upsertArgs.update.consecutiveFailuresByKind).toEqual({ transient: 0, - reauth_required: 4, + reauth_required: 1, persistent: 0, }); }); }); +// ── v1.4.47 W1 — migration 0076 down-script reversibility ────────────── +// +// Migration 0076 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({ @@ -691,7 +817,8 @@ describe("resumeIntegrationFromPark", () => { expect(upsertArgs.update).toEqual({ state: "connected", lastError: null, - consecutiveFailures: 0, + // v1.4.47 W1 — legacy `consecutiveFailures` column dropped + // (migration 0076); the bucket reset is the only counter write. consecutiveFailuresByKind: { transient: 0, reauth_required: 0, diff --git a/tests/integration/integration-status.test.ts b/tests/integration/integration-status.test.ts index 32dce387e..8375b9598 100644 --- a/tests/integration/integration-status.test.ts +++ b/tests/integration/integration-status.test.ts @@ -84,7 +84,13 @@ describe("IntegrationStatus end-to-end", () => { }); expect(status).not.toBeNull(); expect(status!.state).toBe("error_transient"); - expect(status!.consecutiveFailures).toBe(1); + // v1.4.47 W1 — legacy `consecutiveFailures` column dropped; the + // per-kind bucket is now the sole counter. + expect(status!.consecutiveFailuresByKind).toMatchObject({ + transient: 1, + reauth_required: 0, + persistent: 0, + }); expect(status!.lastAttemptAt).toBeInstanceOf(Date); expect(status!.lastError).toBeTruthy(); // Encrypted at rest — must NOT be the plaintext message. @@ -116,7 +122,13 @@ describe("IntegrationStatus end-to-end", () => { const snapshot = await getIntegrationStatus(TEST_USER_ID, "moodlog"); expect(snapshot.state).toBe("error_transient"); expect(snapshot.lastError).toBe("moodLog sync HTTP 502"); - expect(snapshot.consecutiveFailures).toBe(1); + // v1.4.47 W1 — snapshot drops the legacy `consecutiveFailures` + // scalar; the per-kind bucket carries the count. + expect(snapshot.consecutiveFailuresByKind).toMatchObject({ + transient: 1, + reauth_required: 0, + persistent: 0, + }); }); it("recordSyncSuccess clears the streak, resets state, and (importantly) does NOT write an audit row", async () => { @@ -134,7 +146,11 @@ describe("IntegrationStatus end-to-end", () => { }, }); expect(status!.state).toBe("connected"); - expect(status!.consecutiveFailures).toBe(0); + expect(status!.consecutiveFailuresByKind).toMatchObject({ + transient: 0, + reauth_required: 0, + persistent: 0, + }); expect(status!.lastError).toBeNull(); expect(status!.lastSuccessAt).toBeInstanceOf(Date); @@ -242,7 +258,11 @@ describe("IntegrationStatus end-to-end", () => { }); expect(row!.state).toBe("disconnected"); expect(row!.lastError).toBeNull(); - expect(row!.consecutiveFailures).toBe(0); + expect(row!.consecutiveFailuresByKind).toMatchObject({ + transient: 0, + reauth_required: 0, + persistent: 0, + }); }); // v1.4.43 W14 — end-to-end resume flow against real Postgres. @@ -259,7 +279,6 @@ describe("IntegrationStatus end-to-end", () => { userId: TEST_USER_ID, integration: "withings", state: "parked", - consecutiveFailures: 30, consecutiveFailuresByKind: { transient: 2, reauth_required: 0, @@ -286,7 +305,6 @@ describe("IntegrationStatus end-to-end", () => { }); expect(row).not.toBeNull(); expect(row!.state).toBe("connected"); - expect(row!.consecutiveFailures).toBe(0); expect(row!.consecutiveFailuresByKind).toEqual({ transient: 0, reauth_required: 0,