mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Every interactive path, every import, MCP and Telegram checked an incoming measurement against VALUE_RANGES before storing it. The provider sync writers did not, so a decode slip or a glitched sensor landed a row the application itself calls impossible, and every mean, median and personal band derived from it inherited the impossibility. Filtering the read side hid the row. It kept landing. The gate is one module reading the domain the app already declares, applied at two kinds of seam. reconcileExternalMeasurement is the shared identity writer for Polar, Oura, WHOOP, the Withings sleep leg, the Apple export importer and the batch endpoint, so gating there means a provider added later inherits the refusal rather than having to remember a check it never knew about; the new rejected_range verdict carries no row, which is what makes the compiler force every caller to handle it. The five writers that hold Prisma themselves gate at their entry, before the existence probe decides insert versus overwrite, so an impossible reading can no longer replace a good stored value. An out-of-band value is dropped. Not clamped, because a clamp invents a reading nobody took. Not stored behind a flag, because a quarantined row is still a row and every consumer would have to learn the flag. Not fatal to the batch, because one bad sample must not strand the run's other readings behind a held watermark. Absence reads as absence. A silent drop, though, is indistinguishable from a provider that sent nothing, so every drop is tallied on the wide event under measurement.range_rejected with the metric, the provider and the edge it broke. Counts only: the reading itself is health data and never enters the log line, and the direction plus the metric's own declared bound is what a diagnosis actually needs. The tally accumulates rather than replacing, because a provider gates once per resource leg and the reconciler once per row. One warning per provider lifts the event to warn so a drop does not hide inside a green line. A structural guard enumerates all twenty measurement writers with the reason each is allowed to write, so a new one has to answer the question before it ships. scripts/diagnose-implausible-measurements.ts reports the rows already stored, aggregated per metric and source with no value beyond the bucket's own extremes. It repairs nothing: whether a stored impossible row is a decode slip or a band that is too narrow is not a call a script gets to make.
180 lines
5.6 KiB
TypeScript
180 lines
5.6 KiB
TypeScript
/**
|
||
* `upsertFitbitMeasurements` is the single write seam for every Fitbit
|
||
* resource leg — activity, sleep, daily metrics — and it holds Prisma
|
||
* directly, so it does not inherit the shared reconciler's gate. These cases
|
||
* pin that the provider's numbers clear the app's own declared plausibility
|
||
* band before any of them can become a row, and that a refusal is reported.
|
||
*/
|
||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
||
const { findManyMock, createManyMock, updateMock } = vi.hoisted(() => ({
|
||
findManyMock: vi.fn(async () => [] as unknown[]),
|
||
createManyMock: vi.fn<
|
||
() => Promise<Array<{ id: string; type: string; measuredAt: Date }>>
|
||
>(async () => []),
|
||
updateMock: vi.fn(async () => ({})),
|
||
}));
|
||
|
||
vi.mock("@/lib/db", () => ({
|
||
prisma: {
|
||
measurement: {
|
||
findMany: findManyMock,
|
||
createManyAndReturn: createManyMock,
|
||
update: updateMock,
|
||
},
|
||
},
|
||
}));
|
||
vi.mock("@/lib/crypto", () => ({ encrypt: vi.fn(), decrypt: vi.fn() }));
|
||
vi.mock("@/lib/integrations/status", () => ({
|
||
isReauthRequired: vi.fn(async () => false),
|
||
recordSyncFailure: vi.fn(async () => {}),
|
||
recordSyncSuccess: vi.fn(async () => {}),
|
||
}));
|
||
vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||
collapseToTypeDayKeys: vi.fn(() => []),
|
||
recomputeBucketsForMeasurement: vi.fn(async () => {}),
|
||
recomputeUserRollups: vi.fn(async () => {}),
|
||
}));
|
||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
|
||
}));
|
||
vi.mock("@/lib/integrations/oauth-refresh", () => ({
|
||
persistRotatedToken: vi.fn(async () => {}),
|
||
}));
|
||
vi.mock("../credentials", () => ({
|
||
getUserFitbitCredentials: vi.fn(async () => null),
|
||
}));
|
||
vi.mock("../client", () => ({ refreshAccessToken: vi.fn() }));
|
||
|
||
import { eventStorage } from "@/lib/logging/context";
|
||
import { WideEventBuilder } from "@/lib/logging/event-builder";
|
||
import { RANGE_REJECTED_META_KEY } from "@/lib/measurements/plausibility-gate";
|
||
import type { RangeRejectionTally } from "@/lib/measurements/plausibility-gate";
|
||
|
||
import { upsertFitbitMeasurements } from "../sync-core";
|
||
|
||
/** RESTING_HEART_RATE is declared 25–220. */
|
||
const REAL_READING = {
|
||
type: "RESTING_HEART_RATE",
|
||
value: 58,
|
||
unit: "bpm",
|
||
measuredAt: new Date("2026-07-08T00:00:00.000Z"),
|
||
externalId: "rhr:2026-07-08",
|
||
};
|
||
|
||
const IMPOSSIBLE_READING = {
|
||
type: "RESTING_HEART_RATE",
|
||
value: 4_915_200,
|
||
unit: "bpm",
|
||
measuredAt: new Date("2026-07-09T00:00:00.000Z"),
|
||
externalId: "rhr:2026-07-09",
|
||
};
|
||
|
||
let event: WideEventBuilder;
|
||
|
||
function tally(): RangeRejectionTally | undefined {
|
||
return event.toJSON().meta?.[RANGE_REJECTED_META_KEY] as
|
||
RangeRejectionTally | undefined;
|
||
}
|
||
|
||
beforeEach(() => {
|
||
findManyMock.mockReset().mockResolvedValue([]);
|
||
createManyMock.mockReset().mockResolvedValue([]);
|
||
updateMock.mockReset().mockResolvedValue({});
|
||
event = new WideEventBuilder("background");
|
||
});
|
||
|
||
describe("upsertFitbitMeasurements — plausibility gate", () => {
|
||
it("writes a reading inside the metric's declared band", async () => {
|
||
createManyMock.mockResolvedValue([
|
||
{
|
||
id: "row-1",
|
||
type: "RESTING_HEART_RATE",
|
||
measuredAt: REAL_READING.measuredAt,
|
||
},
|
||
]);
|
||
|
||
const result = await eventStorage.run(event, () =>
|
||
upsertFitbitMeasurements("user-1", [REAL_READING], { deferRollup: true }),
|
||
);
|
||
|
||
expect(result.imported).toBe(1);
|
||
const planned = (createManyMock.mock.calls[0] as unknown[])[0] as {
|
||
data: Array<{ value: number }>;
|
||
};
|
||
expect(planned.data).toHaveLength(1);
|
||
expect(planned.data[0]!.value).toBe(58);
|
||
expect(tally()).toBeUndefined();
|
||
});
|
||
|
||
it("refuses an out-of-band reading and annotates the drop", async () => {
|
||
createManyMock.mockResolvedValue([
|
||
{
|
||
id: "row-1",
|
||
type: "RESTING_HEART_RATE",
|
||
measuredAt: REAL_READING.measuredAt,
|
||
},
|
||
]);
|
||
|
||
const result = await eventStorage.run(event, () =>
|
||
upsertFitbitMeasurements("user-1", [REAL_READING, IMPOSSIBLE_READING], {
|
||
deferRollup: true,
|
||
}),
|
||
);
|
||
|
||
expect(result.imported).toBe(1);
|
||
const planned = (createManyMock.mock.calls[0] as unknown[])[0] as {
|
||
data: Array<{ value: number; externalId: string }>;
|
||
};
|
||
expect(planned.data).toHaveLength(1);
|
||
expect(planned.data[0]!.externalId).toBe("rhr:2026-07-08");
|
||
// The refused reading never even reached the existence probe.
|
||
expect(
|
||
(
|
||
(findManyMock.mock.calls[0] as unknown[])[0] as {
|
||
where: { externalId: { in: string[] } };
|
||
}
|
||
).where.externalId.in,
|
||
).toEqual(["rhr:2026-07-08"]);
|
||
expect(tally()).toEqual({
|
||
total: 1,
|
||
buckets: [
|
||
{
|
||
source: "fitbit",
|
||
type: "RESTING_HEART_RATE",
|
||
direction: "above_max",
|
||
count: 1,
|
||
},
|
||
],
|
||
});
|
||
expect(event.getLevel()).toBe("warn");
|
||
});
|
||
|
||
it("does not overwrite a stored reading with an out-of-band one", async () => {
|
||
// The key already exists; ungated, the update branch rewrote its value.
|
||
findManyMock.mockResolvedValue([
|
||
{
|
||
id: "existing",
|
||
type: "RESTING_HEART_RATE",
|
||
externalId: "rhr:2026-07-09",
|
||
value: 58,
|
||
unit: "bpm",
|
||
measuredAt: IMPOSSIBLE_READING.measuredAt,
|
||
sleepStage: null,
|
||
deletedAt: null,
|
||
},
|
||
]);
|
||
|
||
const result = await eventStorage.run(event, () =>
|
||
upsertFitbitMeasurements("user-1", [IMPOSSIBLE_READING], {
|
||
deferRollup: true,
|
||
}),
|
||
);
|
||
|
||
expect(result.imported).toBe(0);
|
||
expect(updateMock).not.toHaveBeenCalled();
|
||
expect(createManyMock).not.toHaveBeenCalled();
|
||
expect(tally()?.total).toBe(1);
|
||
});
|
||
});
|