mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
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 });
|
||||
});
|
||||
Reference in new issue
Block a user