mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
test(integrations): pin the escalation ladder, leg-scoped clearing, and what the card says
Mutation-checked against the production paths they cover: the escalation verdict, the leg guard in `recordSyncSuccess`, the streak anchor the verdict dates itself by, the status envelope threading it, and the pill's relative timestamp. Also give the four legacy per-provider status routes the verdict the consolidated envelope already carries. They answered `connected: true` plus a `state` string and nothing else, so a client reading one of them saw a live connection over a pipe that had delivered nothing for weeks.
This commit is contained in:
1 parent
527dd6c1fa
commit
4e3b029c38
9 files changed
+443
-15
No files matched your search
@@ -29,6 +29,7 @@ const ledger: Record<string, unknown> = {
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
failingSince: null,
|
||||
};
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
getIntegrationStatus: vi.fn(async (_u: string, integration: string) => ({
|
||||
@@ -239,3 +240,40 @@ describe("/api/integrations/status — sync-health verdict on every entry", () =
|
||||
expect(nightscout!.syncHealth).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/integrations/status — a provider that has delivered nothing for weeks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const key of Object.keys(perIntegrationLedger)) {
|
||||
delete perIntegrationLedger[key];
|
||||
}
|
||||
userFind.mockResolvedValue({});
|
||||
whoopFind.mockResolvedValue(null);
|
||||
polarAvailable.mockResolvedValue(true);
|
||||
ouraAvailable.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("reports it as failing and dates the verdict from the streak, not the retry", async () => {
|
||||
const failingSince = new Date(
|
||||
Date.now() - 17 * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
perIntegrationLedger.nightscout = {
|
||||
state: "error_transient",
|
||||
// The hourly cron is still running and a sibling leg can still stamp a
|
||||
// success, so neither of these tells the user anything.
|
||||
lastSuccessAt: new Date(Date.now() - 20 * 60_000).toISOString(),
|
||||
lastAttemptAt: new Date(Date.now() - 4 * 60_000).toISOString(),
|
||||
failingSince,
|
||||
lastError: "Nightscout sync HTTP 502",
|
||||
};
|
||||
userFind.mockResolvedValue({ nightscoutUrlEncrypted: "enc" });
|
||||
|
||||
const nightscout = (await fetchEntries()).find(
|
||||
(entry) => entry.integration === "nightscout",
|
||||
)!;
|
||||
expect(nightscout.syncHealth).toEqual({
|
||||
verdict: "failing",
|
||||
since: failingSince,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getIntegrationStatus,
|
||||
getPersistentFailureThreshold,
|
||||
type IntegrationKey,
|
||||
type IntegrationStatusSnapshot,
|
||||
} from "@/lib/integrations/status";
|
||||
import {
|
||||
getSourceMetricFreshness,
|
||||
@@ -155,6 +156,25 @@ export const GET = apiHandler(async () => {
|
||||
const now = Date.now();
|
||||
const whoopConnected = !!whoopConn?.whoopUserId;
|
||||
|
||||
/**
|
||||
* The ledger fields the envelope publishes, named one by one.
|
||||
*
|
||||
* `failingSince` is deliberately not among them. It is the input to
|
||||
* `syncHealth.since`, which every consumer already reads, and putting both on
|
||||
* the wire would be two names for one fact — the second of which nothing
|
||||
* renders.
|
||||
*/
|
||||
function publish(snapshot: IntegrationStatusSnapshot) {
|
||||
return {
|
||||
integration: snapshot.integration,
|
||||
state: snapshot.state,
|
||||
lastSuccessAt: snapshot.lastSuccessAt,
|
||||
lastAttemptAt: snapshot.lastAttemptAt,
|
||||
lastError: snapshot.lastError,
|
||||
consecutiveFailuresByKind: snapshot.consecutiveFailuresByKind,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The sync-health verdict + the per-metric staleness flags, resolved from
|
||||
* data already in hand — no additional query. Every entry carries it,
|
||||
@@ -227,7 +247,7 @@ export const GET = apiHandler(async () => {
|
||||
threshold: getPersistentFailureThreshold(),
|
||||
integrations: [
|
||||
{
|
||||
...withingsStatus,
|
||||
...publish(withingsStatus),
|
||||
configured: withingsConfigured,
|
||||
connected: !!withingsConn,
|
||||
connectedAt: withingsConn?.createdAt?.toISOString() ?? null,
|
||||
@@ -251,7 +271,7 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
} satisfies IntegrationViewModel & WithingsExtras,
|
||||
{
|
||||
...whoopStatus,
|
||||
...publish(whoopStatus),
|
||||
configured: whoopConfigured,
|
||||
connected: whoopConnected,
|
||||
connectedAt: whoopConnected
|
||||
@@ -274,7 +294,7 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
} satisfies IntegrationViewModel & WhoopExtras,
|
||||
{
|
||||
...fitbitStatus,
|
||||
...publish(fitbitStatus),
|
||||
configured: fitbitConfigured,
|
||||
connected: fitbitConnected,
|
||||
connectedAt: fitbitConn?.createdAt?.toISOString() ?? null,
|
||||
@@ -291,7 +311,7 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
} satisfies IntegrationViewModel & FitbitExtras,
|
||||
{
|
||||
...googleHealthStatus,
|
||||
...publish(googleHealthStatus),
|
||||
configured: googleHealthConfigured,
|
||||
connected: googleHealthConnected,
|
||||
connectedAt: googleHealthConn?.createdAt?.toISOString() ?? null,
|
||||
@@ -314,7 +334,7 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
} satisfies IntegrationViewModel & GoogleHealthExtras,
|
||||
{
|
||||
...polarStatus,
|
||||
...publish(polarStatus),
|
||||
// `connected` = a stored access token; `configured` mirrors it (the
|
||||
// OAuth card has no separate "credentials saved but disconnected" view
|
||||
// beyond `hasOwnCredentials`). The card greys out the connect button
|
||||
@@ -331,7 +351,7 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
||||
{
|
||||
...ouraStatus,
|
||||
...publish(ouraStatus),
|
||||
connected: ouraConnected,
|
||||
configured: ouraConnected,
|
||||
available: ouraAvailable,
|
||||
@@ -344,7 +364,7 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
||||
{
|
||||
...stravaStatus,
|
||||
...publish(stravaStatus),
|
||||
connected: stravaConnected,
|
||||
configured: stravaConnected,
|
||||
available: stravaAvailable,
|
||||
@@ -359,7 +379,7 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
||||
{
|
||||
...nightscoutStatus,
|
||||
...publish(nightscoutStatus),
|
||||
// Nightscout is a URL the self-hoster pastes once; `configured` (a
|
||||
// stored URL) is the connect marker, and a fully-public instance has
|
||||
// no token. The card reads these off the envelope now instead of its
|
||||
@@ -383,12 +403,6 @@ interface IntegrationViewModel {
|
||||
lastSuccessAt: string | null;
|
||||
lastAttemptAt: string | null;
|
||||
lastError: string | null;
|
||||
/**
|
||||
* Start of the current unbroken failure streak, or null when the provider is
|
||||
* not failing. `syncHealth.since` is derived from it; the raw instant rides
|
||||
* along so a client can render the age without re-deriving the verdict.
|
||||
*/
|
||||
failingSince: string | null;
|
||||
/**
|
||||
* The server-resolved liveness verdict. Present on EVERY entry, whether or
|
||||
* not a card renders it — the unit of coverage is the envelope entry, not
|
||||
|
||||
@@ -3,6 +3,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
import {
|
||||
INTEGRATION_CADENCE,
|
||||
resolveSyncVerdict,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
|
||||
/**
|
||||
* Nightscout connection status for the current user (v1.17.0).
|
||||
@@ -45,5 +49,18 @@ export const GET = apiHandler(async () => {
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
lastError: status.lastError,
|
||||
// `connected: true` here means "a connection exists", not "it is working"
|
||||
// — a pipe dead for a fortnight still answers true. The verdict is the
|
||||
// liveness truth, resolved by the same rule the Settings envelope uses so
|
||||
// this route cannot drift into a second opinion.
|
||||
syncHealth: resolveSyncVerdict({
|
||||
connected: true,
|
||||
configured: true,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
failingSinceAt: status.failingSince,
|
||||
cadence: INTEGRATION_CADENCE["nightscout"],
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getOuraClientCredentials } from "@/lib/oura/credentials";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
import {
|
||||
INTEGRATION_CADENCE,
|
||||
resolveSyncVerdict,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
|
||||
/**
|
||||
* v1.17.0 (F4) — Oura connection status for the current user.
|
||||
@@ -53,5 +57,18 @@ export const GET = apiHandler(async () => {
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
lastError: status.lastError,
|
||||
// `connected: true` here means "a connection exists", not "it is working"
|
||||
// — a pipe dead for a fortnight still answers true. The verdict is the
|
||||
// liveness truth, resolved by the same rule the Settings envelope uses so
|
||||
// this route cannot drift into a second opinion.
|
||||
syncHealth: resolveSyncVerdict({
|
||||
connected: true,
|
||||
configured: true,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
failingSinceAt: status.failingSince,
|
||||
cadence: INTEGRATION_CADENCE["oura"],
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
import {
|
||||
INTEGRATION_CADENCE,
|
||||
resolveSyncVerdict,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
|
||||
/**
|
||||
* v1.17.0 (F4) — Polar connection status for the current user.
|
||||
@@ -54,5 +58,18 @@ export const GET = apiHandler(async () => {
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
lastError: status.lastError,
|
||||
// `connected: true` here means "a connection exists", not "it is working"
|
||||
// — a pipe dead for a fortnight still answers true. The verdict is the
|
||||
// liveness truth, resolved by the same rule the Settings envelope uses so
|
||||
// this route cannot drift into a second opinion.
|
||||
syncHealth: resolveSyncVerdict({
|
||||
connected: true,
|
||||
configured: true,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
failingSinceAt: status.failingSince,
|
||||
cadence: INTEGRATION_CADENCE["polar"],
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getStravaClientCredentials } from "@/lib/strava/credentials";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
import {
|
||||
INTEGRATION_CADENCE,
|
||||
resolveSyncVerdict,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
|
||||
/**
|
||||
* v1.28.x — Strava connection status for the current user.
|
||||
@@ -53,5 +57,18 @@ export const GET = apiHandler(async () => {
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
lastError: status.lastError,
|
||||
// `connected: true` here means "a connection exists", not "it is working"
|
||||
// — a pipe dead for a fortnight still answers true. The verdict is the
|
||||
// liveness truth, resolved by the same rule the Settings envelope uses so
|
||||
// this route cannot drift into a second opinion.
|
||||
syncHealth: resolveSyncVerdict({
|
||||
connected: true,
|
||||
configured: true,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
failingSinceAt: status.failingSince,
|
||||
cadence: INTEGRATION_CADENCE["strava"],
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -196,3 +196,37 @@ describe("IntegrationStatusPill", () => {
|
||||
expect(html).toContain('data-testid="apple-health-status"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("IntegrationStatusPill — a failing provider states its age", () => {
|
||||
it("renders the failing state with how long it has been failing", () => {
|
||||
const seventeenDaysAgo = new Date(Date.now() - 17 * 24 * 60 * 60 * 1000);
|
||||
const html = render(
|
||||
<IntegrationStatusPill
|
||||
state="warning"
|
||||
lastSyncAt={seventeenDaysAgo}
|
||||
now={new Date()}
|
||||
/>,
|
||||
);
|
||||
// The label must not open with a claim of connection: the pipe has
|
||||
// delivered nothing for a fortnight.
|
||||
expect(html).not.toContain("Connected");
|
||||
expect(html).toContain("Sync failing");
|
||||
// And the age has to be on the pill. Without it, hour one and day
|
||||
// seventeen render identically, which is how a dead sync stayed quiet.
|
||||
expect(html).toMatch(/17\s*d\s+ago/);
|
||||
expect(html).toContain('data-state="warning"');
|
||||
});
|
||||
|
||||
it("keeps the German label free of a connection claim too", () => {
|
||||
const html = render(
|
||||
<IntegrationStatusPill
|
||||
state="warning"
|
||||
lastSyncAt={new Date(Date.now() - 3 * 24 * 60 * 60 * 1000)}
|
||||
now={new Date()}
|
||||
/>,
|
||||
"de",
|
||||
);
|
||||
expect(html).not.toContain("Verbunden");
|
||||
expect(html).toContain("Sync fehlgeschlagen");
|
||||
});
|
||||
});
|
||||
@@ -899,3 +899,212 @@ describe("sync-failure-recorded marker (§5)", () => {
|
||||
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",
|
||||
failingLeg: "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.
|
||||
expect(args.data).toEqual({ lastAttemptAt: expect.any(Date) });
|
||||
});
|
||||
|
||||
it("clears the row whole when the failing leg is the one that succeeded", async () => {
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
|
||||
state: "error_transient",
|
||||
failingLeg: "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,
|
||||
failingLeg: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the row whole when the failure was never attributed to a leg", async () => {
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
|
||||
state: "error_transient",
|
||||
failingLeg: null,
|
||||
} 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",
|
||||
failingLeg: "sleep",
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await recordSyncSuccess("u1", "withings", { leg: "ecg" });
|
||||
|
||||
expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
const NOW = new Date("2026-07-25T12:00:00.000Z");
|
||||
const nowMs = NOW.getTime();
|
||||
const ago = (ms: number) => new Date(nowMs - ms).toISOString();
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const MINUTE = 60 * 1000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
describe("resolveSyncVerdict — the ladder", () => {
|
||||
@@ -205,3 +206,67 @@ describe("resolveSyncVerdict — the ladder", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSyncVerdict — the error verdicts date the streak", () => {
|
||||
it("dates a failing provider from the streak start, not the last retry", () => {
|
||||
// The shape that hid a fortnight of silence: the hourly retry keeps
|
||||
// `lastAttemptAt` fresh, and a multi-leg provider can keep advancing
|
||||
// `lastSuccessAt` from a healthy leg while another one is dead.
|
||||
const failingSinceAt = ago(17 * DAY);
|
||||
expect(
|
||||
resolveSyncVerdict({
|
||||
connected: true,
|
||||
state: "error_transient",
|
||||
lastSuccessAt: ago(30 * MINUTE),
|
||||
lastAttemptAt: ago(4 * MINUTE),
|
||||
failingSinceAt,
|
||||
cadence: POLLED_CADENCE,
|
||||
now: NOW,
|
||||
}),
|
||||
).toEqual({ verdict: "failing", since: failingSinceAt });
|
||||
});
|
||||
|
||||
it("falls back to the last success when the ledger has no streak anchor", () => {
|
||||
// Rows written before the anchor existed, and never re-failed since.
|
||||
const lastSuccessAt = ago(3 * DAY);
|
||||
expect(
|
||||
resolveSyncVerdict({
|
||||
connected: true,
|
||||
state: "error_transient",
|
||||
lastSuccessAt,
|
||||
lastAttemptAt: ago(2 * HOUR),
|
||||
failingSinceAt: null,
|
||||
cadence: POLLED_CADENCE,
|
||||
now: NOW,
|
||||
}),
|
||||
).toEqual({ verdict: "failing", since: lastSuccessAt });
|
||||
});
|
||||
|
||||
it("dates a parked provider from the streak start", () => {
|
||||
const failingSinceAt = ago(6 * DAY);
|
||||
expect(
|
||||
resolveSyncVerdict({
|
||||
connected: true,
|
||||
state: "parked",
|
||||
lastAttemptAt: ago(10 * MINUTE),
|
||||
failingSinceAt,
|
||||
cadence: POLLED_CADENCE,
|
||||
now: NOW,
|
||||
}),
|
||||
).toEqual({ verdict: "parked", since: failingSinceAt });
|
||||
});
|
||||
|
||||
it("dates a reauth-required provider from the streak start", () => {
|
||||
const failingSinceAt = ago(9 * DAY);
|
||||
expect(
|
||||
resolveSyncVerdict({
|
||||
connected: true,
|
||||
state: "error_reauth",
|
||||
lastAttemptAt: ago(10 * MINUTE),
|
||||
failingSinceAt,
|
||||
cadence: POLLED_CADENCE,
|
||||
now: NOW,
|
||||
}),
|
||||
).toEqual({ verdict: "reauth_required", since: failingSinceAt });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user