feat(withings): surface parked state + manual resume CTA

Pill UI gains a fifth state — 'parked' — for the v1.4.43 W14 24h
persistent-failure park. Copy reads 'Pausiert — manuell wieder
verbinden' (DE) / 'Paused — reconnect manually' (EN); chip uses the
dracula-orange tokens (heavier than warning yellow, lighter than the
red reconnect pill).

The Withings card renders a 'Wieder verbinden' / 'Reconnect' banner
under the pill when the state is parked. Clicking POSTs to the new
/api/integrations/withings/resume endpoint (rate-limited 5/min per
user, same envelope as the test-connection endpoint). The endpoint
calls resumeIntegrationFromPark which flips state back to connected
and zeroes every per-kind bucket; the per-card status query
invalidates so the pill flips back without a page refresh.

Locale parity across all six locales (en/de/es/fr/it/pl); end-to-end
integration test against real Postgres pins both the resume flow and
the per-kind bucket increment contract.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 22:19:29 +02:00
1 parent ee15aa05ea
commit 5a7484658e
13 files changed
+501 -18

No files matched your search

+5 -1
View File
@@ -1905,12 +1905,16 @@
"connected": "Verbunden",
"errorReconnect": "Fehler — neu verbinden",
"warningServerError": "Verbunden, aber Serverfehler",
"parkedReconnect": "Pausiert — manuell wieder verbinden",
"notConnected": "Nicht verbunden",
"justNow": "gerade eben",
"minutesAgo": "vor {count} min",
"hoursAgo": "vor {count} Std.",
"daysAgo": "vor {count} T.",
"ariaLabel": "Integrationsstatus"
"ariaLabel": "Integrationsstatus",
"resumeCta": "Wieder verbinden",
"resumeSuccess": "Integration wieder aktiv",
"resumeError": "Wieder verbinden fehlgeschlagen"
},
"apiTokens": "API-Tokens",
"apiTokensDescription": "Erstelle API-Tokens für die externe Medikamenten-Einnahme (z.B. Shortcuts, Automationen).",
+5 -1
View File
@@ -1905,12 +1905,16 @@
"connected": "Connected",
"errorReconnect": "Error — reconnect",
"warningServerError": "Connected, server error",
"parkedReconnect": "Paused — reconnect manually",
"notConnected": "Not connected",
"justNow": "just now",
"minutesAgo": "{count} min ago",
"hoursAgo": "{count} h ago",
"daysAgo": "{count} d ago",
"ariaLabel": "Integration status"
"ariaLabel": "Integration status",
"resumeCta": "Reconnect",
"resumeSuccess": "Integration resumed",
"resumeError": "Reconnect failed"
},
"apiTokens": "API Tokens",
"apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).",
+5 -1
View File
@@ -1905,12 +1905,16 @@
"connected": "Conectado",
"errorReconnect": "Error — reconectar",
"warningServerError": "Conectado, error del servidor",
"parkedReconnect": "Pausado — reconectar manualmente",
"notConnected": "No conectado",
"justNow": "just now",
"minutesAgo": "{count} min ago",
"hoursAgo": "{count} h ago",
"daysAgo": "{count} d ago",
"ariaLabel": "Integration status"
"ariaLabel": "Integration status",
"resumeCta": "Reconectar",
"resumeSuccess": "Integración reanudada",
"resumeError": "Reconexión fallida"
},
"apiTokens": "API Tokens",
"apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).",
+5 -1
View File
@@ -1905,12 +1905,16 @@
"connected": "Connecté",
"errorReconnect": "Erreur — reconnecter",
"warningServerError": "Connecté, erreur serveur",
"parkedReconnect": "En pause — reconnecter manuellement",
"notConnected": "Non connecté",
"justNow": "just now",
"minutesAgo": "{count} min ago",
"hoursAgo": "{count} h ago",
"daysAgo": "{count} d ago",
"ariaLabel": "Integration status"
"ariaLabel": "Integration status",
"resumeCta": "Reconnecter",
"resumeSuccess": "Intégration reprise",
"resumeError": "Échec de la reconnexion"
},
"apiTokens": "API Tokens",
"apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).",
+5 -1
View File
@@ -1905,12 +1905,16 @@
"connected": "Connesso",
"errorReconnect": "Errore — riconnetti",
"warningServerError": "Connesso, errore del server",
"parkedReconnect": "In pausa — riconnettere manualmente",
"notConnected": "Non connesso",
"justNow": "just now",
"minutesAgo": "{count} min ago",
"hoursAgo": "{count} h ago",
"daysAgo": "{count} d ago",
"ariaLabel": "Integration status"
"ariaLabel": "Integration status",
"resumeCta": "Riconnetti",
"resumeSuccess": "Integrazione ripresa",
"resumeError": "Riconnessione fallita"
},
"apiTokens": "API Tokens",
"apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).",
+5 -1
View File
@@ -1905,12 +1905,16 @@
"connected": "Połączono",
"errorReconnect": "Błąd — połącz ponownie",
"warningServerError": "Połączono, błąd serwera",
"parkedReconnect": "Wstrzymane — połącz ręcznie",
"notConnected": "Nie połączono",
"justNow": "just now",
"minutesAgo": "{count} min ago",
"hoursAgo": "{count} h ago",
"daysAgo": "{count} d ago",
"ariaLabel": "Integration status"
"ariaLabel": "Integration status",
"resumeCta": "Połącz ponownie",
"resumeSuccess": "Integracja wznowiona",
"resumeError": "Ponowne połączenie nieudane"
},
"apiTokens": "API Tokens",
"apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).",
@@ -0,0 +1,109 @@
/**
* v1.4.43 W14 — POST /api/integrations/withings/resume
*
* Three pins:
* 1. Happy path: a parked row resumes, response carries
* `wasParked: true`.
* 2. Idempotent: a connected row resumes silently, response carries
* `wasParked: false`.
* 3. Rate-limit: a 6th call within the window returns 429 with the
* `rate_limited_self` error code.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({
user: { id: "u-1" },
session: { id: "s-1" },
})),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({
allowed: true,
remaining: 5,
resetAt: Date.now() + 60_000,
})),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/integrations/status", () => ({
resumeIntegrationFromPark: vi.fn(),
}));
import { POST } from "../route";
import { checkRateLimit } from "@/lib/rate-limit";
import { resumeIntegrationFromPark } from "@/lib/integrations/status";
interface ApiEnvelope<T> {
data: T | null;
error: string | null;
}
function emptyRequest(): Request {
return new Request("http://localhost/api/integrations/withings/resume", {
method: "POST",
headers: { "content-length": "0" },
});
}
beforeEach(() => {
vi.mocked(resumeIntegrationFromPark).mockReset();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
resetAt: Date.now() + 60_000,
} as never);
});
describe("POST /api/integrations/withings/resume", () => {
it("clears a parked integration and reports wasParked: true", async () => {
vi.mocked(resumeIntegrationFromPark).mockResolvedValueOnce({
wasParked: true,
});
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(200);
const body = (await response.json()) as ApiEnvelope<{
resumed: boolean;
wasParked: boolean;
}>;
expect(body.data).toEqual({ resumed: true, wasParked: true });
expect(resumeIntegrationFromPark).toHaveBeenCalledWith("u-1", "withings");
});
it("returns wasParked: false when the row was already connected", async () => {
vi.mocked(resumeIntegrationFromPark).mockResolvedValueOnce({
wasParked: false,
});
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(200);
const body = (await response.json()) as ApiEnvelope<{
resumed: boolean;
wasParked: boolean;
}>;
expect(body.data).toEqual({ resumed: true, wasParked: false });
});
it("returns 429 with rate_limited_self when the rate limit is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(429);
const body = (await response.json()) as ApiEnvelope<unknown> & {
meta?: { errorCode?: string };
};
expect(body.error).toBeTruthy();
expect(body.meta?.errorCode).toBe("rate_limited_self");
expect(resumeIntegrationFromPark).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,59 @@
/**
* v1.4.43 W14 — manual resume endpoint for a parked Withings
* integration.
*
* Background: B4 introduced the `parked` state in
* `src/lib/integrations/status.ts`. Once a Withings persistent-failure
* streak (contract-mismatch 293/294) has been running for more than
* 24h, the status writer flips the row to `parked` and the sync
* entry-point short-circuits via `isReauthRequired`. The user (or an
* operator) calls this endpoint to clear the park and let the next
* cron tick attempt the sync again.
*
* Rate-limited (5/min per user) to match the existing
* `/api/integrations/withings/test` endpoint — there's no upstream
* call here, but a misbehaving client tab loop would still rack up
* audit-log rows if the limit was absent.
*
* Returns 200 with `{ resumed: true, wasParked: boolean }`. The
* `wasParked` flag lets the caller decide whether to surface a "you
* resumed it" toast or treat the action as a no-op (the row was
* already connected / disconnected). Either way the endpoint is
* idempotent — a second click in the same second sees the same
* post-state and writes no extra audit row.
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { resumeIntegrationFromPark } from "@/lib/integrations/status";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAuth();
annotate({ action: { name: "integrations.withings.resume" } });
const rl = await checkRateLimit(
`withings-resume:${user.id}`,
5,
60_000,
);
if (!rl.allowed) {
return apiError("Too many resume requests", 429, {
errorCode: "rate_limited_self",
});
}
const result = await resumeIntegrationFromPark(user.id, "withings");
annotate({
meta: {
withings_resume_was_parked: result.wasParked,
},
});
return apiSuccess({ resumed: true, wasParked: result.wasParked });
});
@@ -94,4 +94,27 @@ describe("IntegrationStatusPill", () => {
// narrow on Pixel 5.
expect(html).toMatch(/just\s+now/i);
});
// v1.4.43 W14 — parked-state copy.
// Distinct from `error` (red reconnect pill) because the user can
// resume the integration without redoing the OAuth dance; distinct
// from `warning` because the persistent streak survived the alert
// ladder AND the 24h grace window — manual intervention required.
it("renders the parked state with manual-reconnect phrasing (EN)", () => {
const html = render(
<IntegrationStatusPill state="parked" lastSyncAt={null} />,
);
expect(html).toContain("Paused");
expect(html).toContain("reconnect manually");
expect(html).toContain('data-state="parked"');
});
it("renders the parked state with manual-reconnect phrasing (DE)", () => {
const html = render(
<IntegrationStatusPill state="parked" lastSyncAt={null} />,
"de",
);
expect(html).toContain("Pausiert");
expect(html).toContain("manuell wieder verbinden");
});
});
@@ -285,6 +285,50 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
expect(html).not.toContain("Last sync:");
});
// v1.4.43 W14 — parked-state surface check.
// The pill flips to `data-state="parked"` and the resume banner
// renders with the reconnect CTA. The legacy `error` reconnect text
// (red) must NOT appear because the user-facing copy is the lighter
// "Paused — reconnect manually" string, not "Error — reconnect".
it("Withings parked state surfaces the parked pill + resume CTA", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "parked",
lastSuccessAt: "2026-05-08T12:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: "Withings activity error: 293",
consecutiveFailures: 30,
},
{
integration: "moodlog",
state: "connected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
consecutiveFailures: 0,
},
],
});
setWithingsStatus({
connected: true,
configured: true,
lastSyncedAt: "2026-05-08T12:00:00.000Z",
});
const html = render();
expect(html).toContain('data-state="parked"');
expect(html).toContain("Paused");
expect(html).toContain("reconnect manually");
expect(html).toContain('data-testid="withings-parked-banner"');
expect(html).toContain('data-testid="withings-resume-button"');
// The lastError is surfaced under the pill so the operator sees
// the contract-mismatch reason without opening Audit Log.
expect(html).toContain("Withings activity error: 293");
});
it("Mood Log card carries the visual divider Withings has (consistency)", () => {
setIntegrationStatus({
threshold: 3,
@@ -1,6 +1,11 @@
"use client";
import { CheckCircle2, AlertTriangle, CircleSlash } from "lucide-react";
import {
CheckCircle2,
AlertTriangle,
CircleSlash,
PauseCircle,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useTranslations } from "@/lib/i18n/context";
@@ -26,10 +31,24 @@ import { cn } from "@/lib/utils";
* abbreviated time form into a 12 char string at most.
*/
/**
* Pill states.
*
* - connected → happy path; relative "last sync" suffix is shown.
* - warning → connected but the upstream just returned a
* contract-mismatch error (v1.4.43 W4).
* - error → reauth-required / persistent transient error;
* "Error — reconnect" copy.
* - parked → v1.4.43 W14: >24h of persistent failures put the
* integration to sleep; "Pausiert — manuell wieder
* verbinden" copy + reconnect CTA.
* - disconnected → user clicked Disconnect; "Not connected" copy.
*/
export type IntegrationPillState =
| "connected"
| "warning"
| "error"
| "parked"
| "disconnected";
interface IntegrationStatusPillProps {
@@ -78,15 +97,22 @@ export function IntegrationStatusPill({
}: IntegrationStatusPillProps) {
const { t } = useTranslations();
// chipClass only applies to the `connected` and `warning` branches —
// `error` falls back to `variant="destructive"` and `disconnected` to
// `variant="outline"`. The warning chip uses amber tokens to signal
// "connected, but the upstream just returned a contract-level error
// a reauth won't fix" — distinct from the red reconnect pill.
// chipClass only applies to the `connected`, `warning`, and `parked`
// branches — `error` falls back to `variant="destructive"` and
// `disconnected` to `variant="outline"`. The warning chip uses amber
// tokens to signal "connected, but the upstream just returned a
// contract-level error a reauth won't fix" — distinct from the red
// reconnect pill. The parked chip uses the orange tokens to signal
// "we tried to keep going for 24h but the contract mismatch persists
// — manual intervention required" — visually heavier than warning
// (sustained problem), lighter than error (still recoverable without
// re-auth).
const connectedChipClass =
"border-dracula-green/30 bg-dracula-green/15 text-dracula-green";
const warningChipClass =
"border-dracula-yellow/30 bg-dracula-yellow/15 text-dracula-yellow";
const parkedChipClass =
"border-dracula-orange/30 bg-dracula-orange/15 text-dracula-orange";
let label: string;
let icon: React.ReactNode;
@@ -104,6 +130,10 @@ export function IntegrationStatusPill({
label = t("settings.integrationPill.errorReconnect");
icon = <AlertTriangle aria-hidden="true" className="h-3 w-3" />;
break;
case "parked":
label = t("settings.integrationPill.parkedReconnect");
icon = <PauseCircle aria-hidden="true" className="h-3 w-3" />;
break;
case "disconnected":
label = t("settings.integrationPill.notConnected");
icon = <CircleSlash aria-hidden="true" className="h-3 w-3" />;
@@ -124,7 +154,7 @@ export function IntegrationStatusPill({
data-testid="integration-status-pill"
data-state={state}
variant={
state === "connected" || state === "warning"
state === "connected" || state === "warning" || state === "parked"
? undefined
: state === "error"
? "destructive"
@@ -135,6 +165,7 @@ export function IntegrationStatusPill({
"max-w-full whitespace-nowrap",
state === "connected" && connectedChipClass,
state === "warning" && warningChipClass,
state === "parked" && parkedChipClass,
className,
)}
>
@@ -64,7 +64,8 @@ type IntegrationState =
| "connected"
| "error_transient"
| "error_reauth"
| "disconnected";
| "disconnected"
| "parked";
interface IntegrationStatusViewModel {
integration: IntegrationKey;
@@ -114,11 +115,14 @@ function pickStatus(
}
/**
* Collapse the API's four-state machine into the three states the
* pill UI cares about: `error_transient` and `error_reauth` both
* Collapse the API's five-state machine into the four states the
* pill UI cares about. `error_transient` and `error_reauth` both
* surface as the same "Error — reconnect" pill, the actionable
* difference (whether the user must reconnect vs wait for the next
* retry) is conveyed via the inline error text underneath.
* retry) is conveyed via the inline error text underneath. `parked`
* (v1.4.43 W14) is its own pill state — the integration has been
* disabled after 24h of persistent failures and needs an explicit
* "Wieder verbinden" click to resume.
*/
function pillStateFor(
status: IntegrationStatusViewModel | undefined,
@@ -130,6 +134,8 @@ function pillStateFor(
case "error_transient":
case "error_reauth":
return "error";
case "parked":
return "parked";
case "disconnected":
return "disconnected";
}
@@ -256,6 +262,28 @@ function WithingsCard({
},
});
// v1.4.43 W14 — clear a parked integration via the resume endpoint.
// The CTA is rendered inside the parked banner below; success
// invalidates both the per-card status (so the pill flips back to
// connected immediately) and the cross-integration envelope (so any
// other view picks up the change on its next focus).
const resume = useMutation({
mutationFn: async () => {
const res = await fetch("/api/integrations/withings/resume", {
method: "POST",
});
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as {
resumed: boolean;
wasParked: boolean;
};
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() });
},
});
async function handleSync(fullSync = false) {
setSyncing(true);
setSyncMsg(null);
@@ -339,8 +367,13 @@ function WithingsCard({
: "disconnected";
const pillLastSyncAt =
status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
// v1.4.43 W14 — `parked` and `error` both want the underlying error
// message surfaced under the pill (the pill says "what" — the
// message says "why"). Other states leave the inline line off.
const errorMessage =
pillState === "error" && viewModel?.lastError ? viewModel.lastError : null;
(pillState === "error" || pillState === "parked") && viewModel?.lastError
? viewModel.lastError
: null;
return (
<div className="bg-card border-border rounded-xl border p-6">
@@ -388,6 +421,59 @@ function WithingsCard({
</span>
</a>
)}
{/* v1.4.43 W14 — parked-integration resume CTA. Surfaces only
when the row state is `parked` (>24h of persistent
failures). The button POSTs to /api/integrations/withings/
resume which calls `resumeIntegrationFromPark`; on success
the per-card status invalidates and the pill flips back to
connected without a page refresh. The button is the
primary action the user can take from this card — wider
tap target than the inline action row so it's reachable
on a Pixel 5 viewport. */}
{pillState === "parked" && (
<div
data-testid="withings-parked-banner"
className="border-dracula-orange/30 bg-dracula-orange/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-dracula-orange min-w-0 break-words text-xs">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => resume.mutate()}
disabled={resume.isPending}
data-testid="withings-resume-button"
className="min-h-[44px] sm:min-h-0"
>
{resume.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Link2 className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.integrationPill.resumeCta")}
</Button>
</div>
)}
{resume.isError && (
<p
role="alert"
className="text-destructive text-xs"
data-testid="withings-resume-error"
>
{t("settings.integrationPill.resumeError")}
</p>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
className="text-dracula-green text-xs"
data-testid="withings-resume-success"
>
{t("settings.integrationPill.resumeSuccess")}
</p>
)}
<div className="space-y-3">
<h3 className="text-sm font-semibold">
{t("settings.withingsCredentials")}
@@ -46,6 +46,7 @@ import {
markReconnected,
isReauthRequired,
getIntegrationStatus,
resumeIntegrationFromPark,
} from "@/lib/integrations/status";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
@@ -244,6 +245,112 @@ describe("IntegrationStatus end-to-end", () => {
expect(row!.consecutiveFailures).toBe(0);
});
// v1.4.43 W14 — end-to-end resume flow against real Postgres.
//
// The full ladder: persistent failure × N → bucket increments only
// the persistent slot → wall-clock crosses 24h → next failure flips
// state to `parked` → resumeIntegrationFromPark clears it back to
// connected → every bucket zeroed → the next sync runs.
it("resumeIntegrationFromPark clears a parked row and zeroes every bucket", async () => {
// Stamp a row that's already parked. We do it through Prisma
// directly so the test doesn't have to wait 24h of wall clock.
await getPrismaClient().integrationStatus.create({
data: {
userId: TEST_USER_ID,
integration: "withings",
state: "parked",
consecutiveFailures: 30,
consecutiveFailuresByKind: {
transient: 2,
reauth_required: 0,
persistent: 28,
},
persistentFailureStartedAt: new Date(
Date.now() - 26 * 60 * 60 * 1000,
),
lastError: "encrypted-blob-placeholder",
lastAttemptAt: new Date(),
},
});
const result = await resumeIntegrationFromPark(TEST_USER_ID, "withings");
expect(result.wasParked).toBe(true);
const row = await getPrismaClient().integrationStatus.findUnique({
where: {
userId_integration: {
userId: TEST_USER_ID,
integration: "withings",
},
},
});
expect(row).not.toBeNull();
expect(row!.state).toBe("connected");
expect(row!.consecutiveFailures).toBe(0);
expect(row!.consecutiveFailuresByKind).toEqual({
transient: 0,
reauth_required: 0,
persistent: 0,
});
expect(row!.persistentFailureStartedAt).toBeNull();
expect(row!.lastError).toBeNull();
// `isReauthRequired` no longer short-circuits the sync entry-point
// — the next cron tick will attempt the upstream call.
expect(await isReauthRequired(TEST_USER_ID, "withings")).toBe(false);
});
it("recordSyncFailure increments only the matching per-kind bucket", async () => {
// First write — transient bucket goes to 1.
await recordSyncFailure({
userId: TEST_USER_ID,
integration: "withings",
kind: "transient",
message: "Withings refresh error: 503",
errorCode: "503",
});
let row = await getPrismaClient().integrationStatus.findUnique({
where: {
userId_integration: {
userId: TEST_USER_ID,
integration: "withings",
},
},
});
expect(row!.consecutiveFailuresByKind).toMatchObject({
transient: 1,
reauth_required: 0,
persistent: 0,
});
// Second write — persistent failure ticks ONLY the persistent
// bucket. The transient bucket is preserved at 1 (this is the
// whole point of the per-kind counter — a transient hiccup must
// not mask the persistent streak's true age).
await recordSyncFailure({
userId: TEST_USER_ID,
integration: "withings",
kind: "persistent",
message: "Withings measure error: 293",
errorCode: "293",
});
row = await getPrismaClient().integrationStatus.findUnique({
where: {
userId_integration: {
userId: TEST_USER_ID,
integration: "withings",
},
},
});
expect(row!.consecutiveFailuresByKind).toMatchObject({
transient: 1,
reauth_required: 0,
persistent: 1,
});
// First persistent failure stamps the streak anchor.
expect(row!.persistentFailureStartedAt).toBeInstanceOf(Date);
});
it("CASCADE delete on User removes the IntegrationStatus row", async () => {
await recordSyncFailure({
userId: TEST_USER_ID,