Files
HealthLog/tests/integration/withings-activity-sync.test.ts
Marc-André Bombeck a04a8352f5 fix(withings): keep a brand-18 activity day no other source delivered
Skipping every day Health Mate holds from another app (brand 18) lost
the steps of an account whose phone steps only Health Mate reads. Such a
day is now skipped only when another connected source (not Withings,
not a hand entry) has steps on that local day; otherwise it is kept.
2026-10-21 16:25:26 +02:00

166 lines
5.8 KiB
TypeScript
Raw Permalink 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 — Withings activity-sync end-to-end.
*
* Drives `syncUserActivity` against a mocked Withings response and the
* real Postgres testcontainer. Asserts:
* - Per-day activity entries map to one row per (date, metric)
* - The composite-unique with NULLS NOT DISTINCT serializes a
* second sync of the same window into UPDATEs (no duplicates)
* - Activity rows leave sleep_stage NULL so they do not collide
* with the W17c sleep rows
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Crypto reads `ENCRYPTION_KEY` lazily on first encrypt(). Under the
// previous `isolate: false` suite the env from any sibling that seeded
// the key leaked into this file; per-file isolation (v1.4.37 W-CI)
// reveals that this spec never seeded its own key. Pin a deterministic
// 32-byte test key before any `@/lib/crypto` import so the spec stays
// self-contained.
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { encrypt } from "@/lib/crypto";
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-withings-activity-sync";
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.user.create({
data: {
id: TEST_USER_ID,
username: "withings-activity-sync",
email: "withings-activity-sync@example.test",
},
});
await prisma.withingsConnection.create({
data: {
userId: TEST_USER_ID,
withingsUserId: "wu-1",
accessToken: encrypt("access-token"),
refreshToken: encrypt("refresh-token"),
// Token valid for an hour — keeps `getValidToken` off the
// refresh path.
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
scope: "user.metrics,user.activity",
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function stubWithingsActivity(entries: unknown[]) {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
status: 200,
json: async () => ({
status: 0,
body: { activities: entries, more: false, offset: 0 },
}),
})),
);
}
describe("syncUserActivity — integration", () => {
it("writes one Measurement row per (date, metric) on first sync", async () => {
stubWithingsActivity([
{ date: "2026-05-10", steps: 8420, distance: 6720, calories: 412 },
{ date: "2026-05-11", steps: 5012, distance: 3950, calories: 245 },
]);
const { syncUserActivity } = await import("@/lib/withings/sync-activity");
const imported = await syncUserActivity(TEST_USER_ID);
expect(imported).toBe(6); // 2 days × 3 fields
const prisma = getPrismaClient();
const rows = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, source: "WITHINGS" },
orderBy: [{ measuredAt: "asc" }, { type: "asc" }],
});
expect(rows).toHaveLength(6);
// Activity rows never carry a sleepStage — confirms the new
// composite leaves the column NULL for non-sleep ingest.
expect(rows.every((r) => r.sleepStage === null)).toBe(true);
const types = new Set(rows.map((r) => r.type));
expect(types.has("ACTIVITY_STEPS")).toBe(true);
expect(types.has("WALKING_RUNNING_DISTANCE")).toBe(true);
expect(types.has("ACTIVE_ENERGY_BURNED")).toBe(true);
});
it("re-running the same sync updates rather than duplicating", async () => {
const entries = [
{ date: "2026-05-10", steps: 8420, distance: 6720, calories: 412 },
];
stubWithingsActivity(entries);
const { syncUserActivity } = await import("@/lib/withings/sync-activity");
await syncUserActivity(TEST_USER_ID);
// Withings re-aggregates a day when late samples arrive; second
// pull returns updated values.
stubWithingsActivity([
{ date: "2026-05-10", steps: 9001, distance: 7200, calories: 450 },
]);
await syncUserActivity(TEST_USER_ID);
const prisma = getPrismaClient();
const rows = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, source: "WITHINGS" },
orderBy: { type: "asc" },
});
expect(rows).toHaveLength(3);
const steps = rows.find((r) => r.type === "ACTIVITY_STEPS");
expect(steps?.value).toBe(9001);
const distance = rows.find((r) => r.type === "WALKING_RUNNING_DISTANCE");
expect(distance?.value).toBe(7200);
});
it("skips a brand-18 day only when another connected source has steps that day", async () => {
const prisma = getPrismaClient();
// Apple Health delivered the 11th; the 10th holds only a hand-entered
// count, which is not a connected source.
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
source: "APPLE_HEALTH",
value: 7300,
unit: "steps",
measuredAt: new Date("2026-05-11T10:00:00.000Z"),
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-05-11",
},
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
source: "MANUAL",
value: 5000,
unit: "steps",
measuredAt: new Date("2026-05-10T10:00:00.000Z"),
},
],
});
stubWithingsActivity([
{ date: "2026-05-10", steps: 6100, calories: 300, brand: 18 },
{ date: "2026-05-11", steps: 7290, calories: 340, brand: 18 },
]);
const { syncUserActivity } = await import("@/lib/withings/sync-activity");
await syncUserActivity(TEST_USER_ID);
const rows = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, source: "WITHINGS" },
orderBy: [{ measuredAt: "asc" }, { type: "asc" }],
select: { type: true, value: true },
});
expect(rows.map((r) => r.value).sort((a, b) => a - b)).toEqual([300, 6100]);
});
});