Merge branch 'fix/v1391-e2e' into release/v1.39.1

This commit is contained in:
Marc-André Bombeck committed 2026-09-25 13:55:07 +02:00
commit 9b999233b3
5 files changed
+249 -10

No files matched your search

+33 -2
View File
@@ -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),
+25 -8
View File
@@ -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
+34
View File
@@ -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();
});
});