mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(withings): keep weight and blood pressure when every request is refused
A run where Withings refused every requested type stored all of them, core types included, as outside the plan for a week; later runs then requested nothing and moved the watermark on with no readings, losing the gap. A refusal is now remembered only from a run in which another type was accepted. A run that read nothing keeps the watermark and reports that it did not land, and the watermark also holds while plan memory skips a core type (weight, blood pressure, pulse, free body composition).
This commit is contained in:
1 parent
8ce6a83da5
commit
6feaf6849a
4 files changed
+175
-18
No files matched your search
@@ -105,6 +105,7 @@ import {
|
||||
import {
|
||||
releasePlanGatedWithingsParks,
|
||||
requestWithinPlan,
|
||||
shouldPersistPlanLeg,
|
||||
WITHINGS_ADVANCED_MEASTYPES,
|
||||
WITHINGS_PLAN_MAX_REQUESTS,
|
||||
} from "../api-plan";
|
||||
@@ -394,19 +395,128 @@ describe("syncUserMeasurements inside the Withings API plan", () => {
|
||||
expect(requestedStartdates.every((s) => s === expected)).toBe(true);
|
||||
});
|
||||
|
||||
it("holds the watermark and records no failure when the plan refuses everything", async () => {
|
||||
it("treats a run where every request was refused as transient: nothing remembered, watermark held", async () => {
|
||||
refused = new Set([
|
||||
1, 5, 6, 8, 9, 10, 11, 12, 35, 54, 71, 76, 77, 88, 91, 123, 155, 170, 73,
|
||||
]);
|
||||
|
||||
const result = await syncUserMeasurements("user-1");
|
||||
|
||||
expect(result).toEqual({ imported: 0, failed: false });
|
||||
expect(result).toEqual({ imported: 0, failed: true });
|
||||
expect(prisma.withingsConnection.update).not.toHaveBeenCalled();
|
||||
// A 277 stays off the ledger, and a run that read nothing is no success.
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
expect(recordSyncSuccess).not.toHaveBeenCalled();
|
||||
expect(requestedTypes.length).toBeLessThanOrEqual(
|
||||
WITHINGS_PLAN_MAX_REQUESTS,
|
||||
);
|
||||
// Weight and blood pressure are not remembered as refused.
|
||||
expect(persistedLegs()).toEqual([]);
|
||||
|
||||
// The fault clears; the next run asks for everything again and reads the
|
||||
// whole gap since the last good sync.
|
||||
refused = new Set(WITHINGS_ADVANCED_MEASTYPES);
|
||||
requestedTypes.length = 0;
|
||||
requestedStartdates.length = 0;
|
||||
const next = await syncUserMeasurements("user-1");
|
||||
expect(next.failed).toBe(false);
|
||||
expect(requestedTypes[0]).toEqual(expect.arrayContaining([1, 9, 10, 11]));
|
||||
expect(createdTypes()).toEqual(
|
||||
expect.arrayContaining(["WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE"]),
|
||||
);
|
||||
const fromLastGood = String(
|
||||
Math.floor(
|
||||
(LAST_GOOD_SYNC.getTime() - WITHINGS_INCREMENTAL_OVERLAP_MS) / 1000,
|
||||
),
|
||||
);
|
||||
expect(requestedStartdates.every((s) => s === fromLastGood)).toBe(true);
|
||||
});
|
||||
|
||||
it("holds the watermark when plan memory leaves nothing to request", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(
|
||||
connection({
|
||||
version: 1,
|
||||
measures: {
|
||||
unavailable: [...WITHINGS_MEASTYPES],
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const result = await syncUserMeasurements("user-1");
|
||||
|
||||
expect(requestedTypes).toHaveLength(0);
|
||||
expect(result).toEqual({ imported: 0, failed: true });
|
||||
expect(prisma.withingsConnection.update).not.toHaveBeenCalled();
|
||||
expect(recordSyncSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("holds the watermark when a core type was skipped from plan memory", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(
|
||||
connection({
|
||||
version: 1,
|
||||
measures: {
|
||||
unavailable: [1, ...WITHINGS_ADVANCED_MEASTYPES],
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const result = await syncUserMeasurements("user-1");
|
||||
|
||||
expect(requestedTypes).toHaveLength(1);
|
||||
expect(requestedTypes[0]).not.toContain(1);
|
||||
expect(result.failed).toBe(false);
|
||||
expect(createdTypes()).toContain("BLOOD_PRESSURE_SYS");
|
||||
// Weight was not read in this window, so the window stays open for it.
|
||||
expect(prisma.withingsConnection.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still moves the watermark when only advanced types are remembered", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(
|
||||
connection({
|
||||
version: 1,
|
||||
measures: {
|
||||
unavailable: [...WITHINGS_ADVANCED_MEASTYPES],
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
|
||||
await syncUserMeasurements("user-1");
|
||||
|
||||
expect(prisma.withingsConnection.update).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1" },
|
||||
data: { lastSyncedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldPersistPlanLeg", () => {
|
||||
it("remembers a refusal only from a run where something else was accepted", () => {
|
||||
expect(
|
||||
shouldPersistPlanLeg({ gated: true, reprobed: false, acceptedAny: true }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldPersistPlanLeg({
|
||||
gated: true,
|
||||
reprobed: false,
|
||||
acceptedAny: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldPersistPlanLeg({ gated: true, reprobed: true, acceptedAny: false }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldPersistPlanLeg({ gated: false, reprobed: true, acceptedAny: true }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldPersistPlanLeg({
|
||||
gated: false,
|
||||
reprobed: false,
|
||||
acceptedAny: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -58,6 +58,18 @@ export const WITHINGS_ADVANCED_MEASTYPES: readonly number[] = [
|
||||
91, 155, 170, 123, 73,
|
||||
];
|
||||
|
||||
/**
|
||||
* The meastypes Withings lists as free in every plan and that carry the core
|
||||
* of the measure leg: weight (1), blood pressure (9, 10), pulse (11) and the
|
||||
* body-composition readings the table lists as free (fat-free mass 5, fat
|
||||
* mass 8, muscle mass 76, hydration 77, bone mass 88). A run that leaves one
|
||||
* of them out on the strength of plan memory has not read the window for it,
|
||||
* so the measure watermark must not move past it.
|
||||
*/
|
||||
export const WITHINGS_CORE_MEASTYPES: readonly number[] = [
|
||||
1, 5, 8, 9, 10, 11, 76, 77, 88,
|
||||
];
|
||||
|
||||
/**
|
||||
* Sleep-summary fields HealthLog requests that the table marks advanced: the
|
||||
* sleep score, the overnight HRV and the respiration rate. The nightly average
|
||||
@@ -323,15 +335,23 @@ export async function persistWithingsPlanLeg(
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a run's outcome is worth writing down. A run that met a 277
|
||||
* always is; so is a re-probe (the full set was asked for), whatever it found;
|
||||
* a run that skipped remembered keys and was accepted changes nothing.
|
||||
* Decide whether a run's outcome is worth writing down.
|
||||
*
|
||||
* A run that met a 277 is, but only when some other request in the same run
|
||||
* was accepted: a plan never refuses everything, so a run where every request
|
||||
* was refused (a transient fault upstream, a misconfigured developer app)
|
||||
* proves nothing about any single key, and remembering it would skip weight
|
||||
* and blood pressure for a week. A re-probe that met no 277 is written down
|
||||
* too, so a cleared refusal is forgotten. A run that skipped remembered keys
|
||||
* and was accepted changes nothing.
|
||||
*/
|
||||
export function shouldPersistPlanLeg(opts: {
|
||||
gated: boolean;
|
||||
reprobed: boolean;
|
||||
acceptedAny: boolean;
|
||||
}): boolean {
|
||||
return opts.gated || opts.reprobed;
|
||||
if (opts.gated) return opts.acceptedAny;
|
||||
return opts.reprobed;
|
||||
}
|
||||
|
||||
// ── lifting parks an earlier build left ──────────────────────────────────────
|
||||
|
||||
@@ -566,7 +566,13 @@ export async function syncUserSleep(
|
||||
),
|
||||
});
|
||||
summaries = plan.results;
|
||||
if (shouldPersistPlanLeg({ gated: plan.gated, reprobed })) {
|
||||
if (
|
||||
shouldPersistPlanLeg({
|
||||
gated: plan.gated,
|
||||
reprobed,
|
||||
acceptedAny: plan.acceptedAny,
|
||||
})
|
||||
) {
|
||||
await persistWithingsPlanLeg(userId, "sleepSummary", {
|
||||
unavailable: [...skip, ...plan.unavailable],
|
||||
checkedAt: now.toISOString(),
|
||||
|
||||
+32
-11
@@ -22,6 +22,7 @@ import {
|
||||
requestWithinPlan,
|
||||
shouldPersistPlanLeg,
|
||||
WITHINGS_ADVANCED_MEASTYPES,
|
||||
WITHINGS_CORE_MEASTYPES,
|
||||
WITHINGS_PLAN_GATED_STATUS,
|
||||
type WithinPlanResult,
|
||||
} from "./api-plan";
|
||||
@@ -273,7 +274,13 @@ export async function syncUserMeasurements(
|
||||
await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_MEASURES);
|
||||
throw err;
|
||||
}
|
||||
if (shouldPersistPlanLeg({ gated: plan.gated, reprobed })) {
|
||||
if (
|
||||
shouldPersistPlanLeg({
|
||||
gated: plan.gated,
|
||||
reprobed,
|
||||
acceptedAny: plan.acceptedAny,
|
||||
})
|
||||
) {
|
||||
try {
|
||||
await persistWithingsPlanLeg(userId, "measures", {
|
||||
unavailable: [...skip, ...plan.unavailable].sort((a, b) => a - b),
|
||||
@@ -297,15 +304,26 @@ export async function syncUserMeasurements(
|
||||
},
|
||||
});
|
||||
}
|
||||
if (plan.gated && !plan.acceptedAny) {
|
||||
// The plan refused every type asked for. Nothing was read, so the
|
||||
// watermark stays where it is (a later plan change re-reads the gap), and
|
||||
// nothing failed: the connection is healthy, the plan is the limit. The
|
||||
// settings card names what the plan withholds.
|
||||
await recordSyncSuccess(userId, "withings", {
|
||||
leg: WITHINGS_LEG_MEASURES,
|
||||
if (!plan.acceptedAny) {
|
||||
// Nothing was read: either plan memory left nothing to ask for, or every
|
||||
// request was refused. Every plan carries weight and blood pressure, so a
|
||||
// run where all of it was refused is a fault upstream, not a plan answer.
|
||||
// Nothing is remembered (above), the watermark stays where it is so the
|
||||
// next run reads the whole gap, and the run reports that it did not land.
|
||||
// It stays off the failure ledger like every other 277: a plan answer must
|
||||
// never climb the strike ladder or park the connection.
|
||||
getEvent()?.addWarning(
|
||||
requested.length === 0
|
||||
? "withings: plan memory left no measure type to request"
|
||||
: "withings: every measure request was refused with 277",
|
||||
);
|
||||
annotate({
|
||||
action: {
|
||||
name: "withings.sync.plan_nothing_read",
|
||||
details: { requested: requested.length, requests: plan.requests },
|
||||
},
|
||||
});
|
||||
return { imported: 0, failed: false };
|
||||
return { imported: 0, failed: true };
|
||||
}
|
||||
const fetched = plan.results;
|
||||
|
||||
@@ -491,12 +509,15 @@ export async function syncUserMeasurements(
|
||||
// the watermark past them would drop their readings in this window for
|
||||
// good, so it stays put until a run reads (or proves refused) every type.
|
||||
// Re-reading the accepted types is idempotent.
|
||||
const holdWatermark = plan.unverified.length > 0;
|
||||
// The same holds for a core type left out on the strength of plan memory:
|
||||
// that memory may be wrong, and the re-probe must find the window intact.
|
||||
const skippedCore = skip.filter((t) => WITHINGS_CORE_MEASTYPES.includes(t));
|
||||
const holdWatermark = plan.unverified.length > 0 || skippedCore.length > 0;
|
||||
if (holdWatermark) {
|
||||
annotate({
|
||||
action: {
|
||||
name: "withings.sync.plan_watermark_held",
|
||||
details: { unverified: plan.unverified },
|
||||
details: { unverified: plan.unverified, skippedCore },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user