Files
HealthLog/tests/integration/fitbit-retirement.test.ts
T
Marc-André Bombeck ba1271275f feat(fitbit): stop at the Web API turndown and point to Google Health
Google turns the legacy Fitbit Web API off on 30 October 2026 (support
ended on 30 September). From that day the hourly poll, the backfill,
the sleep repair and the token refresh make no call, the connect, sync
and test routes refuse with `provider_retired`, and the connection
reads as not connected to the health read and the watchdog, so the
shutdown pages nobody and asks nobody to reconnect. The connection row
and everything it imported stay.

The status envelope publishes `retirement` on the Fitbit entry (the
day, whether it has come, the successor), and the Fitbit card shows
when the connection stops, how to move to Google Health (same devices,
one more consent, tokens cannot be carried over) and that the data
stays; sync and connect grey out after the day. Both integrations can
run side by side meanwhile: every type Fitbit writes is laddered with
Fitbit just above Google Health, so a day both deliver counts once.

The test setups pin the turndown off, so the Fitbit suites do not turn
red on the day; the turndown's own tests flip it.
2026-10-21 12:46:14 +02:00

114 lines
3.9 KiB
TypeScript

/**
* After Google turned the Fitbit Web API off, a Fitbit connection that has
* gone quiet is the provider's shutdown, not a broken pipe. Against real
* Postgres: the health read publishes no state for it (so no card or banner
* asks for a reconnect), the watchdog pages nobody, and the connection row
* and every imported reading stay.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/notifications/dispatcher", () => ({
dispatchNotification: vi.fn().mockResolvedValue({
dispatched: true,
channelsAttempted: 1,
channelsSucceeded: 1,
}),
}));
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { runIntegrationWatchdog } from "@/lib/integrations/watchdog";
import { loadConnectionHealth } from "@/lib/integrations/connection-health-load";
import { recordSyncSuccess } from "@/lib/integrations/status";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
const NOW = new Date("2026-11-03T12:00:00.000Z");
const USER = "user-fitbit-retired";
const ADMIN = "user-fitbit-retired-admin";
const prisma = () => getPrismaClient();
beforeEach(async () => {
await truncateAllTables(prisma());
await prisma().user.create({
data: {
id: ADMIN,
username: "fr-admin",
email: "admin@example.test",
role: "ADMIN",
},
});
await prisma().user.create({
data: { id: USER, username: "fr-subject", email: "subject@example.test" },
});
await prisma().fitbitConnection.create({
data: {
userId: USER,
fitbitUserId: "fb-1",
accessToken: "not-a-real-token",
refreshToken: "not-a-real-token",
tokenExpiresAt: new Date(NOW.getTime() - DAY),
lastSyncedAt: new Date(NOW.getTime() - 5 * DAY),
},
});
// A fortnight of daily steps that stopped with the turndown.
await prisma().measurement.createMany({
data: Array.from({ length: 14 }, (_, i) => ({
userId: USER,
type: "ACTIVITY_STEPS" as const,
value: 8000,
unit: "count",
source: "FITBIT" as const,
measuredAt: new Date(NOW.getTime() - (i + 5) * DAY),
externalId: `stats:day-${i}`,
})),
});
vi.mocked(dispatchNotification).mockClear();
// The watchdog has run on this instance before, so it pages on a fresh
// incident instead of recording what stands.
await prisma().integrationWatch.create({
data: { userId: ADMIN, integration: "withings" },
});
});
describe("Fitbit after the Web API turndown", () => {
it("still reads as a connection with a health state before the day", async () => {
await recordSyncSuccess(USER, "fitbit");
const health = await loadConnectionHealth(USER, ["fitbit"]);
expect(health.fitbit).not.toBeNull();
});
it("publishes no health, pages nobody, and keeps the row and the data", async () => {
await recordSyncSuccess(USER, "fitbit");
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
const health = await loadConnectionHealth(USER, ["fitbit"]);
expect(health.fitbit).toBeNull();
await runIntegrationWatchdog({ mode: "hourly", now: NOW });
await runIntegrationWatchdog({ mode: "daily", now: NOW });
expect(dispatchNotification).not.toHaveBeenCalled();
const watch = await prisma().integrationWatch.findUnique({
where: { userId_integration: { userId: USER, integration: "fitbit" } },
});
expect(watch?.alertedAt ?? null).toBeNull();
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
expect(
await prisma().fitbitConnection.count({ where: { userId: USER } }),
).toBe(1);
expect(
await prisma().measurement.count({
where: { userId: USER, source: "FITBIT" },
}),
).toBe(14);
});
});