mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge branch 'fix/v14311-integ' into release/v1.43.11
This commit is contained in:
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user