Files
HealthLog/src/lib/withings/__tests__/sync-activity.test.ts
T
Marc-André Bombeck 34f847f12f fix(integrations): anchor synced daily totals at local noon in the user's zone
Withings, Fitbit, Google Health, Polar and Oura wrote each daily total and
daily summary at noon UTC of its date. From UTC+12 on (New Zealand all year,
Fiji, Tonga, Samoa) noon UTC is already the next local day, so every synced
step count, SpO2 or resting-pulse day showed up a day late. The rows now sit
at local noon of their date in the user's zone, and the fetch windows run
through the user's own today.

Fitbit weigh-ins carry a local wall clock without an offset and were read
as UTC; they are read in the user's zone now.

ExternalIds stay on the calendar date, so the next sync finds each row and
moves it in place instead of adding a second one, and the day a row leaves
is refolded. Rows inside each integration's re-read window heal on the next
sync; Fitbit and Google Health re-read the whole history on reconnect.
2026-09-26 15:58:02 +02:00

682 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.4.25 W17b — Withings activity-sync unit tests.
*
* Coverage focuses on the per-page response shape, field mapping, and
* the writer's idempotency hook. The end-to-end DB path is covered by
* the integration suite.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => {
// v1.20.2 F3 — the write path batches: one `findMany` resolves the
// overwrite-vs-insert decision for the whole sync, `createMany` lands
// every fresh slot, and changed-value overwrites run inside a
// `$transaction([...update])`. The mock `$transaction` awaits the array
// of update promises so the per-row `update` mocks still fire and the
// call counts assert the batched shape.
const measurement = {
findMany: vi.fn(),
createMany: vi.fn(),
update: vi.fn(),
};
return {
prisma: {
measurement,
withingsConnection: {
findUnique: vi.fn(),
},
$transaction: vi.fn((ops: unknown) =>
Array.isArray(ops) ? Promise.all(ops) : Promise.resolve(),
),
},
};
});
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn(),
}));
vi.mock("@/lib/integrations/status", () => ({
isReauthRequired: vi.fn().mockResolvedValue(false),
parkIntegrationAtReauth: vi.fn(),
recordSyncFailure: vi.fn(),
recordSyncSuccess: vi.fn(),
}));
vi.mock("../sync", async () => {
const actual = await vi.importActual<typeof import("../sync")>("../sync");
return {
...actual,
getValidToken: vi.fn(async () => ({
accessToken: "token",
connection: { id: "conn-1", withingsUserId: "wu-1" },
})),
};
});
vi.mock("@/lib/logging/context", () => ({
getEvent: vi.fn(() => ({
addExternalCall: vi.fn(),
addWarning: vi.fn(),
})),
}));
// v1.4.39.1 — the sync now hands its touched (type, day) keys to the
// measurement rollup writer so the dashboard chart's `source=rollup`
// fast-path sees the new buckets. The mock collects the call args so
// the regression test below can assert the hook fired.
vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
return {
...actual,
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
};
});
import { prisma } from "@/lib/db";
import {
parkIntegrationAtReauth,
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { fetchWithingsActivity, syncUserActivity } from "../sync-activity";
interface FakeActivity {
date: string;
steps?: number;
distance?: number;
calories?: number;
}
function installFetchMock(entries: FakeActivity[]) {
const fetchMock = vi.fn(async () => ({
status: 200,
json: async () => ({
status: 0,
body: {
activities: entries,
more: false,
offset: 0,
},
}),
}));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
beforeEach(() => {
vi.resetAllMocks();
// v1.4.26 — every syncUserActivity call now reads the connection's
// scope to short-circuit legacy `user.metrics`-only connections.
// Default-mock to "scope is fine" so the existing field-mapping +
// idempotency tests stay focused on the write path. The scope-skip
// case has its own dedicated tests below.
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: "user.metrics,user.activity",
} as never);
vi.mocked(resolveUserTimezone).mockResolvedValue("UTC");
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("fetchWithingsActivity", () => {
it("POSTs getactivity with steps + distance + calories in data_fields", async () => {
const fetchMock = installFetchMock([]);
await fetchWithingsActivity("token", "2026-05-01", "2026-05-12");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(url).toBe("https://wbsapi.withings.net/v2/measure");
const body = String(init.body);
expect(body).toContain("action=getactivity");
expect(body).toContain("startdateymd=2026-05-01");
expect(body).toContain("enddateymd=2026-05-12");
expect(body).toContain("data_fields=steps%2Cdistance%2Ccalories");
});
it("returns one entry per day from the response", async () => {
installFetchMock([
{ date: "2026-05-10", steps: 8420, distance: 6720, calories: 412 },
{ date: "2026-05-11", steps: 5012, distance: 3950, calories: 245 },
]);
const entries = await fetchWithingsActivity(
"token",
"2026-05-10",
"2026-05-11",
);
expect(entries).toHaveLength(2);
expect(entries[0].steps).toBe(8420);
expect(entries[1].date).toBe("2026-05-11");
});
it("throws when Withings returns a non-zero status", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
status: 200,
json: async () => ({ status: 293, error: "insufficient scope" }),
})),
);
await expect(
fetchWithingsActivity("token", "2026-05-10", "2026-05-11"),
).rejects.toThrow(/Withings activity error: 293/);
});
});
/**
* v1.20.2 F3 — helper: pull the flat `data[]` the batched `createMany`
* received (the writer always passes a single call with every fresh
* slot). Returns `[]` when `createMany` never fired.
*/
function createManyRows(): Array<Record<string, unknown>> {
const calls = vi.mocked(prisma.measurement.createMany).mock.calls;
if (calls.length === 0) return [];
return (calls[0][0] as { data: Array<Record<string, unknown>> }).data;
}
describe("syncUserActivity — field mapping + idempotency", () => {
it("writes one row per (date, metric) the first time a day shows up", async () => {
installFetchMock([
{ date: "2026-05-12", steps: 8420, distance: 6720, calories: 412 },
]);
// No existing slot rows → every planned row is an insert.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.createMany).mockResolvedValue({
count: 3,
} as never);
const imported = await syncUserActivity("user-1");
expect(imported).toBe(3);
// v1.20.2 F3 — one batched insert carrying all three metric rows.
expect(prisma.measurement.createMany).toHaveBeenCalledTimes(1);
const types = createManyRows().map((r) => r.type as string);
expect(types.sort()).toEqual([
"ACTIVE_ENERGY_BURNED",
"ACTIVITY_STEPS",
"WALKING_RUNNING_DISTANCE",
]);
});
it("updates instead of inserting when the same (date, metric) already exists with a different value", async () => {
installFetchMock([
{ date: "2026-05-12", steps: 9001, distance: 7000, calories: 450 },
]);
const measuredAt = new Date("2026-05-12T12:00:00.000Z");
// Each slot already holds a row with a DIFFERENT value → all three
// overwrite (the value moved). Keyed by the same four columns the
// batched read matches on.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "row-steps",
type: "ACTIVITY_STEPS",
measuredAt,
value: 1,
deletedAt: null,
},
{
id: "row-distance",
type: "WALKING_RUNNING_DISTANCE",
measuredAt,
value: 1,
deletedAt: null,
},
{
id: "row-calories",
type: "ACTIVE_ENERGY_BURNED",
measuredAt,
value: 1,
deletedAt: null,
},
] as never);
vi.mocked(prisma.measurement.update).mockResolvedValue({} as never);
const imported = await syncUserActivity("user-1");
expect(imported).toBe(3);
expect(prisma.measurement.createMany).not.toHaveBeenCalled();
// One batched `$transaction` carrying three per-row updates.
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(prisma.measurement.update).toHaveBeenCalledTimes(3);
});
it("skips the write entirely when an existing slot already holds the same value (idempotent re-sync)", async () => {
installFetchMock([{ date: "2026-05-12", steps: 8420 }]);
const measuredAt = new Date("2026-05-12T12:00:00.000Z");
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "row-steps",
type: "ACTIVITY_STEPS",
measuredAt,
externalId: "withings:activity:user-1:2026-05-12:steps",
value: 8420,
deletedAt: null,
},
] as never);
const imported = await syncUserActivity("user-1");
// The slot is still "touched" (rollup re-fold + imported count), but
// no value moved so neither the insert nor the update path runs.
expect(imported).toBe(1);
expect(prisma.measurement.createMany).not.toHaveBeenCalled();
expect(prisma.measurement.update).not.toHaveBeenCalled();
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it("resurrects a tombstoned slot even when the value is unchanged (deletedAt: null)", async () => {
installFetchMock([{ date: "2026-05-12", steps: 8420 }]);
const measuredAt = new Date("2026-05-12T12:00:00.000Z");
// Same value, but the row is tombstoned: the write is what resurrects
// it — Withings owns the row, so a re-fetched day brings it back.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "row-steps",
type: "ACTIVITY_STEPS",
measuredAt,
value: 8420,
deletedAt: new Date("2026-05-13T00:00:00.000Z"),
},
] as never);
vi.mocked(prisma.measurement.update).mockResolvedValue({} as never);
const imported = await syncUserActivity("user-1");
expect(imported).toBe(1);
expect(prisma.measurement.createMany).not.toHaveBeenCalled();
expect(prisma.measurement.update).toHaveBeenCalledTimes(1);
const arg = vi.mocked(prisma.measurement.update).mock.calls[0][0] as {
data: { value: number; deletedAt: Date | null };
};
expect(arg.data.value).toBe(8420);
expect(arg.data.deletedAt).toBeNull();
});
it.each([
["UTC", "2026-05-12T12:00:00.000Z"],
["America/New_York", "2026-05-12T16:00:00.000Z"],
["Europe/Berlin", "2026-05-12T10:00:00.000Z"],
["Pacific/Auckland", "2026-05-12T00:00:00.000Z"],
["Pacific/Tongatapu", "2026-05-11T23:00:00.000Z"],
])(
"anchors a %s day at local noon, which reads the same calendar day there",
async (tz, expected) => {
vi.mocked(resolveUserTimezone).mockResolvedValue(tz);
installFetchMock([{ date: "2026-05-12", steps: 1000 }]);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.createMany).mockResolvedValue({
count: 1,
} as never);
await syncUserActivity("user-1");
const measuredAt = createManyRows()[0].measuredAt as Date;
expect(measuredAt.toISOString()).toBe(expected);
expect(
new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(measuredAt),
).toBe("2026-05-12");
},
);
it("moves a row stored at the old noon-UTC anchor to local noon instead of adding a second one", async () => {
// Noon UTC is already 01:00 the next day in Tonga, so the old anchor
// counted 12 May's steps on 13 May there.
vi.mocked(resolveUserTimezone).mockResolvedValue("Pacific/Tongatapu");
installFetchMock([{ date: "2026-05-12", steps: 8420 }]);
const legacy = new Date("2026-05-12T12:00:00.000Z");
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "row-steps",
type: "ACTIVITY_STEPS",
measuredAt: legacy,
externalId: "withings:activity:user-1:2026-05-12:steps",
value: 8420,
deletedAt: null,
},
] as never);
vi.mocked(prisma.measurement.update).mockResolvedValue({} as never);
await syncUserActivity("user-1");
expect(prisma.measurement.createMany).not.toHaveBeenCalled();
expect(prisma.measurement.update).toHaveBeenCalledTimes(1);
const arg = vi.mocked(prisma.measurement.update).mock.calls[0][0] as {
data: { measuredAt: Date };
};
expect(arg.data.measuredAt.toISOString()).toBe("2026-05-11T23:00:00.000Z");
// Both the day the row left and the day it joined are refolded.
const refolded = vi
.mocked(recomputeBucketsForMeasurement)
.mock.calls.map((c) => (c[2] as Date).toISOString().slice(0, 10));
expect(refolded).toEqual(
expect.arrayContaining(["2026-05-11", "2026-05-12"]),
);
});
it("fetches through today's date in the user's zone", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-05-12T20:00:00.000Z"));
try {
vi.mocked(resolveUserTimezone).mockResolvedValue("Pacific/Auckland");
const fetchMock = installFetchMock([]);
await syncUserActivity("user-1");
const [, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ body: unknown },
];
const body = String(init.body);
// 08:00 on 13 May in Auckland: the window ends on the 13th.
expect(body).toContain("enddateymd=2026-05-13");
} finally {
vi.useRealTimers();
}
});
it("skips missing fields without writing zero rows or throwing", async () => {
// `distance` absent — Withings drops the field rather than emitting 0
// when there's no GPS. Steps + calories still write.
installFetchMock([{ date: "2026-05-12", steps: 8000, calories: 380 }]);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.createMany).mockResolvedValue({
count: 2,
} as never);
const imported = await syncUserActivity("user-1");
expect(imported).toBe(2);
const types = createManyRows().map((r) => r.type as string);
expect(types).not.toContain("WALKING_RUNNING_DISTANCE");
});
it("ingests a 0-step rest day rather than dropping it", async () => {
// Withings reports 0 steps for a day a user spent in bed — that's
// a valid datapoint, not a missing one. The mapper must not skip
// it (which would let the prior day's value erroneously "stick").
installFetchMock([
{ date: "2026-05-12", steps: 0, distance: 0, calories: 0 },
]);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.createMany).mockResolvedValue({
count: 3,
} as never);
const imported = await syncUserActivity("user-1");
expect(imported).toBe(3);
});
it("calls recordSyncSuccess after a clean round-trip", async () => {
installFetchMock([{ date: "2026-05-12", steps: 1000 }]);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.createMany).mockResolvedValue({
count: 1,
} as never);
await syncUserActivity("user-1");
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
leg: "activity",
});
});
it("W-2 — records a transient failure and refuses the success stamp when the batch write throws", async () => {
// A swallowed batch-write used to fall through to recordSyncSuccess: green
// pill over lost data, and a PERSISTENT write bug green forever. The write
// failure now records a transient failure and the success stamp is skipped.
installFetchMock([
{ date: "2026-05-12", steps: 8420, distance: 6720, calories: 412 },
]);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.createMany).mockRejectedValue(
new Error("db write blew up"),
);
// No rethrow — sibling chunks + the rollup fold still run (matches Google).
await expect(syncUserActivity("user-1")).resolves.toBeGreaterThanOrEqual(0);
expect(recordSyncSuccess).not.toHaveBeenCalled();
expect(recordSyncFailure).toHaveBeenCalledWith(
expect.objectContaining({
integration: "withings",
kind: "transient",
message: expect.stringContaining("activity batch write failed"),
}),
);
});
it("tags every row with the canonical externalId so future replays dedup", async () => {
installFetchMock([{ date: "2026-05-12", steps: 8420 }]);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.createMany).mockResolvedValue({
count: 1,
} as never);
await syncUserActivity("user-1");
expect(createManyRows()[0].externalId).toBe(
"withings:activity:user-1:2026-05-12:steps",
);
});
it("batches a multi-day backfill into one read and one insert (F3)", async () => {
// 5 days × 3 metrics = 15 planned rows. The pre-v1.20.2 shape ran
// 15 findFirst + 15 create = 30 round-trips; the batched path is one
// findMany + one createMany regardless of the day count.
installFetchMock(
Array.from({ length: 5 }, (_, i) => ({
date: `2026-05-1${i}`,
steps: 1000 + i,
distance: 2000 + i,
calories: 300 + i,
})),
);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.createMany).mockResolvedValue({
count: 15,
} as never);
const imported = await syncUserActivity("user-1");
expect(imported).toBe(15);
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(1);
expect(prisma.measurement.createMany).toHaveBeenCalledTimes(1);
expect(createManyRows()).toHaveLength(15);
// The single read is keyed by the full distinct (type, measuredAt)
// sets the batch will write — `skipDuplicates` guards the insert.
const readArg = vi.mocked(prisma.measurement.findMany).mock.calls[0][0] as {
where: {
type: { in: unknown[] };
OR: [
{ externalId: { in: unknown[] } },
{ measuredAt: { in: unknown[] } },
];
};
};
expect(readArg.where.type.in).toHaveLength(3);
expect(readArg.where.OR[0].externalId.in).toHaveLength(15);
// UTC: the local-noon and the old noon-UTC anchor coincide.
expect(readArg.where.OR[1].measuredAt.in).toHaveLength(5);
const createArg = vi.mocked(prisma.measurement.createMany).mock
.calls[0][0] as { skipDuplicates: boolean };
expect(createArg.skipDuplicates).toBe(true);
});
});
describe("syncUserActivity — scope-skip guard (v1.4.26)", () => {
it("returns 0 without firing the Withings call when scope lacks user.activity", async () => {
// Legacy v1.4.24- connection — scope holds `user.metrics` only.
// The sync must short-circuit BEFORE the upstream fetch so a
// guaranteed 403 doesn't park as `transient` and spam pg-boss
// retries.
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: "user.metrics",
} as never);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const imported = await syncUserActivity("user-1");
expect(imported).toBe(0);
expect(fetchMock).not.toHaveBeenCalled();
expect(prisma.measurement.createMany).not.toHaveBeenCalled();
expect(recordSyncSuccess).not.toHaveBeenCalled();
});
it("parks the connection via parkIntegrationAtReauth (NOT recordSyncFailure) — v1.4.27 F20", async () => {
// The deliberate scope-skip is a no-op park, not a failure burst.
// Calling `recordSyncFailure` here would increment the per-user
// counter and trip the 3-strike admin alert ladder. The v1.4.27
// fix swaps to `parkIntegrationAtReauth`, which leaves the counter
// untouched and never pages admins.
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: "user.metrics",
} as never);
vi.stubGlobal("fetch", vi.fn());
await syncUserActivity("user-1");
expect(parkIntegrationAtReauth).toHaveBeenCalledTimes(1);
expect(parkIntegrationAtReauth).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
integration: "withings",
errorCode: "scope_missing",
}),
);
// CRITICAL: the failure path is NOT touched on a scope-skip — that
// is what silences the false 3-strike Telegram alert.
expect(recordSyncFailure).not.toHaveBeenCalled();
});
it("treats a null scope (pre-v1.4.25 connection) as missing user.activity and parks silently", async () => {
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: null,
} as never);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const imported = await syncUserActivity("user-1");
expect(imported).toBe(0);
expect(fetchMock).not.toHaveBeenCalled();
expect(parkIntegrationAtReauth).toHaveBeenCalledWith(
expect.objectContaining({ errorCode: "scope_missing" }),
);
expect(recordSyncFailure).not.toHaveBeenCalled();
});
it("classifies a Withings 403 in the catch-block as reauth_required and STILL pages (defence-in-depth)", async () => {
// BL-P3-2 — the defence-in-depth 403 catch-block stays on
// `recordSyncFailure` so a genuinely unexpected 403 (Withings
// revokes scope between OAuth and the next sync without
// invalidating the refresh token) still trips the 3-strike alert.
// This is the asymmetric pair to the scope-skip park above: the
// park is silent, the catch is loud.
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: "user.metrics,user.activity",
} as never);
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
status: 200,
json: async () => ({ status: 403, error: "insufficient scope" }),
})),
);
await expect(syncUserActivity("user-1")).rejects.toThrow(
/Withings activity error: 403/,
);
expect(recordSyncFailure).toHaveBeenCalledWith(
expect.objectContaining({
kind: "reauth_required",
errorCode: "403",
}),
);
// The park helper is NOT used for the catch — only the scope-skip.
expect(parkIntegrationAtReauth).not.toHaveBeenCalled();
});
});
describe("syncUserActivity — measurement rollup hook (v1.4.39.1)", () => {
it("folds the persistent rollup table for each (type, day) the sync touched", async () => {
// Two distinct calendar days × three activity metrics each = 6
// writes; the rollup hook should be called once per distinct
// `(type, dayStart)` so the dashboard chart's `source=rollup` fast-
// path lights up the Withings-ingested days. Pre-v1.4.39.1 the
// sync skipped the hook entirely, leaving the rollup tier blind to
// Withings activity.
installFetchMock([
{ date: "2026-05-12", steps: 8420, distance: 6720, calories: 412 },
{ date: "2026-05-13", steps: 9100, distance: 7100, calories: 430 },
]);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.createMany).mockResolvedValue({
count: 0,
} as never);
await syncUserActivity("user-1");
expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(6);
const calls = vi
.mocked(recomputeBucketsForMeasurement)
.mock.calls.map((c) => ({
userId: c[0],
type: c[1],
measuredAtIso: (c[2] as Date).toISOString(),
}));
// Distinct (type, day) pairs the sync touched, derived from the two
// dates × three metrics × `collapseToTypeDayKeys`.
const distinct = new Set(calls.map((c) => `${c.type}|${c.measuredAtIso}`));
expect(distinct.size).toBe(6);
expect(calls.every((c) => c.userId === "user-1")).toBe(true);
});
it("collapses multiple writes for the same (type, day) into a single rollup recompute", async () => {
// A single-date entry exercises the existing day-collapse helper —
// three metric rows for 2026-05-12 must fold into three rollup
// calls (one per type), not nine.
installFetchMock([
{ date: "2026-05-12", steps: 8420, distance: 6720, calories: 412 },
]);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.createMany).mockResolvedValue({
count: 0,
} as never);
await syncUserActivity("user-1");
expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3);
});
it("swallows a populator failure so the sync still returns its imported count", async () => {
// The rollup recompute is a best-effort cache tier — a populator
// hiccup must never fail the user's sync. The sync's `imported`
// return value should still reflect the rows written.
installFetchMock([{ date: "2026-05-12", steps: 8420 }]);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.createMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(recomputeBucketsForMeasurement).mockRejectedValueOnce(
new Error("simulated rollup failure"),
);
const imported = await syncUserActivity("user-1");
expect(imported).toBe(1);
});
});