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

@@ -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 });
});