Files
HealthLog/e2e/today-just-in.spec.ts
Marc-André Bombeck 8b5199ee00 test(e2e): tag the just-in sleep batches as a foreground sync
A batch without a trigger only marks the dashboard snapshot stale since
v1.42, so a snapshot an earlier spec left for the account painted the day
final before this journey's own night counted. Foreground evicts.
2026-10-09 00:41:56 +02:00

313 lines
14 KiB
TypeScript

import { expect, test } from "./setup/test";
import pg from "pg";
import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.31.0 — Today reacts in place.
*
* The milestone's actual moment: new data lands, and the ALREADY-OPEN
* dashboard starts reading differently on the cadence it already runs. No new
* poll, no push, no socket — a state change, not a notification.
*
* The spec drives that end-to-end against the real read path:
*
* 0. give the account a reason to paint a hero at all — one integration
* needing a reconnect, which is an ordinary Today for someone whose
* readings sync from devices;
* 1. load the dashboard with last night's sleep still missing, and prove
* it reads provisional with the pending note up;
* 2. land it through the REAL batch endpoint, and the arrival marker the
* spine's worker would have written;
* 3. surface the tab's real visibility-change signal — the browser event
* TanStack Query's focus manager consumes for
* `refetchOnWindowFocus: "always"` (alongside the 120 s foreground poll);
* using that existing trigger keeps the test inside the real cadence
* while staying well under the poll interval;
* 4. assert the phase flipped and the pending note cleared — in place, no
* reload.
*
* The hero used to carry a "just in" chip here; it is gone, because under a
* number "just in" never said what had arrived. What the milestone was
* always about survives it: the open page reads differently on its own
* cadence.
*
* Step 0 is what the chip's removal exposed rather than caused. The arrival
* was the only thing keeping the hero on screen for this bare fixture
* account, so the spec was watching the day flip on a hero its own subject
* was propping up — and on whatever data a sibling spec happened to leave
* behind. It now establishes its own precondition, so the hero is there for
* a reason a reader would recognise and the run order cannot decide the
* outcome. `today-hero-just-in.test.tsx` pins the same composition at the
* component, since this file cannot be run without a built app.
*
* The marker is seeded directly rather than waited for from pg-boss: whether
* the queue drains inside the e2e web server is the SPINE's contract and has
* its own tests. What this spec owns is everything downstream of a marker
* existing — the read path, the DTO, and the surface.
*
* Assertions anchor on `data-slot` / `data-phase`, never on visible text:
* responsive `sm:hidden` classes have broken `getByText` in this repo before.
*/
test.use({ storageState: STORAGE_STATE_PATH });
/** The user's local day, in the same profile-tz space the digest files under. */
function localDayKey(at: Date, timeZone: string): string {
// en-CA renders ISO-ordered YYYY-MM-DD, which is exactly the key shape.
return new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(at);
}
test("a fresh arrival flips the day in place", async ({ page }) => {
const dbUrl = process.env.DATABASE_URL;
test.skip(!dbUrl, "DATABASE_URL is required to seed the arrival marker");
const pool = new pg.Pool({ connectionString: dbUrl });
const client = await pool.connect();
try {
// Both Playwright projects deliberately exercise this real-data flow, but
// they share the one authenticated fixture user. Keep the two viewport
// runs from deleting/inserting the same arrival marker concurrently; the
// lock is scoped to this database session and releases even if the test
// fails. This preserves real Desktop + Mobile coverage instead of hiding
// the race behind a project skip.
await client.query(
"SELECT pg_advisory_lock(hashtext('e2e:today-just-in'))",
);
const { rows } = await client.query<{
id: string;
timezone: string | null;
}>("SELECT id, timezone FROM users WHERE username = $1", [
E2E_USER.username,
]);
expect(rows.length, "seeded e2e user must exist").toBe(1);
const userId = rows[0].id;
const timezone = rows[0].timezone ?? "UTC";
// A clean reaction slate: no marker and no final morning-refresh stamp.
// Older health data may remain, but it must not masquerade as newly
// arrived data before this scenario records its own sleep sample.
await client.query("DELETE FROM arrival_reactions WHERE user_id = $1", [
userId,
]);
await client.query(
"UPDATE users SET morning_digest_refreshed_on = NULL WHERE id = $1",
[userId],
);
// And no sleep sample from an earlier run of THIS spec — the desktop
// project runs first and leaves one behind, and the day reads `final`
// for as long as last night's sleep is in the record, which would make
// the provisional assertion below unreachable on every run after the
// first. Scoped to the external ids this file writes: it is the only
// e2e spec that records sleep for the fixture account (the two v1427
// specs mock the route in the browser and write nothing), so nothing
// else can be depending on these rows.
//
// Deleted through the APP rather than with a `DELETE FROM measurements`,
// and that difference is the whole reason this scenario used to fail on
// the second project: a fixture that mutates rows behind the app's back
// inherits the app's caching. `sleepLastSeenDaysAgo` is read off the
// dashboard snapshot, which is an SWR cell in the analytics bucket
// (60 s fresh, one HOUR of stale-serve), so the desktop run's
// "sleep is in" snapshot outlived the rows it was built from and the
// mobile run read `final` from cache against an empty table.
//
// `bulk-delete` specifically: it hard-evicts that bucket
// (`invalidateUserMeasurements(userId, { evict: true })`, pinned by its
// own route test), tombstones the rows the way the management list
// does, and recomputes the rollup buckets the snapshot reads through.
// The delete-by-external-id route would have been the closer fit for
// how this spec names its rows, but it invalidates WITHOUT `evict` —
// a marked-stale cell is served immediately while it revalidates, so
// the next read would still have been the stale `final` one.
//
// Ids come from SQL rather than from `GET /api/measurements`: that
// list collapses overlapping sources to one canonical row per day, so
// it can hide a row that is still in the table and still counts.
//
// Deliberately NOT moved into the `finally` block. The eviction only
// happens when the delete actually removes something, so cleaning up
// at the end of each run would leave the next one with nothing to
// delete, no eviction, and the same stale snapshot.
const stale = await client.query<{ id: string }>(
`SELECT id FROM measurements
WHERE user_id = $1
AND external_id LIKE 'e2e-just-in-%'
AND deleted_at IS NULL
LIMIT 200`,
[userId],
);
if (stale.rows.length > 0) {
const purge = await page.request.post("/api/measurements/bulk-delete", {
data: { ids: stale.rows.map((row) => row.id) },
});
expect(
purge.ok(),
`purging ${stale.rows.length} stale sleep rows must be accepted (got ${purge.status()})`,
).toBe(true);
}
// A night from earlier in the week, so this is a record that expects
// sleep at all. The digest waits for last night only when sleep actually
// reaches the account (a night inside the last seven days); without one
// the purge above leaves a record with no sleep source, which reads
// `final` by design and puts the provisional assertion below out of
// reach. Written through the same ingest route as the fresh night, and
// swept by the same `e2e-just-in-` purge on the next run.
const priorNight = new Date(Date.now() - 3 * 24 * 60 * 60_000);
const priorWoke = new Date(priorNight.getTime() + 7 * 60 * 60_000);
//
// Tagged `foreground`, the trigger an open app sends. Since v1.42 a batch
// without it only marks the dashboard snapshot stale, and `cachedSwr`
// serves the stale cell once while it rebuilds: a snapshot an earlier
// spec left for this account (no sleep at all, so no sleep expected)
// would then paint the BEFORE state as `final`, depending on which spec
// happened to run first in the shard. `foreground` evicts, so the first
// read below is built from the rows this spec just wrote.
const priorBatch = await page.request.post("/api/measurements/batch", {
data: {
syncTrigger: "foreground",
entries: [
{
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
value: 420,
unit: "min",
startDate: priorNight.toISOString(),
endDate: priorWoke.toISOString(),
externalId: `e2e-just-in-prior-${priorNight.getTime()}`,
sleepStage: 3,
},
],
},
});
expect(
priorBatch.ok(),
`prior-night sleep batch must be accepted (got ${priorBatch.status()})`,
).toBe(true);
// The account needs a reason to paint a hero that is not the arrival
// itself. A connection needing a reconnect is that reason: the digest
// reads `integration_statuses` fresh on every request rather than
// through the dashboard snapshot's SWR cell, so unlike a seeded score
// or a seeded briefing (both of which ride that cell) it
// cannot lose a race with a cached snapshot, and `buildSyncIssueItems`
// admits it unconditionally — no module gate, no window, and
// `sync_issue` carries no dismiss path that a previous run could have
// silenced. Removed again in the `finally` below.
await client.query(
`INSERT INTO integration_statuses (id, user_id, integration, state, updated_at)
VALUES ($1, $2, 'withings', 'error_reauth', NOW())
ON CONFLICT (user_id, integration) DO UPDATE SET state = 'error_reauth'`,
[`c${Date.now()}syncissue000`, userId],
);
// ── BEFORE ────────────────────────────────────────────────────────────
// Wait until the digest settles, then pin the starting state. The two
// assertions that matter here are the ones that make the AFTER block
// mean something: a note that was never rendered is also absent at the
// end, and a day that was already final also reads final at the end.
await page.goto("/");
// No wait on the skeleton being gone: it is also gone while the protected
// shell holds the whole page behind its hydration gate, so it resolves
// before the digest has rendered anything. The hero count below is the
// wait, and it is a wait on the content itself.
const hero = page.locator('[data-slot="today-hero"]');
// Counts rather than visibility, matching the AFTER block: the two are
// then the same assertion with the expected number flipped, and neither
// can pass because a responsive class happened to hide the element.
await expect(hero).toHaveCount(1, { timeout: 20_000 });
await expect(hero).toHaveAttribute("data-phase", "provisional");
await expect(
hero.locator('[data-slot="today-hero-sleep-pending"]'),
).toHaveCount(1);
// Nothing on it may name an arrival: that chip was removed.
await expect(page.locator('[data-slot="today-hero-just-in"]')).toHaveCount(
0,
);
// ── NEW DATA LANDS ────────────────────────────────────────────────────
// Last night's sleep through the real ingest route, on the session the
// storage state already carries.
const now = new Date();
const wokeAt = new Date(now.getTime() - 60 * 60_000);
const fellAsleepAt = new Date(wokeAt.getTime() - 7 * 60 * 60_000);
// `foreground` for the same reason as the prior night above: the page
// is open while it lands.
const batch = await page.request.post("/api/measurements/batch", {
data: {
syncTrigger: "foreground",
entries: [
{
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
value: 420,
unit: "min",
startDate: fellAsleepAt.toISOString(),
endDate: wokeAt.toISOString(),
externalId: `e2e-just-in-${now.getTime()}`,
sleepStage: 3,
},
],
},
});
expect(
batch.ok(),
`sleep batch must be accepted (got ${batch.status()})`,
).toBe(true);
// The marker the spine's worker writes on a salient arrival, plus the
// morning-refresh stamp it rides alongside — the two rows that together
// make the day final and the arrival news.
const localDate = localDayKey(now, timezone);
await client.query(
`INSERT INTO arrival_reactions
(id, user_id, kind, local_date, occurred_at, arrived_at, created_at)
VALUES ($1, $2, 'sleep_night', $3, $4, NOW(), NOW())
ON CONFLICT (user_id, kind, local_date) DO UPDATE
SET occurred_at = EXCLUDED.occurred_at,
arrived_at = NOW()`,
[`c${now.getTime()}justin000000`, userId, localDate, wokeAt],
);
await client.query(
"UPDATE users SET morning_digest_refreshed_on = $2 WHERE id = $1",
[userId, localDate],
);
// The tab becomes visible — the browser signal TanStack Query's focus
// manager consumes. The digest refetches in place; the page never reloads.
await page.evaluate(() =>
window.dispatchEvent(new Event("visibilitychange")),
);
await expect(hero).toHaveAttribute("data-phase", "final", {
timeout: 20_000,
});
// Sleep is in, so the provisional freshness note is gone with it.
await expect(
hero.locator('[data-slot="today-hero-sleep-pending"]'),
).toHaveCount(0);
// And the arrival still raises no chip of its own, on either pass.
await expect(page.locator('[data-slot="today-hero-just-in"]')).toHaveCount(
0,
);
} finally {
await client
.query(
"DELETE FROM integration_statuses WHERE user_id = (SELECT id FROM users WHERE username = $1) AND integration = 'withings'",
[E2E_USER.username],
)
// The account is shared, so the seeded row must go even when an
// assertion above threw — but a cleanup that throws would replace a
// real failure with a confusing one.
.catch(() => {});
client.release();
await pool.end();
}
});