test(sources): cross-source priority for Withings Activity + Apple Health

New test module exercises pickCanonicalSourceRows with the realistic
W17b/c shape: same user reporting both WITHINGS rows (from the new
activity / sleep sync routines) and APPLE_HEALTH rows (from the v1.5
iOS passthrough) for the same day.

Coverage:
  - Default Marc-directive priority picks APPLE_HEALTH for every
    cumulative metric (steps, active energy, distance, flights)
  - Same default applies to sleep — even when both sources captured
    the same set of per-stage rows from Migration 0055
  - Withings stays the fallback when Apple Health has no rows that
    day (today's v1.4.25 reality; v1.5 may flip it per-user)
  - User-level overrides re-prioritise the ladder as documented
  - Activity rows (sleepStage NULL) and sleep rows (sleepStage non-null)
    pick independently — Migration 0055's per-stage uniqueness doesn't
    leak into the picker's source axis.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 18:31:32 +02:00
1 parent 9b30e116ee
commit dab7de3786
1 file changed
+329
@@ -0,0 +1,329 @@
/**
* v1.4.25 W17b/c — cross-source priority verification when both
* Withings (activity + sleep v2 ingest) AND Apple Health (iOS
* passthrough, v1.5) populate the same metric for the same day.
*
* The Marc-directive default priority (W8c — see
* `src/lib/validations/source-priority.ts`) picks APPLE_HEALTH first
* for every cumulative metric (steps, active energy, walking-running
* distance, flights climbed) because HealthKit aggregates iPhone +
* watch + scale into a single canonical stream. Withings rows stay in
* the DB as a complete shadow set — the picker just drops them from
* the aggregation, not the persistence.
*
* Sleep stages add a per-row dimension via Migration 0055 — a single
* night can carry 4–6 rows from Withings (per stage) AND 4–6 rows
* from Apple Health (per stage). The picker must resolve per stage,
* keeping the winning-source rows for every stage so the analytics
* aggregator's per-stage sum doesn't double-count.
*/
import { describe, expect, it } from "vitest";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
function isoDayKey(d: Date): string {
return d.toISOString().slice(0, 10);
}
describe("cross-source priority — Withings Activity + Apple Health", () => {
it("picks APPLE_HEALTH over WITHINGS for cumulative steps (default priority)", () => {
// Both sources reported steps for the same day. Naïve summing
// would triple-count (8 500 + 5 000 + 2 000 = 15 500); the picker
// keeps only the APPLE_HEALTH rows for the daily aggregation.
const rows = [
{
measuredAt: new Date("2026-05-12T23:59:59Z"),
source: "WITHINGS" as const,
type: "ACTIVITY_STEPS" as const,
value: 5000,
},
{
measuredAt: new Date("2026-05-12T23:59:59Z"),
source: "WITHINGS" as const,
type: "ACTIVITY_STEPS" as const,
value: 2000,
},
{
measuredAt: new Date("2026-05-12T09:00:00Z"),
source: "APPLE_HEALTH" as const,
type: "ACTIVITY_STEPS" as const,
value: 8500,
},
];
const out = pickCanonicalSourceRows(rows, "steps", null, isoDayKey);
expect(out.canonicalRows).toHaveLength(1);
expect(out.canonicalRows[0].source).toBe("APPLE_HEALTH");
expect(out.canonicalRows[0].value).toBe(8500);
expect(out.pickedByDay.get("2026-05-12")).toBe("APPLE_HEALTH");
});
it("picks APPLE_HEALTH over WITHINGS for active energy + distance + flights", () => {
// The same default applies to every cumulative metric. One
// assertion per metric class — guards against a future schema
// tweak that diverges one of them from the canonical ladder.
for (const metricKey of [
"activeEnergy",
"walkingRunningDistance",
"flightsClimbed",
] as const) {
const rows = [
{
measuredAt: new Date("2026-05-12T23:59:59Z"),
source: "WITHINGS" as const,
type: "ACTIVE_ENERGY_BURNED" as const,
value: 320,
},
{
measuredAt: new Date("2026-05-12T08:00:00Z"),
source: "APPLE_HEALTH" as const,
type: "ACTIVE_ENERGY_BURNED" as const,
value: 410,
},
];
const out = pickCanonicalSourceRows(rows, metricKey, null, isoDayKey);
expect(out.canonicalRows).toHaveLength(1);
expect(out.canonicalRows[0].source).toBe("APPLE_HEALTH");
}
});
it("falls back to WITHINGS when Apple Health has no rows that day", () => {
// The iOS passthrough hasn't synced yet (or the user disabled
// HealthKit on that day) — Withings must light up the daily
// total rather than the chart going dark.
const rows = [
{
measuredAt: new Date("2026-05-12T23:59:59Z"),
source: "WITHINGS" as const,
type: "ACTIVITY_STEPS" as const,
value: 4200,
},
];
const out = pickCanonicalSourceRows(rows, "steps", null, isoDayKey);
expect(out.canonicalRows).toHaveLength(1);
expect(out.canonicalRows[0].source).toBe("WITHINGS");
expect(out.pickedByDay.get("2026-05-12")).toBe("WITHINGS");
});
it("respects a user override that promotes WITHINGS above APPLE_HEALTH", () => {
// Marc could prefer his ScanWatch for steps even after iOS lands.
// The W5e flat-shape override (back-compat) is exercised here.
const rows = [
{
measuredAt: new Date("2026-05-12T23:59:59Z"),
source: "WITHINGS" as const,
type: "ACTIVITY_STEPS" as const,
value: 5000,
},
{
measuredAt: new Date("2026-05-12T09:00:00Z"),
source: "APPLE_HEALTH" as const,
type: "ACTIVITY_STEPS" as const,
value: 8500,
},
];
const userPriority = {
steps: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
};
const out = pickCanonicalSourceRows(
rows,
"steps",
userPriority,
isoDayKey,
);
expect(out.canonicalRows).toHaveLength(1);
expect(out.canonicalRows[0].source).toBe("WITHINGS");
expect(out.canonicalRows[0].value).toBe(5000);
});
});
describe("cross-source priority — Withings Sleep v2 + Apple Health (per-stage)", () => {
it("picks APPLE_HEALTH over WITHINGS for sleep when both sources reported the same night", () => {
// Marc's ScanWatch and iPhone both wrote per-stage rows for the
// same night. Sleep default (APPLE_HEALTH > WITHINGS) keeps only
// the iOS rows in the aggregation.
const measuredAt = new Date("2026-05-12T22:00:00Z");
const rows = [
// Withings ScanWatch — three stages.
{
measuredAt,
source: "WITHINGS" as const,
type: "SLEEP_DURATION" as const,
sleepStage: "CORE" as const,
value: 240,
},
{
measuredAt,
source: "WITHINGS" as const,
type: "SLEEP_DURATION" as const,
sleepStage: "DEEP" as const,
value: 90,
},
{
measuredAt,
source: "WITHINGS" as const,
type: "SLEEP_DURATION" as const,
sleepStage: "REM" as const,
value: 80,
},
// Apple Health — same three stages.
{
measuredAt,
source: "APPLE_HEALTH" as const,
type: "SLEEP_DURATION" as const,
sleepStage: "CORE" as const,
value: 250,
},
{
measuredAt,
source: "APPLE_HEALTH" as const,
type: "SLEEP_DURATION" as const,
sleepStage: "DEEP" as const,
value: 95,
},
{
measuredAt,
source: "APPLE_HEALTH" as const,
type: "SLEEP_DURATION" as const,
sleepStage: "REM" as const,
value: 82,
},
];
const out = pickCanonicalSourceRows(rows, "sleep", null, isoDayKey);
expect(out.canonicalRows).toHaveLength(3);
expect(out.canonicalRows.every((r) => r.source === "APPLE_HEALTH")).toBe(
true,
);
const stages = out.canonicalRows.map((r) => r.sleepStage).sort();
expect(stages).toEqual(["CORE", "DEEP", "REM"]);
});
it("keeps Withings sleep rows on stages Apple Health didn't capture", () => {
// Per-day source pick: today the picker keeps APPLE_HEALTH for
// the whole night because Apple Health has at least one row.
// Withings stages that Apple Health didn't capture are dropped
// from the aggregation (NOT from the DB) — matches the picker's
// documented "source axis dropped rows stay persisted as an audit
// trail" contract.
const measuredAt = new Date("2026-05-12T22:00:00Z");
const rows = [
{
measuredAt,
source: "WITHINGS" as const,
type: "SLEEP_DURATION" as const,
sleepStage: "AWAKE" as const,
value: 12,
},
{
measuredAt,
source: "APPLE_HEALTH" as const,
type: "SLEEP_DURATION" as const,
sleepStage: "CORE" as const,
value: 250,
},
{
measuredAt,
source: "APPLE_HEALTH" as const,
type: "SLEEP_DURATION" as const,
sleepStage: "DEEP" as const,
value: 95,
},
];
const out = pickCanonicalSourceRows(rows, "sleep", null, isoDayKey);
// Apple Health is picked for the day → only its rows survive.
expect(out.canonicalRows).toHaveLength(2);
expect(out.canonicalRows.every((r) => r.source === "APPLE_HEALTH")).toBe(
true,
);
expect(out.pickedByDay.get("2026-05-12")).toBe("APPLE_HEALTH");
});
it("uses Withings-only sleep rows when iOS hasn't reported (today's v1.4.25 reality)", () => {
// The current state of the world: v1.4.25 has no iOS
// passthrough, so the picker falls through to WITHINGS for every
// user. Per-stage rows from Migration 0055 must pass through
// intact so the analytics aggregator sees the full breakdown.
const measuredAt = new Date("2026-05-12T22:00:00Z");
const rows = [
{
measuredAt,
source: "WITHINGS" as const,
type: "SLEEP_DURATION" as const,
sleepStage: "CORE" as const,
value: 240,
},
{
measuredAt,
source: "WITHINGS" as const,
type: "SLEEP_DURATION" as const,
sleepStage: "DEEP" as const,
value: 90,
},
{
measuredAt,
source: "WITHINGS" as const,
type: "SLEEP_DURATION" as const,
sleepStage: "REM" as const,
value: 80,
},
];
const out = pickCanonicalSourceRows(rows, "sleep", null, isoDayKey);
expect(out.canonicalRows).toHaveLength(3);
expect(out.canonicalRows.every((r) => r.source === "WITHINGS")).toBe(true);
});
it("activity rows leave sleepStage NULL; sleep rows carry non-null sleepStage — no collision in the picker", () => {
// Confirms the picker treats activity + sleep as independent
// metric buckets. A day with steps + sleep rows from both
// sources resolves each axis independently.
const measuredAt = new Date("2026-05-12T22:00:00Z");
const allRows = [
{
measuredAt,
source: "WITHINGS" as const,
type: "ACTIVITY_STEPS" as const,
sleepStage: null,
value: 7000,
},
{
measuredAt,
source: "APPLE_HEALTH" as const,
type: "ACTIVITY_STEPS" as const,
sleepStage: null,
value: 8000,
},
{
measuredAt,
source: "WITHINGS" as const,
type: "SLEEP_DURATION" as const,
sleepStage: "DEEP" as const,
value: 90,
},
{
measuredAt,
source: "APPLE_HEALTH" as const,
type: "SLEEP_DURATION" as const,
sleepStage: "DEEP" as const,
value: 95,
},
];
const stepsOut = pickCanonicalSourceRows(
allRows.filter((r) => r.type === "ACTIVITY_STEPS"),
"steps",
null,
isoDayKey,
);
expect(stepsOut.canonicalRows).toHaveLength(1);
expect(stepsOut.canonicalRows[0].value).toBe(8000);
const sleepOut = pickCanonicalSourceRows(
allRows.filter((r) => r.type === "SLEEP_DURATION"),
"sleep",
null,
isoDayKey,
);
expect(sleepOut.canonicalRows).toHaveLength(1);
expect(sleepOut.canonicalRows[0].value).toBe(95);
expect(sleepOut.canonicalRows[0].sleepStage).toBe("DEEP");
});
});