mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(whoop): add connection test and resume-from-park parity with Withings
Mirror the Withings integration actions on the WHOOP card. A new POST /api/integrations/whoop/test runs a live, rate-limited probe against the WHOOP basic-profile endpoint, refreshing the token via getValidToken and classifying HTTP/transport failures into the same errorCode taxonomy the Withings test route uses. A new POST /api/integrations/whoop/resume clears a parked integration via resumeIntegrationFromPark for the whoop provider. The settings WhoopCard gains the shared Test-connection button, the parked-state resume banner, and switches the full-sync icon to RefreshCw for visual parity. Sync-now and sync-all are unchanged.
This commit is contained in:
1 parent
74aa41519f
commit
6f378c8584
4 files changed
+462
-1
No files matched your search
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Manual resume endpoint for a parked WHOOP integration.
|
||||
*
|
||||
* Background: the `parked` state lives in
|
||||
* `src/lib/integrations/status.ts`. Once a WHOOP persistent-failure
|
||||
* streak 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/whoop/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 { 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 () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "integrations.whoop.resume" } });
|
||||
|
||||
const rl = await checkRateLimit(`whoop-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, "whoop");
|
||||
annotate({
|
||||
meta: {
|
||||
whoop_resume_was_parked: result.wasParked,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ resumed: true, wasParked: result.wasParked });
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } 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/db", () => ({
|
||||
prisma: {
|
||||
whoopConnection: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((x: string) => x),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/whoop/sync", () => ({
|
||||
getValidToken: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getValidToken } from "@/lib/whoop/sync";
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
error: null;
|
||||
}
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.whoopConnection.findUnique).mockReset();
|
||||
vi.mocked(getValidToken).mockReset();
|
||||
global.fetch = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/integrations/whoop/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/integrations/whoop/test", () => {
|
||||
it("happy path returns ok with shape", async () => {
|
||||
const lastSyncedAt = new Date("2026-05-01T10:00:00.000Z");
|
||||
vi.mocked(getValidToken).mockResolvedValueOnce({
|
||||
accessToken: "plain-access",
|
||||
connection: { id: "wc-1", whoopUserId: "wu-1" },
|
||||
});
|
||||
vi.mocked(prisma.whoopConnection.findUnique).mockResolvedValueOnce({
|
||||
lastSyncedAt,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ user_id: 1, first_name: "x" }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
lastSyncedAt: string;
|
||||
latencyMs: number;
|
||||
}>;
|
||||
expect(body.data.ok).toBe(true);
|
||||
expect(typeof body.data.latencyMs).toBe("number");
|
||||
expect(body.data.lastSyncedAt).toBe(lastSyncedAt.toISOString());
|
||||
});
|
||||
|
||||
it("rate-limit denial returns 429 with no upstream call", 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);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when no WHOOP connection exists", async () => {
|
||||
vi.mocked(getValidToken).mockResolvedValueOnce(null);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/not connected/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("classifies 401 upstream as credentials_rejected", async () => {
|
||||
vi.mocked(getValidToken).mockResolvedValueOnce({
|
||||
accessToken: "sk-secret",
|
||||
connection: { id: "wc-1", whoopUserId: "wu-1" },
|
||||
});
|
||||
vi.mocked(prisma.whoopConnection.findUnique).mockResolvedValueOnce({
|
||||
lastSyncedAt: null,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 }),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(502);
|
||||
const body = (await response.json()) as ApiErrorEnvelope & {
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("credentials_rejected");
|
||||
});
|
||||
|
||||
it("classifies 429 upstream as rate_limited", async () => {
|
||||
vi.mocked(getValidToken).mockResolvedValueOnce({
|
||||
accessToken: "plain-access",
|
||||
connection: { id: "wc-1", whoopUserId: "wu-1" },
|
||||
});
|
||||
vi.mocked(prisma.whoopConnection.findUnique).mockResolvedValueOnce({
|
||||
lastSyncedAt: null,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: "rate limited" }), { status: 429 }),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(502);
|
||||
const body = (await response.json()) as ApiErrorEnvelope & {
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("rate_limited");
|
||||
});
|
||||
|
||||
it("classifies 500 upstream as upstream_error", async () => {
|
||||
vi.mocked(getValidToken).mockResolvedValueOnce({
|
||||
accessToken: "plain-access",
|
||||
connection: { id: "wc-1", whoopUserId: "wu-1" },
|
||||
});
|
||||
vi.mocked(prisma.whoopConnection.findUnique).mockResolvedValueOnce({
|
||||
lastSyncedAt: null,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response("oops", { status: 503 }),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(502);
|
||||
const body = (await response.json()) as ApiErrorEnvelope & {
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("upstream_error");
|
||||
});
|
||||
|
||||
it("does not leak Bearer token or upstream URL on failure", async () => {
|
||||
vi.mocked(getValidToken).mockResolvedValueOnce({
|
||||
accessToken: "sk-secret",
|
||||
connection: { id: "wc-1", whoopUserId: "wu-1" },
|
||||
});
|
||||
vi.mocked(prisma.whoopConnection.findUnique).mockResolvedValueOnce({
|
||||
lastSyncedAt: null,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: "Authorization: Bearer sk-secret echoed",
|
||||
url: "https://api.prod.whoop.com/developer/v2/user/profile/basic",
|
||||
}),
|
||||
{ status: 401 },
|
||||
),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const text = await response.text();
|
||||
expect(response.status).toBe(502);
|
||||
expect(text).not.toMatch(/sk-/);
|
||||
expect(text).not.toMatch(/api\.prod\.whoop\.com/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
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 { prisma } from "@/lib/db";
|
||||
import { getValidToken } from "@/lib/whoop/sync";
|
||||
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Cheap authenticated probe — the single (non-paginated) basic-profile
|
||||
// endpoint the WHOOP client already calls via `fetchProfile`.
|
||||
// Base from `src/lib/whoop/client.ts` (`WHOOP_API_BASE`).
|
||||
const WHOOP_PROFILE_URL =
|
||||
"https://api.prod.whoop.com/developer/v2/user/profile/basic";
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
type CategorisedError = { code: string; message: string };
|
||||
|
||||
function categoriseHttpStatus(status: number): CategorisedError {
|
||||
if (status === 401 || status === 403) {
|
||||
return {
|
||||
code: "credentials_rejected",
|
||||
message: "WHOOP rejected the credentials",
|
||||
};
|
||||
}
|
||||
if (status === 429) {
|
||||
return {
|
||||
code: "rate_limited",
|
||||
message: "WHOOP rate-limited the request",
|
||||
};
|
||||
}
|
||||
if (status >= 500) {
|
||||
return {
|
||||
code: "upstream_error",
|
||||
message: "WHOOP returned a server error",
|
||||
};
|
||||
}
|
||||
return { code: "connection_failed", message: "WHOOP connection failed" };
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
void request;
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "integrations.whoop.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`whoop-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const tokenInfo = await getValidToken(user.id);
|
||||
if (!tokenInfo) {
|
||||
return apiError("WHOOP not connected", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const connection = await prisma.whoopConnection.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { lastSyncedAt: true },
|
||||
});
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await safeFetch(
|
||||
WHOOP_PROFILE_URL,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenInfo.accessToken}`,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: TIMEOUT_MS },
|
||||
);
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
if (!res.ok) {
|
||||
const cat = categoriseHttpStatus(res.status);
|
||||
annotate({
|
||||
meta: {
|
||||
whoop_test_status: res.status,
|
||||
whoop_test_code: cat.code,
|
||||
whoop_test_latency_ms: latencyMs,
|
||||
},
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
lastSyncedAt: connection?.lastSyncedAt ?? null,
|
||||
latencyMs,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
// safeFetch wraps timeouts into SafeFetchError{kind:"timeout"};
|
||||
// legacy AbortError name is preserved for compatibility with any
|
||||
// future direct-fetch path that still reaches this catch.
|
||||
const isAbort =
|
||||
(e instanceof SafeFetchError && e.kind === "timeout") ||
|
||||
err.name === "AbortError";
|
||||
const code = isAbort ? "timeout" : "connection_failed";
|
||||
annotate({
|
||||
meta: {
|
||||
whoop_test_code: code,
|
||||
whoop_test_error: redact(err.message).slice(0, 300),
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
isAbort ? "WHOOP request timed out" : "WHOOP connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Strip any `Bearer <token>` substring before annotating; the catch path can
|
||||
// see DNS / TLS errors that include the request init via err.cause in some
|
||||
// runtimes, and we never want a token in Loki.
|
||||
function redact(text: string): string {
|
||||
return text.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]");
|
||||
}
|
||||
@@ -749,6 +749,30 @@ function WhoopCard({
|
||||
},
|
||||
});
|
||||
|
||||
// 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/whoop/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.whoop() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
async function handleSync(fullSync = false) {
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
@@ -851,6 +875,57 @@ function WhoopCard({
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
{/* Parked-integration resume CTA. Surfaces only when the row
|
||||
state is `parked` (>24h of persistent failures). The button
|
||||
POSTs to /api/integrations/whoop/resume which calls
|
||||
`resumeIntegrationFromPark`; on success the per-card status
|
||||
invalidates and the pill flips back to connected without a
|
||||
page refresh. Wider tap target than the inline action row so
|
||||
it stays reachable on a Pixel 5 viewport. */}
|
||||
{pillState === "parked" && (
|
||||
<div
|
||||
data-testid="whoop-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="whoop-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="whoop-resume-error"
|
||||
>
|
||||
{t("settings.integrationPill.resumeError")}
|
||||
</p>
|
||||
)}
|
||||
{resume.isSuccess && resume.data?.wasParked && (
|
||||
<p
|
||||
role="status"
|
||||
className="text-dracula-green text-xs"
|
||||
data-testid="whoop-resume-success"
|
||||
>
|
||||
{t("settings.integrationPill.resumeSuccess")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
@@ -952,7 +1027,11 @@ function WhoopCard({
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={syncing}>
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
{syncing ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.whoopFullSync")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
@@ -973,6 +1052,10 @@ function WhoopCard({
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<TestConnectionButton
|
||||
endpoint="/api/integrations/whoop/test"
|
||||
disabled={!status?.connected}
|
||||
/>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
|
||||
Reference in new issue
Block a user