Files
HealthLog/src/lib/integrations/__tests__/status.test.ts
T
Marc-André Bombeck 0e5afe76a4 fix(integrations): skip the ledger page when the watchdog announced it
A failure streak reaching the alert threshold paged the operator even
when the watchdog had already announced an open incident for the same
connection. The ledger now checks for that announced incident first,
stamps the streak as announced and sends nothing; the watchdog's
recovery note still ends it.
2026-10-15 16:38:12 +02:00

1439 lines
49 KiB
TypeScript

/**
* 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<string, unknown>;
}
it("records a transient failure as persistent once the streak outlives the window", async () => {
seedStreak({ transient: 3, ageMs: DAY_MS + 60_000 });
await recordSyncFailure({
userId: "u1",
integration: "withings",
kind: "transient",
message: "Withings measure error: 503 - upstream",
});
// The transient bucket is frozen where the window closed; the persistent
// bucket — the only one the park ladder reads — starts counting.
expect(upsertUpdate().consecutiveFailuresByKind).toEqual({
transient: 3,
reauth_required: 0,
persistent: 1,
});
expect(upsertUpdate().persistentFailureStartedAt).toBeInstanceOf(Date);
expect(auditLog).toHaveBeenCalledWith(
"integrations.sync.failed",
expect.objectContaining({
details: expect.objectContaining({
kind: "persistent",
escalatedFrom: "transient",
}),
}),
);
});
it("leaves a streak inside the window alone", async () => {
seedStreak({ transient: 9, ageMs: DAY_MS - 60_000 });
await recordSyncFailure({
userId: "u1",
integration: "withings",
kind: "transient",
message: "Withings measure error: 503 - upstream",
});
expect(upsertUpdate().consecutiveFailuresByKind).toEqual({
transient: 10,
reauth_required: 0,
persistent: 0,
});
expect(upsertUpdate().persistentFailureStartedAt).toBeNull();
expect(auditLog).toHaveBeenCalledWith(
"integrations.sync.failed",
expect.objectContaining({
details: expect.objectContaining({
kind: "transient",
escalatedFrom: null,
}),
}),
);
});
it("needs the consecutive-failure threshold as well as the clock", async () => {
// A provider polled once a day: old streak, but only two attempts in it.
seedStreak({ transient: 1, ageMs: 5 * DAY_MS });
await recordSyncFailure({
userId: "u1",
integration: "nightscout",
kind: "transient",
message: "Nightscout sync HTTP 502",
});
expect(upsertUpdate().consecutiveFailuresByKind).toEqual({
transient: 2,
reauth_required: 0,
persistent: 0,
});
expect(upsertUpdate().persistentFailureStartedAt).toBeNull();
});
it("dates a fresh streak from this failure and never re-dates a running one", async () => {
const anchor = new Date(Date.now() - 3 * DAY_MS);
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
consecutiveFailuresByKind: {
transient: 1,
reauth_required: 0,
persistent: 0,
},
persistentFailureStartedAt: null,
failingSinceAt: anchor,
alertedAt: new Date(),
} as never);
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
alertedAt: new Date(),
} as never);
await recordSyncFailure({
userId: "u1",
integration: "withings",
kind: "transient",
message: "Withings measure error: 503 - upstream",
});
expect(upsertUpdate().failingSinceAt).toEqual(anchor);
});
});
describe("recordSyncSuccess — leg scoping", () => {
it("leaves a sibling leg's error standing and only records the attempt", async () => {
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
state: "error_transient",
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<string, unknown>;
}
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();
});
});