Files
HealthLog/src/lib/withings/__tests__/sync-activity.test.ts
T
Marc-André Bombeck 29bfcc674b refactor(rollups): move measurement-rollups under src/lib/rollups umbrella
Creates the new src/lib/rollups/ aggregator directory and relocates
the main measurement rollup module:

- src/lib/measurements/rollups.ts → src/lib/rollups/measurement-rollups.ts
- src/lib/measurements/__tests__/rollups.test.ts →
  src/lib/rollups/__tests__/measurement-rollups.test.ts

Every importer of @/lib/measurements/rollups is rewritten to
@/lib/rollups/measurement-rollups. The orphan sibling reference inside
src/lib/measurements/rollup-read.ts (still in its old home until the
next batch) is promoted to the @/lib alias so the chain keeps
typechecking through the in-flight move.

Pure relocation — no behaviour change, no exported-surface change. The
umbrella exists so the rollup contract is discoverable from a single
directory before the iOS sprint locks the boundary.
2026-05-21 12:19:45 +02:00

444 lines
16 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", () => ({
prisma: {
measurement: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
withingsConnection: {
findUnique: 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 { 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);
});
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/);
});
});
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 },
]);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
const imported = await syncUserActivity("user-1");
expect(imported).toBe(3);
expect(prisma.measurement.create).toHaveBeenCalledTimes(3);
const types = vi
.mocked(prisma.measurement.create)
.mock.calls.map((c) => (c[0].data as { type: string }).type);
expect(types.sort()).toEqual([
"ACTIVE_ENERGY_BURNED",
"ACTIVITY_STEPS",
"WALKING_RUNNING_DISTANCE",
]);
});
it("updates instead of inserting when the same (date, metric) already exists", async () => {
installFetchMock([
{ date: "2026-05-12", steps: 9001, distance: 7000, calories: 450 },
]);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue({
id: "row-existing",
} as never);
vi.mocked(prisma.measurement.update).mockResolvedValue({} as never);
const imported = await syncUserActivity("user-1");
expect(imported).toBe(3);
expect(prisma.measurement.create).not.toHaveBeenCalled();
expect(prisma.measurement.update).toHaveBeenCalledTimes(3);
});
it("anchors measuredAt at noon UTC so the instant lands inside the local day for every supported tz", async () => {
installFetchMock([{ date: "2026-05-12", steps: 1000 }]);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
await syncUserActivity("user-1");
const createArg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as {
data: { measuredAt: Date };
};
const measuredAt = createArg.data.measuredAt;
expect(measuredAt.toISOString()).toBe("2026-05-12T12:00:00.000Z");
// Regression: anchoring at noon UTC keeps the row inside the
// calendar day every user reads it in. Bucketing the same instant
// via `Intl.DateTimeFormat` across the canonical Withings user
// span — Honolulu (UTC-10), Los Angeles (UTC-7/-8), Berlin
// (UTC+1/+2) and Tokyo (UTC+9) — must all return "2026-05-12".
// Anchoring at end-of-day UTC (the v1.4.25 W17b shape) shifted
// Tokyo by +1 day; anchoring at midnight UTC would shift LA by -1.
// Noon UTC is the only choice that holds across the practical
// [-11, +12) range.
const bucket = (tz: string) =>
new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(measuredAt);
expect(bucket("Pacific/Honolulu")).toBe("2026-05-12");
expect(bucket("America/Los_Angeles")).toBe("2026-05-12");
expect(bucket("Europe/Berlin")).toBe("2026-05-12");
expect(bucket("Asia/Tokyo")).toBe("2026-05-12");
});
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.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
const imported = await syncUserActivity("user-1");
expect(imported).toBe(2);
const types = vi
.mocked(prisma.measurement.create)
.mock.calls.map((c) => (c[0].data as { type: string }).type);
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.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} 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.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
await syncUserActivity("user-1");
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings");
});
it("tags every row with the canonical externalId so future replays dedup", async () => {
installFetchMock([{ date: "2026-05-12", steps: 8420 }]);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
await syncUserActivity("user-1");
const createArg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as {
data: { externalId: string };
};
expect(createArg.data.externalId).toBe(
"withings:activity:user-1:2026-05-12:steps",
);
});
});
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.create).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.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} 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.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} 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.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
vi.mocked(recomputeBucketsForMeasurement).mockRejectedValueOnce(
new Error("simulated rollup failure"),
);
const imported = await syncUserActivity("user-1");
expect(imported).toBe(1);
});
});