test(fitbit): pin the dead-token verdict and the verdict-gated backfill

New sync-dead-token suite mirrors the Google Health parity test: the three
getValidToken null-return paths each register on the hard-fail ledger, a
successful refresh leaves it empty, and a dead-token cycle returns
{ imported: 0, failed: true } without stamping markSynced or recordSyncSuccess.
Extends the upsert suite with the two write-catch labels (measurements:create,
measurements:update) and the sync orchestrator suite with a write-label
watermark hold. Rewrites the backfill suite to mirror google-health-backfill:
a clean run stamps, a failed verdict throws without stamping, a parked
connection returns without running the sync.
This commit is contained in:
Marc-André Bombeck committed 2026-07-24 12:52:17 +02:00
1 parent 25b7ada8bf
commit da8dfe4256
4 files changed
+404 -23

No files matched your search

@@ -0,0 +1,243 @@
/**
* Pins the dead-token verdict for Fitbit — the parity mirror of
* `google-health/__tests__/sync-failsoft.test.ts`'s "getValidToken —
* dead-token cycle verdict" block.
*
* `getValidToken`'s three null-return paths (credentials missing, refresh
* failure, and the Fitbit-only vanished-connection CAS loss) must register on
* the cycle's hard-fail ledger, and a cycle whose token is dead must NOT stamp
* `markSynced` / `recordSyncSuccess`. Otherwise every resource returns 0
* without failures, the cycle reads as clean, `recordSyncSuccess` un-parks
* `error_reauth`, and the hourly cohort hammers the dead refresh token forever
* while `lastSyncedAt` advances past real data.
*
* Fitbit has no one-shot leaf job, so no `runWith…HardFailLedger` wrapper is
* ported; the tests scope the ledger through the exported `hardFailStorage.run`
* directly.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
recordSyncFailureMock,
recordSyncSuccessMock,
prismaMock,
getUserFitbitCredentialsMock,
refreshAccessTokenMock,
resourceFake,
} = vi.hoisted(() => ({
recordSyncFailureMock: vi.fn(async () => {}),
recordSyncSuccessMock: vi.fn(async () => {}),
prismaMock: {
fitbitConnection: {
findUnique: vi.fn(),
update: vi.fn(async () => ({})),
updateMany: vi.fn(async () => ({ count: 1 })),
},
},
getUserFitbitCredentialsMock: vi.fn(async (): Promise<unknown> => null),
refreshAccessTokenMock: vi.fn(),
// The dead-token cycle test drives the real `syncUserFitbit`; each resource
// module is mocked to do exactly what the real ones do first — resolve the
// token — so the ledger registration happens inside the cycle's ALS scope.
resourceFake: async (userId: string): Promise<number> => {
const { getValidToken } = await import("../sync-core");
return (await getValidToken(userId)) ? 1 : 0;
},
}));
vi.mock("@/lib/db", () => ({ prisma: prismaMock }));
vi.mock("@/lib/crypto", () => ({
encrypt: (s: string) => `enc(${s})`,
decrypt: (s: string) => s.replace(/^enc\(|\)$/g, ""),
}));
vi.mock("@/lib/integrations/status", () => ({
isReauthRequired: vi.fn(async () => false),
recordSyncFailure: recordSyncFailureMock,
recordSyncSuccess: recordSyncSuccessMock,
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
collapseToTypeDayKeys: vi.fn(() => []),
recomputeBucketsForMeasurement: vi.fn(async () => {}),
recomputeUserRollups: vi.fn(async () => {}),
}));
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => null,
annotate: () => {},
}));
vi.mock("../credentials", () => ({
getUserFitbitCredentials: getUserFitbitCredentialsMock,
}));
vi.mock("../client", () => ({ refreshAccessToken: refreshAccessTokenMock }));
vi.mock("../sync-metrics", () => ({ syncUserMetrics: resourceFake }));
vi.mock("../sync-activity", () => ({ syncUserActivity: resourceFake }));
vi.mock("../sync-sleep", () => ({ syncUserSleep: resourceFake }));
vi.mock("../sync-workout", () => ({ syncUserWorkout: resourceFake }));
import {
FITBIT_TOKEN_HARD_FAIL,
getValidToken,
hardFailStorage,
} from "../sync-core";
import { syncUserFitbit } from "../sync";
import { FitbitApiError } from "../response-classifier";
/** Run `fn` inside a fresh hard-fail ledger scope and return its failures. */
async function withLedger<T>(
fn: () => Promise<T>,
): Promise<{ result: T; failures: string[] }> {
const tracker = { failures: [] as string[] };
const result = await hardFailStorage.run(tracker, fn);
return { result, failures: tracker.failures };
}
/** A connection whose access token is inside the 5-min refresh buffer. */
const EXPIRED_CONNECTION = {
id: "conn-1",
userId: "user-1",
fitbitUserId: "fb-user-1",
accessToken: "enc(access)",
refreshToken: "enc(refresh)",
tokenExpiresAt: new Date(Date.now() - 60_000),
lastSyncedAt: new Date("2026-07-01T00:00:00.000Z"),
};
const CREDS = { clientId: "id", clientSecret: "secret" };
beforeEach(() => {
recordSyncFailureMock.mockClear();
recordSyncSuccessMock.mockClear();
prismaMock.fitbitConnection.findUnique.mockReset();
prismaMock.fitbitConnection.update.mockReset().mockResolvedValue({} as never);
prismaMock.fitbitConnection.updateMany
.mockReset()
.mockResolvedValue({ count: 1 } as never);
getUserFitbitCredentialsMock.mockReset().mockResolvedValue(null);
refreshAccessTokenMock.mockReset();
});
describe("getValidToken — dead-token cycle verdict", () => {
it("registers the credentials-missing path on the hard-fail ledger", async () => {
prismaMock.fitbitConnection.findUnique.mockResolvedValue(
EXPIRED_CONNECTION as never,
);
const { result, failures } = await withLedger(() =>
getValidToken("user-1"),
);
expect(result).toBeNull();
expect(failures).toEqual([FITBIT_TOKEN_HARD_FAIL]);
expect(recordSyncFailureMock).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
integration: "fitbit",
kind: "reauth_required",
errorCode: "credentials_missing",
}),
);
});
it("registers a refresh failure on the hard-fail ledger", async () => {
prismaMock.fitbitConnection.findUnique.mockResolvedValue(
EXPIRED_CONNECTION as never,
);
getUserFitbitCredentialsMock.mockResolvedValue(CREDS);
refreshAccessTokenMock.mockRejectedValue(
new FitbitApiError({
verb: "refreshToken",
classification: "reauth_required",
httpStatus: 401,
reason: "invalid_grant",
}),
);
const { result, failures } = await withLedger(() =>
getValidToken("user-1"),
);
expect(result).toBeNull();
expect(failures).toEqual([FITBIT_TOKEN_HARD_FAIL]);
expect(recordSyncFailureMock).toHaveBeenCalledWith(
expect.objectContaining({ kind: "reauth_required" }),
);
});
it("registers a vanished-connection CAS loss on the ledger", async () => {
// Fitbit-only path (no Google twin): the CAS update touches zero rows AND
// the peer re-read finds no row — the connection was deleted mid-flight.
prismaMock.fitbitConnection.findUnique
.mockResolvedValueOnce(EXPIRED_CONNECTION as never) // initial read
.mockResolvedValueOnce(null as never); // peer re-read: row gone
getUserFitbitCredentialsMock.mockResolvedValue(CREDS);
refreshAccessTokenMock.mockResolvedValue({
access_token: "new-access",
refresh_token: "rotated-refresh",
expires_in: 3600,
});
prismaMock.fitbitConnection.updateMany.mockResolvedValue({
count: 0,
} as never);
const { result, failures } = await withLedger(() =>
getValidToken("user-1"),
);
expect(result).toBeNull();
expect(failures).toEqual([FITBIT_TOKEN_HARD_FAIL]);
});
it("a successful refresh leaves the ledger empty", async () => {
// Guard against over-failing: a healthy refresh must not touch the ledger.
prismaMock.fitbitConnection.findUnique.mockResolvedValue(
EXPIRED_CONNECTION as never,
);
getUserFitbitCredentialsMock.mockResolvedValue(CREDS);
refreshAccessTokenMock.mockResolvedValue({
access_token: "new-access",
refresh_token: "rotated-refresh",
expires_in: 3600,
});
prismaMock.fitbitConnection.updateMany.mockResolvedValue({
count: 1,
} as never);
const { result, failures } = await withLedger(() =>
getValidToken("user-1"),
);
expect(result?.accessToken).toBe("new-access");
expect(failures).toEqual([]);
});
it("a dead-token cycle fails the verdict and never stamps markSynced / recordSyncSuccess", async () => {
// Every resource resolves the token, the refresh fails each time — before
// the ledger registration the cycle read as CLEAN (all resources returned
// 0 without failures), stamped the watermark, and un-parked error_reauth.
prismaMock.fitbitConnection.findUnique.mockResolvedValue(
EXPIRED_CONNECTION as never,
);
getUserFitbitCredentialsMock.mockResolvedValue(CREDS);
refreshAccessTokenMock.mockRejectedValue(
new FitbitApiError({
verb: "refreshToken",
classification: "reauth_required",
httpStatus: 401,
reason: "invalid_grant",
}),
);
const res = await syncUserFitbit("user-1");
expect(res).toEqual({ imported: 0, failed: true });
expect(recordSyncSuccessMock).not.toHaveBeenCalled();
// No connection write may carry the watermark stamp.
for (const call of prismaMock.fitbitConnection.update.mock.calls) {
const arg = (call as unknown[])[0] as { data: Record<string, unknown> };
expect(arg.data).not.toHaveProperty("lastSyncedAt");
}
});
});
@@ -92,6 +92,7 @@ vi.mock("../sync-workout", () => ({
import {
handleCollectionFetchError,
noteHardFailure,
upsertFitbitMeasurements,
} from "../sync-core";
import { syncUserFitbit } from "../sync";
@@ -139,7 +140,7 @@ describe("syncUserFitbit — all-403 looks-healthy guard", () => {
syncUserSleep.mockImplementation(softSkip403);
syncUserWorkout.mockImplementation(softSkip403);
const total = await syncUserFitbit("user1");
const { imported: total } = await syncUserFitbit("user1");
expect(total).toBe(0);
expect(recordSyncSuccess).not.toHaveBeenCalled();
@@ -155,7 +156,7 @@ describe("syncUserFitbit — all-403 looks-healthy guard", () => {
syncUserSleep.mockImplementation(softSkip403);
syncUserWorkout.mockImplementation(softSkip403);
const total = await syncUserFitbit("user1");
const { imported: total } = await syncUserFitbit("user1");
expect(total).toBe(5);
expect(recordSyncSuccess).toHaveBeenCalledWith("user1", "fitbit");
@@ -165,16 +166,16 @@ describe("syncUserFitbit — all-403 looks-healthy guard", () => {
syncUserMetrics.mockResolvedValue(3);
syncUserActivity.mockResolvedValue(2);
const total = await syncUserFitbit("user1");
const res = await syncUserFitbit("user1");
expect(total).toBe(5);
expect(res).toEqual({ imported: 5, failed: false });
expect(recordSyncSuccess).toHaveBeenCalledWith("user1", "fitbit");
});
it("stamps success on a quiet cycle (no soft-skip, nothing new)", async () => {
syncUserMetrics.mockResolvedValue(0);
const total = await syncUserFitbit("user1");
const { imported: total } = await syncUserFitbit("user1");
expect(total).toBe(0);
// No collection was 403'd → this is a genuine "nothing changed" tick.
@@ -184,7 +185,7 @@ describe("syncUserFitbit — all-403 looks-healthy guard", () => {
it("short-circuits without stamping success when parked at error_reauth", async () => {
isReauthRequired.mockResolvedValue(true);
const total = await syncUserFitbit("user1");
const { imported: total } = await syncUserFitbit("user1");
expect(total).toBe(0);
expect(recordSyncSuccess).not.toHaveBeenCalled();
@@ -219,7 +220,7 @@ describe("syncUserFitbit — hard-fail ledger (F-SYNC-4)", () => {
syncUserWorkout.mockResolvedValue(0);
// Resolves (no rethrow) with the siblings' imports.
const total = await syncUserFitbit("user1");
const { imported: total } = await syncUserFitbit("user1");
expect(total).toBe(3);
// Every sibling resource still ran despite the metrics hard failure.
@@ -243,6 +244,27 @@ describe("syncUserFitbit — hard-fail ledger (F-SYNC-4)", () => {
expect(recordSyncFailure).toHaveBeenCalled();
});
it("a write-failure ledger entry holds the watermark and fails the verdict", async () => {
// A resource that fetched rows but failed to write them notes the write
// label on the ledger and returns 0. The F-SYNC-4 test above covers a
// collection label; this pins the WRITE label (F-2) through the same gate.
syncUserMetrics.mockImplementation(async () => {
noteHardFailure("measurements:create");
return 0;
});
// A sibling imports so the run is not degenerate — only the ledger keeps it
// honest.
syncUserActivity.mockResolvedValue(4);
const { imported, failed } = await syncUserFitbit("user1");
expect(imported).toBe(4);
expect(failed).toBe(true);
// The write failure holds the watermark: no stamp, no success.
expect(update).not.toHaveBeenCalled();
expect(recordSyncSuccess).not.toHaveBeenCalled();
});
it("a failed natural-key rescue probe notes the ledger: no success stamp, no watermark", async () => {
// The rescue probe (second measurement.findMany inside the upsert) dies.
// The rows still go to createMany (skipDuplicates absorbs a twin
@@ -270,7 +292,7 @@ describe("syncUserFitbit — hard-fail ledger (F-SYNC-4)", () => {
});
// Resolves (no rethrow) — the insert was still attempted.
const total = await syncUserFitbit("user1");
const { imported: total } = await syncUserFitbit("user1");
expect(total).toBe(1);
// The ledger flips anyFailed → success is NOT stamped and the watermark is
@@ -424,7 +446,7 @@ describe("syncUserFitbit — backfill collapses rollup recompute to one pass", (
it("returns 0 without running resources when the connection is gone", async () => {
findUnique.mockResolvedValue(null);
const total = await syncUserFitbit("user1");
const { imported: total } = await syncUserFitbit("user1");
expect(total).toBe(0);
expect(syncUserMetrics).not.toHaveBeenCalled();
+92
View File
@@ -77,6 +77,7 @@ vi.mock("@/lib/integrations/status", () => ({
import {
classificationToFailureKind,
getValidToken,
hardFailStorage,
upsertFitbitMeasurements,
} from "../sync-core";
@@ -430,6 +431,97 @@ describe("upsertFitbitMeasurements — batched write, tombstones resurrect", ()
});
});
describe("upsertFitbitMeasurements — write-catch ledger (F-2)", () => {
it("a createMany failure notes measurements:create on the ledger and does not throw", async () => {
const measuredAt = new Date("2026-05-10T07:00:00.000Z");
// Probe: one live row (its externalId) → update branch; the other key is
// fresh → create branch. Second findMany is the natural-key rescue probe.
prismaMock.measurement.findMany
.mockResolvedValueOnce([
{ id: "m-live", type: "WEIGHT", externalId: "live-key" },
])
.mockResolvedValueOnce([]); // natural-key rescue: no twin
prismaMock.measurement.createManyAndReturn.mockRejectedValueOnce(
new Error("createMany exploded"),
);
const tracker = { failures: [] as string[] };
const { imported } = await hardFailStorage.run(tracker, () =>
upsertFitbitMeasurements("user1", [
{
type: "WEIGHT",
value: 1,
unit: "kg",
measuredAt,
externalId: "live-key",
},
{
type: "WEIGHT",
value: 2,
unit: "kg",
measuredAt,
externalId: "fresh-key",
},
]),
);
// No rethrow: the sibling live row still wrote through the update branch.
expect(prismaMock.measurement.update).toHaveBeenCalledTimes(1);
expect(imported).toBe(1);
// The failed create is recorded so the cycle's watermark holds.
expect(tracker.failures).toContain("measurements:create");
});
it("an update failure notes measurements:update on the ledger and does not throw", async () => {
const measuredAt = new Date("2026-05-10T07:00:00.000Z");
// Two live rows (both updates) + one fresh row (create). The first update
// throws; the loop must continue to the second, and the create still runs.
prismaMock.measurement.findMany
.mockResolvedValueOnce([
{ id: "m-live-1", type: "WEIGHT", externalId: "live-1" },
{ id: "m-live-2", type: "WEIGHT", externalId: "live-2" },
])
.mockResolvedValueOnce([]); // natural-key rescue: no twin
prismaMock.measurement.update
.mockRejectedValueOnce(new Error("update exploded"))
.mockResolvedValueOnce({});
const tracker = { failures: [] as string[] };
const { imported } = await hardFailStorage.run(tracker, () =>
upsertFitbitMeasurements("user1", [
{
type: "WEIGHT",
value: 1,
unit: "kg",
measuredAt,
externalId: "live-1",
},
{
type: "WEIGHT",
value: 2,
unit: "kg",
measuredAt,
externalId: "live-2",
},
{
type: "WEIGHT",
value: 3,
unit: "kg",
measuredAt,
externalId: "fresh-3",
},
]),
);
// No rethrow: the sibling update ran and the fresh create still wrote.
expect(prismaMock.measurement.update).toHaveBeenCalledTimes(2);
expect(prismaMock.measurement.createManyAndReturn).toHaveBeenCalledTimes(1);
// create (1) + the one surviving update (1) landed.
expect(imported).toBe(2);
expect(tracker.failures).toContain("measurements:update");
});
});
describe("classificationToFailureKind", () => {
it("maps each classification onto a FailureKind", () => {
expect(classificationToFailureKind("reauth_required")).toBe(
+38 -14
View File
@@ -7,16 +7,18 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const { prismaMock, bossSend, syncUserFitbit } = vi.hoisted(() => ({
prismaMock: {
fitbitConnection: {
findMany: vi.fn(),
update: vi.fn(),
const { prismaMock, bossSend, syncUserFitbit, isReauthRequiredMock } =
vi.hoisted(() => ({
prismaMock: {
fitbitConnection: {
findMany: vi.fn(),
update: vi.fn(),
},
},
},
bossSend: vi.fn(),
syncUserFitbit: vi.fn(),
}));
bossSend: vi.fn(),
syncUserFitbit: vi.fn(),
isReauthRequiredMock: vi.fn(async () => false),
}));
vi.mock("@/lib/db", () => ({ prisma: prismaMock }));
@@ -28,6 +30,10 @@ vi.mock("@/lib/fitbit/sync", () => ({
syncUserFitbit: (...a: unknown[]) => syncUserFitbit(...a),
}));
vi.mock("@/lib/integrations/status", () => ({
isReauthRequired: isReauthRequiredMock,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: () => {},
getEvent: () => null,
@@ -40,6 +46,8 @@ import {
beforeEach(() => {
vi.clearAllMocks();
isReauthRequiredMock.mockResolvedValue(false);
prismaMock.fitbitConnection.update.mockResolvedValue({});
});
describe("enqueueBootTimeFitbitBackfill — discovery", () => {
@@ -87,17 +95,33 @@ describe("enqueueBootTimeFitbitBackfill — discovery", () => {
});
});
describe("runFitbitBackfillForUser", () => {
it("runs a full sync and stamps backfillCompletedAt", async () => {
syncUserFitbit.mockResolvedValue(123);
prismaMock.fitbitConnection.update.mockResolvedValue({});
describe("runFitbitBackfillForUser — verdict-gated marker", () => {
it("stamps backfillCompletedAt after a CLEAN full-history run", async () => {
syncUserFitbit.mockResolvedValue({ imported: 42, failed: false });
const { imported } = await runFitbitBackfillForUser("u1");
expect(imported).toBe(123);
expect(imported).toBe(42);
expect(syncUserFitbit).toHaveBeenCalledWith("u1", { fullSync: true });
const updateArg = prismaMock.fitbitConnection.update.mock.calls[0]![0];
expect(updateArg.where).toEqual({ userId: "u1" });
expect(updateArg.data.backfillCompletedAt).toBeInstanceOf(Date);
});
it("a failed verdict THROWS without stamping — pg-boss retries become real", async () => {
syncUserFitbit.mockResolvedValue({ imported: 7, failed: true });
await expect(runFitbitBackfillForUser("u1")).rejects.toThrow(/incomplete/);
expect(prismaMock.fitbitConnection.update).not.toHaveBeenCalled();
});
it("a connection parked at error_reauth returns WITHOUT running the sync or stamping", async () => {
isReauthRequiredMock.mockResolvedValue(true);
await expect(runFitbitBackfillForUser("u1")).resolves.toEqual({
imported: 0,
});
expect(syncUserFitbit).not.toHaveBeenCalled();
expect(prismaMock.fitbitConnection.update).not.toHaveBeenCalled();
});
});