mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge branch 'fix/v1391-e2e' into release/v1.39.1
This commit is contained in:
commit
9b999233b3
5 files changed
+249
-10
No files matched your search
@@ -532,7 +532,38 @@ test.describe("Backup and restore, through the settings surfaces", () => {
|
||||
|
||||
await openBackupsConsole(page);
|
||||
const response = await restoreThroughDialog(page, requireSnapshot());
|
||||
expect(response.status()).toBe(200);
|
||||
// The restore runs on the `backup-restore` queue: the request answers 202
|
||||
// with the job and its status route, and the worker inside the server
|
||||
// under test does the work. Nothing below is read until the job says it
|
||||
// is done, so every assertion is about the finished restore rather than
|
||||
// a moment in the middle of it.
|
||||
expect(response.status()).toBe(202);
|
||||
const queued = (await response.json()) as {
|
||||
data: { jobId: string; status: string; statusUrl: string };
|
||||
};
|
||||
expect(queued.data.status).toBe("queued");
|
||||
expect(queued.data.statusUrl).toBe(
|
||||
`/api/admin/backups/restores/${queued.data.jobId}`,
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await page.request.get(queued.data.statusUrl);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
status: string;
|
||||
failure: { code: string } | null;
|
||||
} | null;
|
||||
};
|
||||
// A failed job ends the wait with its code in the message instead
|
||||
// of running the clock out on a state that will never change.
|
||||
return body.data?.status === "failed"
|
||||
? `failed: ${body.data.failure?.code ?? "unknown"}`
|
||||
: body.data?.status;
|
||||
},
|
||||
{ timeout: 60_000, intervals: [250, 500, 1_000] },
|
||||
)
|
||||
.toBe("succeeded");
|
||||
|
||||
const afterRestore = await storedMeasurements();
|
||||
|
||||
@@ -564,7 +595,7 @@ test.describe("Backup and restore, through the settings surfaces", () => {
|
||||
|
||||
// And the reading that arrived after the snapshot is gone.
|
||||
// `docs/ops/backup-restore.md` says the restore "replaces the account's
|
||||
// data tables", and the route's own words are "one transaction replaces
|
||||
// data tables", and `restore-backup.ts` puts it as "one transaction replaces
|
||||
// every serialized owner-scoped class". Replacing is not merging: whoever
|
||||
// restores yesterday's copy loses today's readings, and that is the
|
||||
// documented behaviour rather than an accident of the implementation.
|
||||
|
||||
@@ -29,6 +29,11 @@ vi.mock("@/lib/analytics/summaries-slice", () => ({
|
||||
vi.mock("@/lib/rollups/measurement-coverage", () => ({
|
||||
probeRollupCoverage: (...a: unknown[]) => probeRollupCoverage(...a),
|
||||
isFullyCovered: (...a: unknown[]) => isFullyCovered(...a),
|
||||
// Every uncovered type asked about has readings inside the fold window, so
|
||||
// a miss here is a real miss. The pre-window case runs against Postgres in
|
||||
// `tests/integration/dashboard-snapshot-pre-window-types.test.ts`.
|
||||
typesWithReadingsInFoldWindow: async (_userId: string, types: string[]) =>
|
||||
new Set(types),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/mood-rollups", () => ({
|
||||
readMoodDayRollups: (...a: unknown[]) => readMoodDayRollups(...a),
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
} from "@/lib/targets/weight-trend";
|
||||
import {
|
||||
probeRollupCoverage,
|
||||
typesWithReadingsInFoldWindow,
|
||||
type RollupCoverageMap,
|
||||
} from "@/lib/rollups/measurement-coverage";
|
||||
import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
|
||||
@@ -1091,18 +1092,34 @@ function buildLayoutCatalogue(
|
||||
* cheap (empty reads) and absence must not cold the phase. A type
|
||||
* present-but-`false` has live rows without buckets — for these dense types
|
||||
* that is exactly the multi-second live fallback the snapshot must never
|
||||
* wait on, so the phase stays `null` until the backfill converges.
|
||||
* wait on, so the phase stays `null` until the backfill converges — unless
|
||||
* none of its readings lies inside the fold window. The fold never writes a
|
||||
* bucket older than `ROLLUP_FOLD_WINDOW_MS`, so such a type is not waiting
|
||||
* for anything: counting it as a miss held the phase `null` for good on an
|
||||
* account whose blood pressure, say, was all imported history. Its live reads
|
||||
* cover only rows older than the window, and the window reads come back
|
||||
* empty. The second query runs on the miss path alone.
|
||||
* An empty map is a fresh account with no measurements at all — nothing to
|
||||
* compute, so the thick phase stays `null`.
|
||||
*/
|
||||
function isThickPhaseWarm(coverage: RollupCoverageMap): boolean {
|
||||
const THICK_PHASE_GATE_TYPES = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"ACTIVITY_STEPS",
|
||||
] as const;
|
||||
|
||||
async function isThickPhaseWarm(
|
||||
userId: string,
|
||||
coverage: RollupCoverageMap,
|
||||
): Promise<boolean> {
|
||||
if (coverage.size === 0) return false;
|
||||
return (
|
||||
coverage.get("WEIGHT") !== false &&
|
||||
coverage.get("BLOOD_PRESSURE_SYS") !== false &&
|
||||
coverage.get("BLOOD_PRESSURE_DIA") !== false &&
|
||||
coverage.get("ACTIVITY_STEPS") !== false
|
||||
const uncovered = THICK_PHASE_GATE_TYPES.filter(
|
||||
(type) => coverage.get(type) === false,
|
||||
);
|
||||
if (uncovered.length === 0) return true;
|
||||
const foldable = await typesWithReadingsInFoldWindow(userId, uncovered);
|
||||
return foldable.size === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1262,7 +1279,7 @@ export async function buildDashboardSnapshot(
|
||||
// paint-together vs slowest-wins). Unrelated uncovered types do NOT
|
||||
// cold the phase (see `isThickPhaseWarm`).
|
||||
const coverage = await time("coverage", () => probeRollupCoverage(user.id));
|
||||
const warm = isThickPhaseWarm(coverage);
|
||||
const warm = await isThickPhaseWarm(user.id, coverage);
|
||||
|
||||
// Resolve the stored layout once up front: the hero score rings read
|
||||
// the `selectedScoreRings` preference off it, and the module-gated
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
* read regardless of how many types the user has logged.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ROLLUP_FOLD_WINDOW_MS } from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
/**
|
||||
* Per-type coverage map. `true` means at least one DAY rollup row
|
||||
@@ -82,3 +83,36 @@ export function isFullyCovered(coverage: RollupCoverageMap): boolean {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Of `types`, the ones this user has a live reading of inside the fold
|
||||
* window — the only ones the fold can ever give a bucket.
|
||||
*
|
||||
* The fold writes buckets inside `ROLLUP_FOLD_WINDOW_MS` and nowhere else, so
|
||||
* a type whose every reading is older than that reports `false` in
|
||||
* `probeRollupCoverage` for good. A caller that waits for coverage before
|
||||
* taking a path has to tell that apart from a type the backfill simply has
|
||||
* not reached yet, or it waits forever. One indexed `EXISTS` per type on
|
||||
* `(user_id, type, measured_at)`; callers ask only about the types their
|
||||
* probe found uncovered.
|
||||
*/
|
||||
export async function typesWithReadingsInFoldWindow(
|
||||
userId: string,
|
||||
types: readonly string[],
|
||||
): Promise<Set<string>> {
|
||||
if (types.length === 0) return new Set();
|
||||
const windowStart = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS);
|
||||
const rows = await prisma.$queryRaw<Array<{ type: string }>>`
|
||||
SELECT candidate."type"
|
||||
FROM unnest(${[...types]}::text[]) AS candidate("type")
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM measurements m
|
||||
WHERE m.user_id = ${userId}
|
||||
AND m."type" = candidate."type"::measurement_type
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" >= ${windowStart}
|
||||
)
|
||||
`;
|
||||
return new Set(rows.map((row) => row.type));
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* The dashboard's second phase (blood-pressure in target, the per-context
|
||||
* glucose tiles, the Health Score) waits until the rollup tier covers the
|
||||
* types it reads: weight, both blood-pressure halves and steps. The fold only
|
||||
* ever writes buckets inside `ROLLUP_FOLD_WINDOW_MS`, by design, so a type
|
||||
* whose every reading is older than that can never be covered. Counting it as
|
||||
* "not yet covered" held the phase back for good: an account that imported
|
||||
* years-old blood pressure and nothing since lost its glucose tiles, its
|
||||
* in-target figure and its score, with nothing to say why.
|
||||
*
|
||||
* Real Postgres, real fold, real builder.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
import { buildDashboardSnapshot } from "@/lib/dashboard/snapshot";
|
||||
import {
|
||||
recomputeUserRollups,
|
||||
ROLLUP_FOLD_WINDOW_MS,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
async function seed(options: { recentBloodPressure: boolean }) {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "pre-window-types",
|
||||
email: "pre-window-types@example.test",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
const now = Date.now();
|
||||
const beforeWindow = new Date(now - ROLLUP_FOLD_WINDOW_MS - 400 * DAY_MS);
|
||||
const bpAt = options.recentBloodPressure
|
||||
? new Date(now - 2 * DAY_MS)
|
||||
: beforeWindow;
|
||||
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: user.id,
|
||||
type: "WEIGHT",
|
||||
value: 78,
|
||||
unit: "kg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(now - DAY_MS),
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 118,
|
||||
unit: "mmHg",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: bpAt,
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 76,
|
||||
unit: "mmHg",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: bpAt,
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 8421,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: beforeWindow,
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 95,
|
||||
unit: "mg/dL",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(now - DAY_MS),
|
||||
glucoseContext: "FASTING",
|
||||
},
|
||||
],
|
||||
});
|
||||
// The fold every path runs: the trailing window, nothing older.
|
||||
await recomputeUserRollups(user.id);
|
||||
return user;
|
||||
}
|
||||
|
||||
function snapshotFor(user: Awaited<ReturnType<typeof seed>>) {
|
||||
return buildDashboardSnapshot(
|
||||
getPrismaClient(),
|
||||
{
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: null,
|
||||
timezone: user.timezone,
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
glucoseUnit: null,
|
||||
onboardingTourCompleted: true,
|
||||
insightsCachedText: null,
|
||||
insightsCachedAt: null,
|
||||
insightsCachedLocale: null,
|
||||
dashboardWidgetsJson: null,
|
||||
thresholdsJson: null,
|
||||
healthScoreConfigJson: null,
|
||||
},
|
||||
{ hasProvider: async () => false },
|
||||
);
|
||||
}
|
||||
|
||||
describe("dashboard snapshot with readings older than the fold window", () => {
|
||||
it("does not hold the second phase back for types the fold can never cover", async () => {
|
||||
const user = await seed({ recentBloodPressure: false });
|
||||
|
||||
// The premise: the old types really have no buckets, and never will.
|
||||
const buckets = await getPrismaClient().measurementRollup.findMany({
|
||||
where: { userId: user.id, granularity: "DAY" },
|
||||
select: { type: true },
|
||||
});
|
||||
const covered = new Set(buckets.map((b) => b.type));
|
||||
expect(covered.has("BLOOD_PRESSURE_SYS")).toBe(false);
|
||||
expect(covered.has("ACTIVITY_STEPS")).toBe(false);
|
||||
expect(covered.has("WEIGHT")).toBe(true);
|
||||
|
||||
const snap = await snapshotFor(user);
|
||||
|
||||
expect(snap.extras).not.toBeNull();
|
||||
expect(snap.extras?.glucoseByContext.FASTING?.count).toBe(1);
|
||||
// The old readings are still the account's: the tile strip reports them.
|
||||
expect(snap.tiles.summaries.BLOOD_PRESSURE_SYS?.latest).toBe(118);
|
||||
});
|
||||
|
||||
it("still waits while a type inside the window has no buckets yet", async () => {
|
||||
const user = await seed({ recentBloodPressure: true });
|
||||
// A reading the fold has not reached yet: inside the window, no bucket.
|
||||
await getPrismaClient().measurementRollup.deleteMany({
|
||||
where: { userId: user.id, type: "BLOOD_PRESSURE_SYS" },
|
||||
});
|
||||
|
||||
const snap = await snapshotFor(user);
|
||||
|
||||
expect(snap.extras).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user