test(integrations): align status tests with dropped legacy counter + lock in down-script recipe

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-22 09:16:30 +02:00
1 parent 0cdf974e07
commit 76e62283fa
4 files changed
+211 -66

No files matched your search

@@ -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,
},
],
});
@@ -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();
});
+174 -47
View File
@@ -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,
+24 -6
View File
@@ -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,