mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Merge remote-tracking branch 'origin/main' into fix/v1398-polish
This commit is contained in:
commit
ba426706be
7 files changed
+131
-5
No files matched your search
@@ -64,10 +64,15 @@ const MAX_SAMPLES = 32_768;
|
||||
// any real recording. A DoS ceiling, not a tight bound.
|
||||
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
// One recording per request, so the limit is per recording. A person records
|
||||
// a handful of strips a week; 60/min still drains a full watch history in
|
||||
// minutes and stops a leaked wildcard token from saturating the write path.
|
||||
const INGEST_RATE_LIMIT_MAX = 60;
|
||||
// One recording per request, so the limit is per recording. The iPhone app
|
||||
// sends every recording its sweep has not yet consumed in one go and, on a
|
||||
// 429, stops and starts the same sweep again on its next wake. A window that
|
||||
// held more than the limit could therefore never finish: with 60 per minute
|
||||
// the 61st recording was refused on every sync and the newest ECG never
|
||||
// arrived (#1060). 600 covers more than a decade of weekly strips in one
|
||||
// sweep and still stops a leaked token from saturating the write path; a
|
||||
// request is at most 2 MB and usually about 100 KB.
|
||||
const INGEST_RATE_LIMIT_MAX = 600;
|
||||
const INGEST_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
// Only the three ECG verdicts. `RhythmClassification` in the database has six
|
||||
|
||||
@@ -207,6 +207,42 @@ describe("POST /api/workouts/batch — which credentials it admits", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch — rate limit buckets", () => {
|
||||
function lastBucket(): string {
|
||||
const calls = vi.mocked(checkRateLimit).mock.calls;
|
||||
return calls[calls.length - 1][0] as string;
|
||||
}
|
||||
|
||||
it("counts a workouts:write token apart from the phone", async () => {
|
||||
armToken([WORKOUTS_WRITE_SCOPE]);
|
||||
await POST(makeRequest({ workouts: [workout("s-1")] }));
|
||||
const bridge = lastBucket();
|
||||
|
||||
armToken(["*"]);
|
||||
await POST(
|
||||
makeRequest({ workouts: [workout("s-2", { source: "APPLE_HEALTH" })] }),
|
||||
);
|
||||
const phone = lastBucket();
|
||||
|
||||
expect(bridge).not.toBe(phone);
|
||||
expect(bridge).toContain(USER.id);
|
||||
expect(phone).toContain(USER.id);
|
||||
});
|
||||
|
||||
it("keeps a session and a wildcard token in the same bucket", async () => {
|
||||
armSession();
|
||||
await POST(
|
||||
makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }),
|
||||
);
|
||||
const session = lastBucket();
|
||||
armToken(["*"]);
|
||||
await POST(
|
||||
makeRequest({ workouts: [workout("s-2", { source: "APPLE_HEALTH" })] }),
|
||||
);
|
||||
expect(lastBucket()).toBe(session);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch — what a scoped caller writes", () => {
|
||||
it("attributes its rows to EXTERNAL", async () => {
|
||||
armToken([WORKOUTS_WRITE_SCOPE]);
|
||||
|
||||
@@ -232,8 +232,14 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// database for every other user on the host. 60 batches/min/user is
|
||||
// generous for healthy iOS sync and tight enough that a misbehaving
|
||||
// client bottoms out within a minute.
|
||||
//
|
||||
// A scoped `workouts:write` token is a bridge, not the phone, so it counts
|
||||
// in a bucket of its own: a bridge stuck in a retry loop must not use up
|
||||
// the allowance the iPhone app needs to sync the same account's workouts.
|
||||
// All bridge tokens of one account share that bucket, so minting more
|
||||
// tokens does not multiply the allowance.
|
||||
const rl = await checkRateLimit(
|
||||
`workouts:batch:${user.id}`,
|
||||
scoped ? `workouts:batch:bridge:${user.id}` : `workouts:batch:${user.id}`,
|
||||
BATCH_RATE_LIMIT_MAX,
|
||||
BATCH_RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The manual-entry form offers the resting heart rate (#1079).
|
||||
*
|
||||
* Resting heart rate has its own chart and series, separate from spot pulse.
|
||||
* A value read off a watch or taken on waking had no row in the form, so it
|
||||
* could only be logged as a pulse, which feeds a different chart.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { VALUE_RANGES, getUnitForType } from "@/lib/validations/measurement";
|
||||
import {
|
||||
MEASUREMENT_FORM_TYPE_VALUES,
|
||||
MEASUREMENT_TYPES,
|
||||
} from "@/components/measurements/measurement-form";
|
||||
import en from "../../../../messages/en.json";
|
||||
|
||||
describe("manual entry of the resting heart rate", () => {
|
||||
it("offers it as its own type, next to pulse", () => {
|
||||
expect(MEASUREMENT_FORM_TYPE_VALUES).toContain("RESTING_HEART_RATE");
|
||||
const values = MEASUREMENT_TYPES.map((t) => t.value);
|
||||
expect(values.indexOf("RESTING_HEART_RATE")).toBe(
|
||||
values.indexOf("PULSE") + 1,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the server's unit, a labelled row and a plausible placeholder", () => {
|
||||
const row = MEASUREMENT_TYPES.find((t) => t.value === "RESTING_HEART_RATE");
|
||||
expect(row).toBeDefined();
|
||||
expect(row && "unit" in row ? row.unit : undefined).toBe(
|
||||
getUnitForType("RESTING_HEART_RATE"),
|
||||
);
|
||||
expect(
|
||||
(en as { measurements: Record<string, string> }).measurements
|
||||
.typeRestingHeartRate,
|
||||
).toBeTruthy();
|
||||
const range = VALUE_RANGES.RESTING_HEART_RATE;
|
||||
const placeholder = Number(
|
||||
row && "placeholder" in row ? row.placeholder : NaN,
|
||||
);
|
||||
if (range) {
|
||||
expect(placeholder).toBeGreaterThanOrEqual(range.min);
|
||||
expect(placeholder).toBeLessThanOrEqual(range.max);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,14 @@ export const MEASUREMENT_TYPES = [
|
||||
unit: "bpm",
|
||||
placeholder: "72",
|
||||
},
|
||||
// The resting heart rate has its own chart and series; a value read off a
|
||||
// watch or taken on waking belongs there rather than among spot pulses.
|
||||
{
|
||||
value: "RESTING_HEART_RATE",
|
||||
labelKey: "measurements.typeRestingHeartRate",
|
||||
unit: "bpm",
|
||||
placeholder: "58",
|
||||
},
|
||||
{
|
||||
value: "BODY_FAT",
|
||||
labelKey: "measurements.typeBodyFat",
|
||||
|
||||
@@ -100,6 +100,9 @@ const STATIC_SURFACE_MODULE = {
|
||||
"insights-page:six-minute-walk": "recovery",
|
||||
"insights-page:stair-ascent-speed": "recovery",
|
||||
"insights-page:stair-descent-speed": "recovery",
|
||||
// The resting heart rate is the recovery module's `resting_hr` domain; its
|
||||
// page, trend slot, correlation channel and summary key follow it.
|
||||
"insights-page:resting-pulse": "recovery",
|
||||
|
||||
// ── Insights overview blocks ──
|
||||
"overview:cycle-summary": "cycle",
|
||||
@@ -115,6 +118,7 @@ const STATIC_SURFACE_MODULE = {
|
||||
// ── Trends row ──
|
||||
"trend:mood": "mood",
|
||||
"trend:sleep": "sleep",
|
||||
"trend:resting_hr": "recovery",
|
||||
|
||||
// ── Add menu ──
|
||||
"capture:mood": "mood",
|
||||
@@ -141,6 +145,9 @@ const STATIC_SURFACE_MODULE = {
|
||||
"summary:SLEEP_DURATION": "sleep",
|
||||
"summary:BLOOD_GLUCOSE": "glucose",
|
||||
"summary:CARDIO_RECOVERY": "recovery",
|
||||
// The recovery module owns the resting_hr domain (`measurement-scope.ts`),
|
||||
// so the server gates the type on it; the form and the dashboard follow.
|
||||
"summary:RESTING_HEART_RATE": "recovery",
|
||||
"summary:SIX_MINUTE_WALK_DISTANCE": "recovery",
|
||||
"summary:STAIR_ASCENT_SPEED": "recovery",
|
||||
"summary:STAIR_DESCENT_SPEED": "recovery",
|
||||
@@ -189,6 +196,7 @@ const STATIC_SURFACE_MODULE = {
|
||||
"correlation:MOOD": "mood",
|
||||
"correlation:SLEEP_DURATION": "sleep",
|
||||
"correlation:BLOOD_GLUCOSE": "glucose",
|
||||
"correlation:RESTING_HEART_RATE": "recovery",
|
||||
"correlation:MEDICATION_COMPLIANCE": "medications",
|
||||
// Symptom burden is read from the illness journal.
|
||||
"correlation:SYMPTOM_SEVERITY": "illness",
|
||||
|
||||
@@ -205,6 +205,24 @@ describe("POST /api/insights/ecg — round trip through the real routes", () =>
|
||||
expect(posted.data.durationSeconds).toBeCloseTo(SAMPLES.length / 512, 10);
|
||||
});
|
||||
|
||||
it("takes a whole sweep of recordings in one go (#1060)", async () => {
|
||||
// The app posts every recording its sweep has not consumed and restarts
|
||||
// the sweep after a 429; a limit below the sweep's size refused the same
|
||||
// last recording on every sync. Seventy distinct strips must all land.
|
||||
const statuses: number[] = [];
|
||||
for (let i = 0; i < 70; i += 1) {
|
||||
const posted = await ingest(
|
||||
ingestPayload({
|
||||
externalRecordingId: `sweep-${i}`,
|
||||
recordedAt: new Date(Date.UTC(2026, 5, 1, 8, i)).toISOString(),
|
||||
}),
|
||||
);
|
||||
statuses.push(posted.status);
|
||||
}
|
||||
expect(statuses.filter((s) => s === 429)).toHaveLength(0);
|
||||
expect(await getPrismaClient().ecgRecording.count()).toBe(70);
|
||||
});
|
||||
|
||||
it("never writes the waveform as plaintext", async () => {
|
||||
const posted = await ingest(ingestPayload());
|
||||
const row = await getPrismaClient().ecgRecording.findUniqueOrThrow({
|
||||
|
||||
Reference in new issue
Block a user