mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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.
682 lines
25 KiB
TypeScript
682 lines
25 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|