Merge branch 'fix/v14311-integ' into release/v1.43.11

This commit is contained in:
Marc-André Bombeck committed 2026-10-21 14:13:37 +02:00
commit eaaff23858
88 files changed
+3311 -384

No files matched your search

@@ -56,6 +56,7 @@ import { GET } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
function connectRequest(): NextRequest {
return new NextRequest("http://localhost/api/fitbit/connect", {
@@ -112,4 +113,16 @@ describe("GET /api/fitbit/connect", () => {
expect(location).toContain("reason=nocreds");
expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled();
});
it("redirects to reason=retired once Google has turned the Web API off", async () => {
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
const response = await GET(connectRequest());
const location = response.headers.get("location") ?? "";
expect(location).toContain("fitbit=error");
expect(location).toContain("reason=retired");
expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled();
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
});
});
+10
View File
@@ -12,6 +12,7 @@ import {
import { NextRequest, NextResponse } from "next/server";
import { relativeRedirect } from "@/lib/http/relative-redirect";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
/**
* Redirect the user to the Fitbit OAuth consent page (classic
@@ -57,6 +58,15 @@ export const GET = apiHandler(async (req: NextRequest) => {
);
}
// A grant issued now would never sync anything: Google has turned the Web
// API off. The card explains the move to Google Health.
if (isFitbitRetired()) {
annotate({ action: { name: "fitbit.connect.retired" } });
return relativeRedirect(
"/settings/integrations?fitbit=error&reason=retired",
);
}
const creds = await getUserFitbitCredentials(user.id);
if (!creds) {
annotate({ action: { name: "fitbit.connect.no_credentials" } });
@@ -0,0 +1,68 @@
/**
* POST /api/fitbit/sync once Google has turned the Fitbit Web API off: a
* clear 410 naming the cause, and no sync attempt behind it.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import type * as ApiHandlerModule from "@/lib/api-handler";
const { syncUserFitbitMock } = vi.hoisted(() => ({
syncUserFitbitMock: vi.fn(),
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof ApiHandlerModule>("@/lib/api-handler");
return {
...actual,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
};
});
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/fitbit/sync", () => ({ syncUserFitbit: syncUserFitbitMock }));
import { POST } from "../route";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
const post = POST as unknown as (r: NextRequest) => Promise<Response>;
function request(): NextRequest {
return new NextRequest("http://localhost/api/fitbit/sync", {
method: "POST",
});
}
beforeEach(() => {
vi.clearAllMocks();
syncUserFitbitMock.mockResolvedValue({ imported: 2, failed: false });
});
describe("POST /api/fitbit/sync", () => {
it("syncs while the Web API is still up", async () => {
const response = await post(request());
expect(response.status).toBe(200);
expect(syncUserFitbitMock).toHaveBeenCalledWith("u1", {
fullSync: false,
});
});
it("answers 410 provider_retired after the turndown and syncs nothing", async () => {
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
const response = await post(request());
expect(response.status).toBe(410);
const body = (await response.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("provider_retired");
expect(body.error).toMatch(/Google Health/);
expect(syncUserFitbitMock).not.toHaveBeenCalled();
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
});
});
+9
View File
@@ -4,6 +4,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { readSyncTriggerBody } from "@/lib/integrations/sync-request";
import { syncUserFitbit } from "@/lib/fitbit/sync";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
import { NextRequest } from "next/server";
@@ -31,6 +32,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
if (isFitbitRetired()) {
return apiError(
"Google has turned the Fitbit Web API off. Connect Google Health instead.",
410,
{ errorCode: "provider_retired" },
);
}
const body = await readSyncTriggerBody(request);
if (body.error) return body.error;
const { fullSync } = body;
@@ -36,6 +36,7 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { getValidToken } from "@/lib/fitbit/sync-core";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
interface ApiErrorEnvelope {
data: null;
@@ -109,6 +110,22 @@ describe("POST /api/integrations/fitbit/test", () => {
expect(global.fetch).not.toHaveBeenCalled();
});
it("answers 410 provider_retired once Google has turned the Web API off", async () => {
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(410);
const body = (await response.json()) as ApiErrorEnvelope & {
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("provider_retired");
expect(getValidToken).not.toHaveBeenCalled();
expect(global.fetch).not.toHaveBeenCalled();
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
});
it("returns 422 when no Fitbit connection exists", async () => {
vi.mocked(getValidToken).mockResolvedValueOnce(null);
const response = await POST(emptyRequest() as never);
@@ -7,6 +7,7 @@ import { prisma } from "@/lib/db";
import { getValidToken } from "@/lib/fitbit/sync-core";
import { FITBIT_API_BASE } from "@/lib/fitbit/client";
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
export const dynamic = "force-dynamic";
@@ -55,6 +56,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
if (isFitbitRetired()) {
return apiError(
"Google has turned the Fitbit Web API off. Connect Google Health instead.",
410,
{ errorCode: "provider_retired" },
);
}
const tokenInfo = await getValidToken(user.id);
if (!tokenInfo) {
return apiError("Fitbit not connected", 422, {
+7
View File
@@ -57,6 +57,10 @@ import {
type GoogleHealthBackfillEndedEntry,
} from "@/lib/google-health/backfill-progress";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import {
fitbitRetirement,
type FitbitRetirement,
} from "@/lib/fitbit/retirement";
export const dynamic = "force-dynamic";
@@ -378,6 +382,7 @@ export const GET = apiHandler(async () => {
? fitbitConn.tokenExpiresAt.getTime() <= now
: null,
backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null,
retirement: fitbitRetirement(),
health: health["fitbit"],
...resolve("fitbit", fitbitStatus, {
connected: fitbitConnected,
@@ -558,6 +563,8 @@ interface FitbitExtras {
tokenExpiresAt: string | null;
tokenExpired: boolean | null;
backfillCompleted: boolean | null;
/** The end of the legacy Fitbit Web API (`@/lib/fitbit/retirement`). */
retirement: FitbitRetirement;
}
// v1.27.0 — Google Health mirrors the Fitbit shape plus `needsReauth`: Google
+14 -3
View File
@@ -67,13 +67,21 @@ beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(requireAuth).mockResolvedValue({ user: { id: "u1" } } as never);
syncUserPolarMock.mockResolvedValue({ imported: 0, failed: false });
syncUserPolarMock.mockResolvedValue({
imported: 0,
failed: false,
tokenProven: true,
});
syncUserPolarWorkoutsMock.mockResolvedValue(0);
});
describe("POST /api/polar/sync", () => {
it("runs both Polar legs and totals what they imported", async () => {
syncUserPolarMock.mockResolvedValue({ imported: 5, failed: false });
syncUserPolarMock.mockResolvedValue({
imported: 5,
failed: false,
tokenProven: true,
});
syncUserPolarWorkoutsMock.mockResolvedValue(2);
const response = await post(request());
@@ -84,7 +92,10 @@ describe("POST /api/polar/sync", () => {
error: null,
});
expect(syncUserPolarMock).toHaveBeenCalledWith("u1");
expect(syncUserPolarWorkoutsMock).toHaveBeenCalledWith("u1");
// The vitals leg tells the workouts leg the token just worked.
expect(syncUserPolarWorkoutsMock).toHaveBeenCalledWith("u1", {
tokenProven: true,
});
});
it("does not call a run whose vitals collections all failed a success", async () => {
@@ -191,14 +191,16 @@ describe("POST /api/whoop/webhook/[token]", () => {
expect(getGlobalBoss).not.toHaveBeenCalled();
});
it("soft-deletes matching rows on a `*.deleted` event", async () => {
it("soft-deletes the recovery's own rows on a `recovery.deleted` event", async () => {
vi.mocked(prisma.whoopConnection.findUnique).mockResolvedValue({
userId: "user-1",
} as never);
// The v2 recovery webhook id is the associated sleep's UUID, which also
// prefixes the night's sleep rows — the match names the recovery's ids.
const req = signedRequest({
user_id: 42,
id: "rec-uuid",
id: "sleep-uuid",
type: "recovery.deleted",
});
const res = await POST(req, ctx(SECRET));
@@ -208,7 +210,15 @@ describe("POST /api/whoop/webhook/[token]", () => {
where: {
userId: "user-1",
source: "WHOOP",
externalId: { startsWith: "rec-uuid:" },
externalId: {
in: [
"sleep-uuid:recovery",
"sleep-uuid:hrv_rmssd",
"sleep-uuid:rhr",
"sleep-uuid:spo2",
"sleep-uuid:skin_temp",
],
},
deletedAt: null,
},
data: { deletedAt: expect.any(Date), syncVersion: { increment: 1 } },
@@ -246,8 +246,18 @@ describe("POST /api/workouts/batch — rate limit buckets", () => {
describe("POST /api/workouts/batch — what a scoped caller writes", () => {
it("attributes its rows to EXTERNAL", async () => {
armToken([WORKOUTS_WRITE_SCOPE]);
// Two sessions of the day: EXTERNAL ranks on the workout ladder, so two
// rows of one session would land once.
const res = await POST(
makeRequest({ workouts: [workout("s-1"), workout("s-2")] }),
makeRequest({
workouts: [
workout("s-1"),
workout("s-2", {
startedAt: "2026-09-20T18:00:00.000Z",
endedAt: "2026-09-20T18:45:00.000Z",
}),
],
}),
);
expect(res.status).toBe(200);
const rows = insertedRows();