mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user