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:
Marc-André Bombeck committed 2026-06-04 19:13:47 +02:00
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]");
}