60 Commits
Author SHA1 Message Date
Marc-André Bombeck 567fd8d51c test(e2e): sweep every signed-in route for sideways scroll at 390px
The lab list panned sideways on a phone while the overflow guard was
green, because /labs was not on its hand-kept list. The new sweep reads
the routes from src/app: static routes are visited as is, every dynamic
route needs a resolver to real paths, and routes that are not signed-in
surfaces are listed with their reason. A route added without either
fails the bookkeeping test by name.

It runs on an account of its own, filled once from globalSetup with one
or two of everything a route renders, in German, so the narrow-column
and long-string cases are present. Two lab tests assert at the elements
that broke: the marker name keeps a readable width, and the values
list's actions stay on screen. The dialog spec gains a 360px width,
where the labs sheet's three-button footer overflowed in English too.
2026-10-01 09:45:15 +02:00
Marc-André Bombeck ed05772a8e test(e2e): run the delegated-writes journey and fail on silent skips
The delegated-writes spec checked for the invitation form's level control
right after page load, before the form had painted, found nothing and
skipped every test on every run, CI included.

- Give the journey its own account pair, seeded and reset in global setup,
  so it no longer invites the same pair as the account-sharing journey
  (a second live grant between two accounts is refused with 409).
- Wait for the form through its data-slot and fail when the WRITE level
  control is missing instead of skipping.
- Revoke the journey's grant in afterAll.
- Keep it out of the mobile project in the config rather than through a
  runtime skip.

Add two checks so a skip cannot hide a spec again:

- e2e-conditional-skip-guard.test.ts requires every conditional
  test.skip/test.fixme in e2e/ to carry a reason and refuses a bare
  test.skip().
- scripts/check-e2e-skipped-specs.mjs reads the Playwright JSON reports of
  both shards in the e2e gate job and fails when a spec file skipped all of
  its tests across every project, apart from an allowlist with reasons.
2026-09-27 04:48:08 +02:00
Marc-André Bombeck 881a8d9061 test(e2e): meet every recent-proof gate from a stale session
The invitation journeys, the backup journey (archive, snapshot download
and restores) and the delegated doctor report acted on jars signed in once
in global setup, so they passed or met the re-proof gate depending on how
far into the run they started. Each now acts from a session of its own,
made stale before every gated press, and answers the dialog with the
password; the delegated report asserts the 401 before re-proving over the
wire. The helper gains expireRecentProof, reproveWithPassword and
actWithReproof.
2026-09-27 03:19:39 +02:00
Marc-André Bombeck a6daa84c17 test(e2e): meet the recent-proof gate from a stale session, as a person does
Connecting an assistant and making a share link ask for a sign-in or
re-proof within five minutes. The shared jar is signed in once in global
setup, so the specs passed early in a run and failed later. They now start
from a session of their own signed in ten minutes ago: the consent page
withholds Allow and links to the confirm page, where the password re-proof
returns to the consent request; the share form opens the re-proof dialog
and creates the link after the password is given.
2026-09-27 03:00:36 +02:00
Marc-André Bombeck cec7fd2b9c test(e2e): tamper with a piece of the stored backup, now that copies are kept in pieces 2026-09-26 11:40:50 +02:00
Marc-André Bombeck b7830c3a72 test(e2e): cover Insights and the Coach memory without AI
Adds the AI-optional journeys: the overview with AI switched off by the
operator and with no provider (no AI requests, no refusals, one setup
hint, remembered dismissal), and on an account of its own, withdrawing AI
consent deletes the stored briefing, and a hidden Coach's conversation is
read and deleted from Settings, AI.

Specs that drive AI surfaces against mocked routes now serve an available
capability map through /api/auth/me, since the web no longer assumes AI
is on until told otherwise.
2026-09-24 11:36:30 +02:00
Marc-André Bombeck 8eff500e9d test(e2e): prove a switched-off module leaves every surface it owns
A journey on its own account switches mood and medications on, proves
the Mood nav entry, the Mood and Medications pills, the mood trends slot
and both add-menu entries are there, switches both off through the
module route and proves each is gone, the trends row fills from pulse,
/insights/mood shows the module notice at the same URL, and no chart on
the way paints its error state.
2026-09-24 10:06:14 +02:00
Marc-André Bombeck ef585c34c4 test(e2e): draw the SMTP stub port below the ephemeral ranges
The stub port was drawn from 49152-65535. Linux assigns local ports for
outgoing connections from 32768-60999, so the drawn number could already
be held by a client socket the runner had opened, and the notification
journey failed in beforeAll with EADDRINUSE although no other stub was
listening. The port now comes from 20000-32767, below the Linux and
macOS ephemeral ranges.
2026-09-14 00:20:58 +02:00
Marc-André Bombeck 34caeeea3c test(onboarding): the AI panel names the variant it settled on
The panel paints before its status query answers, and every variant inside it
is conditional, so an assertion that a variant is absent could pass on a
question that had simply not been answered yet — which is how the journey for
the unmeasured-provider case passed on a retry against the very defect it was
written to catch.

The panel now carries the variant it settled on, absent until it has settled,
and the three journeys wait on that instead of racing it. The third journey
covers the state a freshly deployed instance is in before its first AI call:
no offer, and no claim of an outage either.
2026-09-11 11:15:39 +02:00
Marc-André Bombeck 41596d1cb8 docs: comments name what the code does, not how it was built
Thirty-one files carried internal build vocabulary and anchors pointing at
notes that are not in the repository: a reader of `checklist.ts` in a year
would find its rationale keyed to a research id they cannot open, inside a
comment that also names an internal build stage. Two test titles carried the
same anchors.

The version tags and the whole of the reasoning stay; only the vocabulary and
the dangling references go, with the few sentences that were built around an
anchor rewritten to stand on their own.
2026-09-11 11:03:04 +02:00
Marc-André Bombeck 1885b7867d Merge wave D — the shared provider is offered only when it is known to work
The done screen's assistant panel stops claiming that the instance's
provider already serves the person. A tri-state read over the operator's
own provider ledger decides between a one-tap offer that mints the
consent receipt, a plain "not answering right now" note, and today's
setup link; anything unknown, any read error, a managed profile and an
operator who has switched the assistant off all fall to no offer.

# Conflicts:
#	e2e/setup/global-setup.ts
2026-09-11 10:25:19 +02:00
Marc-André Bombeck e5cfdc9fdf test(e2e): the AI offer follows the shared provider's health
Two runs on one account, and the only difference between them is a row in
provider_health: a success inside the freshness window brings the offer and
the tap leaves an active ai_full receipt; a dead credential inside its
cooldown brings 'not answering' and no button at all.

The seed is instance-wide because the projection is - a fresh account's own
ledger is empty by construction - so the spec runs serially and puts the
operator key and the ledger rows back afterwards. The offer path asserts
that no provider was called: the flow reads a projection and never probes.

Verified as a real check, not a tautology: moving the seeded success from 30
minutes to 25 hours ago makes the offer case fail.
2026-09-11 09:36:07 +02:00
Marc-André Bombeck dfa5b9a5e0 test(e2e): the connect journey asserts after the status resolves
No journey reached the connect arm: bp answers `manual`, visit answers
`file`, and the child path ends at confirm. The a11y sweep opened the screen
and asserted the connect card was visible — which it was, for the few hundred
milliseconds before the status envelope resolved, so the sweep passed through
the defect rather than catching it.

The new journey answers Q4 with a wearable and asserts only after
`GET /api/integrations/status` has settled: nothing is claimed for an account
with no connection and no result is stamped on its behalf, and a connection
seeded as delivering is acknowledged rather than offered. The a11y sweep now
waits for the tile the screen settled on instead of the frame before it.
2026-09-11 09:31:59 +02:00
Marc-André Bombeck 2686da2803 Add the setup flow's browser journeys, axe sweep and string-headroom check
Four journeys, one account each, from the welcome screen to the
dashboard: blood pressure and weight typed in, a daily medication added
inline, a visit within a month with labs, and a child's managed profile
created on the confirm screen. Each asserts what the walk wrote — the
module map, the tile order, the checklist, the settled ledger — and the
first proves that leaving after Q3 resumes at Q4.

Two sweeps on accounts of their own drive the state through the answers
route and visit every screen by URL: axe at both widths plus one
keyboard-only answer, and every locale with every text node lengthened
by 30% before the page, the shell, every clipping box and every control
are measured. All six run in one project because each mutates the
account it walks.

The medication wizard learns to stay where it was mounted after a
create, which the first-result screen needs and the medications list
does not.
2026-09-10 15:57:41 +02:00
Marc-André Bombeck 8b1ca7ee5a Merge the notification dispatch end-to-end journey, the account-scoped reminder sweep and the published notification preferences 2026-09-10 06:37:56 +02:00
Marc-André Bombeck 74dbb1a2e4 test(e2e): give the notification journey a server and a scope of its own
The journey's isolation went one way only. `test.describe.configure` orders
the tests inside the file; the suite is fullyParallel with two workers, so
everything else runs beside it — and its own trigger, the admin reminder
sweep, walked the whole instance and dispatched for every account that had
an overdue dose. With the mail env pointed at the local responder, that
would have started sending real messages for accounts this file has never
heard of, and the exact-equality assertion on the accepted recipients would
have been reading them. The sweep now names the account it then counts, and
the comments that defended the opposite direction say what the exposure
actually is.

Its own server, too, and for two reasons. The `SMTP_*` vars decide whether
the Email card renders at all, so on the shared server they put a card the
axe scan and the card-anatomy sweep had never measured onto
`/settings/integrations` for every spec that opens the route. And unset,
`HEALTHLOG_PROCESS_TYPE` starts the pg-boss reminder scheduler inside the
process serving requests, whose every-fifteen-minutes tick either mints the
missed-dose row that makes the sweep skip its own dispatch or dispatches
beside it. A third server carries both: mail configured, `web`, and only
this spec talks to it.

The shared server keeps `all` on measured grounds rather than taste. Worker
status is per-process and in-memory, so a `web` process reports the worker
stopped, `/admin` renders that row destructive-red, and axe fails it at
3.97:1 against the muted tile — a real contrast defect any operator running
the documented web/worker split already sees, and not this branch's to fix.
That limit is written where the server is defined.

Also here: the stub draws its port per run instead of pinning one, and says
so by name when the port is taken; teardown ends its sockets and survives a
setup that never got as far as starting it; the ledger reset stops deleting
rows whose subject is this account but whose recipient is somebody else,
and clears intake events explicitly rather than through a cascade; the
attempt poll refuses a window larger than the twenty rows the diagnostic
returns; and the header names the precondition the file cannot enforce and
the internal reasons it pins on purpose.

Verified against a production build and a throwaway Postgres: the journey
three times over at one worker, then the a11y and settings geometry specs
at two. Both assertions broken once to see them fail.
2026-09-10 06:35:40 +02:00
Marc-André Bombeck ee73e840e0 Merge the backup and restore end-to-end journey, the restore that leaves instance settings alone and the 422 for an unreadable copy 2026-09-10 06:09:23 +02:00
Marc-André Bombeck 343d6397a0 test(e2e): say what the backup journey really touches, and pin it to promises
The file claimed it stayed inside its own account. "Backup now" enqueues the
pass for every account on the instance, so the header says that now, and says
what is confined instead: the restore's transaction, and the instance-wide
settings the dialog's opt-in leaves alone — which the journey asserts is clear
before it confirms, because a default that flipped would hand this file the
power to rewrite the document size cap for every spec sharing the server.

The run's own answer is asserted where it happens. Without it a missing worker
cost two minutes of polling and then blamed the backup pass for a 503 about
something else; the failure now names the endpoint in two seconds.

The rest are the assertions that pinned incidentals rather than promises: the
Argon2 cost comes from the constant the writer exports, so a legitimate cost
bump is not a red spec; the document is found by id rather than by position;
the poll compares the row against the row it saw before the click rather than
against the runner's clock; the non-admin bounce is pinned by pathname; the
empty-state CTA has its own test id, so nothing has to pick between two
buttons that share one; the deliberately corrupted copy is deleted when the
control is done with it instead of sitting in the console's list; and the
fixture orders by id after the timestamp so a tie cannot present as a change.
2026-09-10 05:48:18 +02:00
Marc-André Bombeck 8be51d9beb test(e2e): cover notification preferences through to the dispatch decision
One browser journey for the web arm of notification delivery: an account
configures its channels on the settings surface, a due medication reminder
runs through the product's own sweep, and the dispatch decision is read back
off that account's `push_attempts` ledger via the admin diagnostic.

What it pins:
  - the enabled channel delivers (`result: ok`) and the message really leaves
    the process, asserted against a local SMTP responder rather than against
    the ledger row alone;
  - a channel that is configured and switched OFF is never attempted, and its
    own test control is inert on the surface;
  - the APNs arm is attempted and skipped, and the ledger's reason
    distinguishes the transport refusing (`apns_not_configured`) from the
    client-managed opt-in refusing (`client_managed`) — with email still
    delivering under the opt-in, which is the published "APNs leg only"
    contract;
  - an ntfy relay URL inside a private range is refused with 422, leaves the
    stored config untouched, and writes no attempt.

Email is the channel exercised for real because it is the only one that can
be: every account-supplied delivery target crosses the SSRF floor
(`isPublicUrl` plus `safeFetch({ requirePublicHost: true })` with a
connect-time address pin), so ntfy, the generic webhook and Web Push refuse a
local stub by design, and Telegram and APNs address hard-coded third parties.
Email's transport is operator configuration, so `e2e/setup/smtp-stub.ts` can
stand in for it end to end. The config points the e2e server at that stub.

The journey carries one skipped test at full strength: the medication detail
surface decides between the reminder switch and the client-managed chip on
`notificationPrefs.medication.clientManaged` read out of `/api/auth/me`, and
that payload does not carry the field. The flag therefore reads as false for
every account and the chip never renders, so a person whose phone owns the
reminders is still shown a server-side switch that no longer decides anything.
The write path and the dispatch decision are both correct; only the web
reflection is missing its end.

The account and its jar are seeded in global setup, and its channels, devices,
ledger and preferences are reset before every test so the counts are about the
run in front of them.
2026-09-10 05:41:17 +02:00
Marc-André Bombeck 8865e1b225 Merge the second-factor end-to-end journey and the confirmation dialog fix 2026-09-10 04:53:50 +02:00
Marc-André Bombeck 5cb5c7df44 test(e2e): drive backup and restore end to end through the settings surfaces
One journey across the whole path: an account writes readings in three
domains and files a PDF, downloads its own record as the passphrase-sealed
archive, an admin snapshots it from the backups console, and the snapshot is
restored over an account that has since lost a reading and a dose and gained
one the snapshot never saw.

The assertions address what the documentation promises rather than what the
implementation happens to do. The archive is checked byte by byte against the
HLX1 header in src/lib/export/passphrase-archive.ts — magic, version, KDF id
and the Argon2 cost the file carries — because "a file arrived" is equally
true of plaintext JSON under a new extension. The restored reading and dose
are checked by their ORIGINAL ids, because a restore that re-created them
under fresh ones would have written a different record that reads the same.
The reading created after the snapshot is asserted GONE: the runbook says the
restore replaces the account's data tables, and replacing is not merging.

Two refusing controls. A stored copy with one flipped ciphertext byte is
refused with the route's decrypt failure and leaves the record untouched — the
envelope is AES-256-GCM and its tag covers every byte, so the flip is caught
before the transaction opens. A delegate holding whole-record READ inside the
account gets the not-covered panel instead of the export card, never reaches
the console, and is refused 403 by the list, the manual run and the restore,
the last of them naming the real snapshot so the refusal cannot be mistaken
for a missing row.

Waiting is on the job's own evidence: the console's endpoint is polled until
this account's stored copy is newer than the click, so nothing in the file
guesses at a queue. The journey has its own account, its own delegate and its
own cookie jars; only the reset between repetitions and the deliberate
corruption reach Postgres directly, because a browser cannot express either.

The backups console gains data-testid hooks and a data-backup-id on each row
so the spec addresses controls rather than translated labels.
2026-09-10 04:53:03 +02:00
Marc-André Bombeck 78789e090e test(e2e): measure the login cap the journey keeps clearing
The journey emptied every `auth:%` bucket before each sign-in, which is wider
than it needs and takes the register, passkey and reset buckets with it — a
future spec measuring one of those throttles would find its evidence deleted by
an unrelated sign-in. The clear now names the two surfaces this file spends.

It also left the throttle as the one auth control the journey rides on and
never sees: no spec in the suite asserted a 429, and this is the only one that
posts a real login at runtime. A closing stage spends the cap from an empty
bucket and asserts the answer that goes over — 429, with the bucket's own
remaining and reset headers. It costs nothing: the account is dropped
immediately after and nothing else signs in from there.

The bucket is keyed per IP and every spec on the machine is that IP, the
sibling browser project included, so the measurement is taken in one project
and a round disturbed by the sibling is retried rather than asserted. Two
probes emptying and filling one bucket beside each other were seen to measure
their own interference instead of the limiter.
2026-09-10 04:52:33 +02:00
Marc-André Bombeck b428fcf74f test(e2e): read the replay verdict instead of inferring it from a 401
The replay control submitted the enrolment code at the login challenge and
asserted a 401. That 401 is the same answer the route gives a code that has
simply drifted out of its ±1-step window, and by the time the journey reached
the challenge — a rotation, a refused rotation, a sign-out, a page load and an
Argon2id verify later — the enrolment code usually had. Out of window, the
refusal happens before the monotonic floor is ever consulted, so the control
could pass with the floor deleted.

Both TOTP refusals now assert the verdict the route records itself,
auth.mfa.failed with details.replay, written and awaited before it answers:
false for a code that never matched the secret, true for the replay. Flipping
that verdict while leaving the refusal in place turns the positive half red,
which is what the old assertion could not do.

The replayed code is the one the previous sign-in just spent, so the window is
a property of the journey rather than of the wall clock: the accept is taken at
a step boundary when the current step has too little left, which bounds the
wait by one period and leaves the pair at least two.

The rotation stages no longer press Escape to get the confirmation out of the
way. They assert the card closes it, by test id.
2026-09-10 04:52:11 +02:00
Marc-André Bombeck a792ac09b1 Merge the doctor-report end-to-end journeys 2026-09-10 04:22:47 +02:00
Marc-André Bombeck 89ec49cd42 Merge the medication adherence end-to-end journey 2026-09-10 04:20:01 +02:00
Marc-André Bombeck 50a65b6f80 test(e2e): cover the second-factor journey end to end
Enrol TOTP from the settings surface, sign out, sign back in through the
challenge, and step up for a recovery-code rotation — in a browser, against
a real server, on a throwaway account the journey seeds and drops itself.

Four refusals ride alongside the four positives: a wrong code at the
challenge (401, no session cookie, and the ticket's attempt counter moved),
the enrolment code replayed (401, refused by the monotonic totpLastStep
floor), the rotation once the session's mfaVerifiedAt has aged past the
five-minute window (401 with meta.errorCode auth.stepup.required), and a
recovery code presented a second time (401). Each was proved to fail by
removing the control it stands on and watching that one assertion break.

Codes are computed from the enrolment secret with the otpauth dependency the
server verifies against, at explicit timestamps, so nothing waits on a step
boundary: the sign-in code is generated one step ahead of now, inside the
±1 drift window and necessarily above the replay floor.

The fixture reaches Postgres for the two things that have no endpoint by
design — ageing the step-up stamp, and emptying the per-IP auth buckets
this journey would otherwise exhaust. The account carries the Playwright
project name so the two browser projects never drive one factor state.

Adds data-testid hooks on the TOTP card, the login step, and the password
form so the spec asserts on stable attributes instead of rendered text.
2026-09-10 03:53:11 +02:00
Marc-André Bombeck 33f5bed802 test(e2e): give the doctor-report journey its own account
Generating a report writes: the route remembers the scope and the
practice name on the account row, and the export panel a later visit
opens is therefore the collapsed "repeat run" state. The journey was
generating on the shared fixture account, which is the account
`settings-export.spec.ts` asserts the first-run state against in nine
places — an expanded picker, an empty summary, a disabled button. The
two files run in an unconstrained order under two workers, so the first
symptom was an order-dependent red in CI; against a persistent local
database the flip never cleared and the red was permanent.

The journey now seeds, generates and reads back on `e2e-report-owner`,
with its own cookie jar. The shared account and the whole-record
fixtures also get `report_selection_json` and `last_report_practice_name`
cleared on every seed, which repairs a database an earlier run already
dirtied and keeps a delegate's scope choice from being left on a record
owner's row.

The account's date order is pinned rather than left to the locale, and
its export bucket is purged beside the share-link and login buckets: the
10-per-hour ceiling is shared by every export route, keys on the actor
and outlives a run by an hour, so repeated local runs would otherwise
meet a 429 the spec reports as a missing artefact.
2026-09-10 03:48:37 +02:00
Marc-André Bombeck d529d5976c test(e2e): move the adherence journey onto its own account
The batched compliance read allows thirty calls a minute per ACTOR, and the
journey spends that allowance twice over: it writes doses and then reads the
resulting rates back off the same cabinet. On the shared account two siblings
spend it as well — `refetch-nav.spec.ts` mounts `/medications` unstubbed and
drives a client-side round trip back to it, and `delegated-writes.spec.ts`
opens `/medications?new=1` on a SECOND session row of the same account, which
is a different session but the same actor and therefore the same bucket. Add a
serial group's retries and thirty reads inside one minute is reachable rather
than theoretical.

The cabinet has the same shape of problem in the other direction: the clear
before each test deletes every medication row of the account it runs on, and
that is today the account every other authenticated spec holds.

So the journey gets its own account and its own jar. The rate-limit bucket,
the medication cabinet and the dashboard tile's denominator become private at
once.

Two fixture repairs ride along, because they are the same helper:

- The clear now goes out as the app's own DELETE per row instead of one raw
  `DELETE FROM medications`. Three fifteen-minute per-user cells hold
  medication state and a raw statement evicts none of them; the file survived
  only because every test happened to write through the API afterwards, which
  is an accident of ordering rather than a property. The rows are still
  enumerated in SQL, because the list route serves a memoised shape and a row
  it has not caught up with would hide from it.

- `ageMedication` takes both stamps from its caller instead of deriving them
  from `CURRENT_DATE`. That resolves in the database session's zone — UTC in
  CI — while the plan dates the spec sends are computed in the browser's
  pinned Europe/Berlin, so between midnight and 02:00 the row silently started
  a day earlier than the caller asked for. The docstring now says `starts_on`
  is overwritten, not adjusted.
2026-09-10 03:44:48 +02:00
Marc-André Bombeck 7b06a235e1 test(e2e): cover doctor-report generation end to end
The doctor report had unit coverage for the renderer and route coverage for
the envelope, and nothing that pressed the button. Two browser journeys close
that.

`doctor-report.spec.ts` seeds a few weeks of blood pressure and weight, one
allergy and one medication through the page's own `fetch`, picks a window in
the export panel and generates. The saved download is asserted as a real
artefact — `%PDF` magic, a `Content-Length` that agrees with the bytes, a
length no empty document reaches — and then parsed back to text, so the
period line, the blood-pressure section and the drug list are read out of the
document rather than inferred from a 200. The period is checked as a span, so
the assertion survives the account's timezone. Its refusing control is a
window with no readings: still a valid, readable report that names the period
and carries no blood-pressure section, with the reference data that is not
window-scoped as the control against a blank page passing.

`doctor-report-delegate.spec.ts` covers the access boundary the route draws at
MANAGE. A delegate inside a record it may only read is not offered the export
card at all, and the route refuses the request that card would have sent with
`sharing.access.denied`. The positive control is the same delegate, the same
payload and the same switched-record path against a record it manages, so the
one variable between pass and refusal is the access level. Both requests carry
the record-context headers the shipped client sends: without them the fence
refuses with the same status and the same error code, which would make the
refusal pass for the wrong reason.

Seeding is idempotent — a re-posted reading collides on its natural key and
answers 409 — so a second local run and a repeated run both stay green. The
delegate journey takes its own cookie jar because it moves the session's
record selector, and both specs run in one project.
2026-09-10 03:27:02 +02:00
Marc-André Bombeck 617beb40dc test(e2e): cover the medication-adherence journey end to end
The percentage on a medication card is the number this product is for, and
nothing in the browser suite walked the path that produces it: the wizard
specs stub `/api/medications` and stop at the POST body, and the compliance
engine's own tests are unit tests over pure functions. The join was the part
nobody checked.

Three flows against the seeded account, serial and in one project because
they read counts back off a cabinet they own:

  - a twice-daily medication created through the real wizard, one dose taken
    and one skipped from the card, and the figure read back on the card, in
    the intake history and on the dashboard tile;
  - a dose recorded against a schedule that does not exist, refused, with the
    rate exactly what it was before;
  - a plan due on two weekdays whose other five days stay out of the
    denominator, with a daily twin given the identical doses as the control —
    without it, "100 %" would also be what an engine that counted no days at
    all would say.

The dose times are derived from the clock rather than picked from the
wizard's suggestions: the card offers one dose, so with fixed times there is
always an hour at which that dose belongs to yesterday or tomorrow, and the
day the write lands on decides what the tile reads. Each medication is aged a
few days as well — compliance is reconstructed from the creation stamp
forward, so a medication created a second ago reads the empty-set 100 % and
no take can move it.

Four surfaces gain data attributes so the journey addresses controls and
figures rather than translated copy: the card's take / skip buttons, the
card's two adherence rates, the intake-history row status, and the dashboard
tile's most recent day, which existed only as bar geometry. Presentation is
unchanged; the compliance block's fixed-geometry guard normalises the two new
rate attributes alongside the ones it already did.
2026-09-10 01:46:13 +02:00
Marc-André Bombeck 1de3681c81 test(e2e): cover the blood-glucose journey across a unit switch
The glucose display unit had thirty-odd readers and, until recently, no
control to set it. Nothing in the browser suite walks the path a person
actually takes: capture a reading in mg/dL, switch the account to mmol/L,
capture a second one, and read both back — each converted, none of them
reinterpreted — in the list and on the dashboard strip.

The journey covers that end to end, plus the boundary: a value past the
plausible ceiling is refused by the server, the capture keeps what was typed,
and the list is exactly what it was.

One reading arrives with no meal-time context, the way a meter export or a
device sync delivers one. It reads back correctly in the list; the tile it
should paint does not exist yet (#943), so the tile assertion is skipped by
issue number rather than softened into something that passes.

Three supporting changes:

- The list rows carry what they hold — type and resolved display unit on the
  row, a slot on the value cell — so the journey pins "this reading now reads
  in mmol/L" structurally instead of matching translated copy that also paints
  through a different subtree on the card list. The meal-time options carry
  their enum for the same reason.
- The sharing-marker weight rows were seeded by handing a Date to a
  `timestamp without time zone` column, which stamps them in the host's zone.
  On a machine east of UTC that put them hours in the future, where every fold
  whose window ends "now" skips them, and the type stayed uncovered — which
  keeps the snapshot's thick phase cold and the whole tile strip short. Seed
  the instant as UTC, the shape the app's own writes use.
- The file mutates one shared account and reads global verdicts off it, so it
  runs in a single project alongside the visits and immunization journeys.
2026-09-10 01:01:11 +02:00
Marc-André Bombeck 4b3e71570c test(sharing): drive the managed-record capabilities over the routes and the browser
The integration leg reads GET /api/auth/me as a guardian acting on a
managed profile and asserts both lists, lands a mood entry and an
allergy on the profile's record, is refused a vault write, and shows a
WRITE delegate writable sections without manageable ones together with
the refused mood entry the list withheld.

The browser leg opens the managed profile, adds a mood entry through
the control that used to be hidden and asserts the POST's status, and
finds the owner-only sentence and no upload control on the documents
page. The spec gets its own session jar and runs in the desktop
project only, like every other spec that moves the record selector.
2026-09-09 19:42:27 +02:00
Marc-André Bombeck 4335b38e02 feat(nutrients): remove in-app water logging, keep the sync path
Manual water entry is gone from the app: the water item on the capture
picker, the dashboard quick-add menu and the hydration card's add button
are removed, and the manual quick-add sheet is deleted. Water that syncs
in through Apple Health still arrives and still shows on the hydration
card with its daily total, history and reference line.

The POST /api/nutrients/water endpoint and the batch sync path stay
untouched. The nutrients page's module-enable error, which had borrowed
a water-quick-add string, gets its own key across all six locales.
Input-only water keys (nav.capture.water*, nutrients.hydration.quickAdd*)
are removed.
2026-08-09 06:46:28 +02:00
Marc-André Bombeck a6a65719eb test(vaccinations): cover the immunization log end to end
Five Playwright flows over the built app, each asserting stable data-slot
attributes rather than viewport text: transcribe a catalogue pick with a lot
into its antigen group, a combination dose rendering under each of its three
component groups, the booster prompt minting a Vorsorge reminder that shows on
checkups and moves its due date when the next dose is logged, the module-off
redirect with the nav entry gone, and an axe pass over the list and the form.

The catalogue option gains a data-catalog-slug hook so a flow can pick an exact
antigen deterministically. The spec runs in a single project and serial, and
clears its own rows before each test, so a grouped verdict counts only what the
test wrote.
2026-08-08 23:00:29 +02:00
Marc-André Bombeck 594898d350 Anchor the document visit-suggestion on a full instant
The suggestion anchored on the document's own date, which the DTO carries
as a date only. The suggest endpoint takes a full instant, so every
document asked the question as `2025-10-14` and got 422 back; the field
then rendered nothing and no document ever offered the visit its date
falls near. Lift the date to noon UTC before asking, the shape the two
lab moments already send.

The e2e that should have caught it never authenticated: the visits spec
carried no storage state, so its `page` redirected to login and its
`request` reads answered 401. It also asserts a global verdict — one
visit in the document's window — so clear the account's visits before
each test, run the file serially, and keep it to one project, since two
projects mutating the shared account would leave two candidates in the
window.

The accessibility mock lagged the same feature: the detail sheet reads
`encounterLinks` off the document and the mock omitted it, so the sheet
threw before it painted. Add the field the server always sends.
2026-08-08 18:46:55 +02:00
Marc-André Bombeck e33700c7a9 feat(sharing): name a record's owner in full to whoever they share with
The switcher and the banner named a shared record's owner by their greeting
name — the value they typed at onboarding for "how should HealthLog address
you". When an owner has set a full name, that is what a delegate should see:
entering somebody's record, you read whose it is by who they are.

- the account-access resolver publishes the owner's `fullName` alongside the
  greeting name and the username
- `accountLabel` prefers fullName, then displayName, then username. Optional
  on the argument so the grant-view and actor shapes that never carried it
  keep the old two-field behaviour untouched
- the closed presentation schema carries `fullName` and its parity matcher
  compares it, so the two published views of the active record cannot name
  the same owner differently
- the OpenAPI contract regenerates with the additive field

This deliberately discloses the owner's full name to every delegate, read-only
included — it was patient-identity data behind a MANAGE-gated export until now.
That is the intended behaviour, documented at `accountLabel` so it is not
mistaken for a leak and tightened back. The resolver, the schema parity, the
label fallback and a read-only delegate's banner are each pinned by a test.
2026-08-08 16:07:25 +02:00
Marc-André Bombeck 13701ff2b3 test: switch into a real record for the banner-geometry seam
The shared-record strip was forced by capturing /api/auth/me and serving
it back with the sharing block filled. That state is now proved across
the payload validator, the fence store and the transition machine at
once, so a single route mock cannot produce it and the two narrow
repairs both stop one step short of the shell.

So the spec stops faking it: its own login jar, a real switch into the
seeded whole-record READ grant, and a place on the list of files that
run in exactly one project, because a second project would drive the
same session row from two browsers. The viewport below md that project
used to cover is measured inside the desktop project instead, where the
shell picks its column layout from the same width-driven rules.
2026-08-07 08:06:05 +02:00
Marc-André Bombeck 8793bbf229 fix: close four small gaps around the managed-profile surface
- bound profile creation at ten an hour per account, the ceiling its
  guardian sibling has carried since it shipped
- distinguish a failed roster read from a slow one, with the way back
- cover the three caller states the roster read has to refuse: an
  unaccepted invitation, an expired grant, and MANAGE held elsewhere
- cover what deletion does to a guardian standing inside the record
2026-08-07 03:56:39 +02:00
Marc-André Bombeck e9084d9f17 test: cover the managed-profile journey
- drive creation, the switcher entry, the managed banner and the return
  trip in a real browser, plus the guardian roster and the floor stated
  before an invitation is accepted
- seed a guardian account with a second factor and refresh its step-up
  before the journey acts, since no fixture could reach a step-up-gated
  route before this
- tie the journey id to its test titles, so a marker that goes missing
  fails a check rather than vanishing from a run nobody counted
2026-08-07 03:22:51 +02:00
Marc-André Bombeck 6e5b420e4e test(e2e): give every switching journey its own session row
The suite only passed on one worker: four specs shared the scope
delegate's cookie jar and moved the same session's record selector, so
at CI's two workers they moved the epoch under each other and the
failures were the harness racing itself.

- one login per switching spec, which is a separate session row and
  therefore a separate selector; the login bucket is cleared between
  them because the ceiling is five and setup now signs in eight times
- each such spec runs in exactly one project, since a second project
  drives the same row from two browsers
- clear what the browser suite WRITES, so it can be run twice: the
  write journey saves a minute-rounded reading and collided with itself
- wait for adoption after leaving a record, retry a lost switch the way
  a person would, and budget the cross-tab hold for machine load
2026-08-06 23:04:08 +02:00
Marc-André Bombeck 4999c786e3 test(02-12): exercise access-level record journeys
- Seed adult and managed whole-record grants for browser coverage\n- Verify scoped writes, settings boundaries, and refused coach reads
2026-08-06 05:26:21 +02:00
Marc-André Bombeck 9bb1e5b2ea test(02-12): scan shared record accessibility states
- use an isolated shared-record browser session\n- scan empty, loading, error, and refusal states with axe
2026-08-06 05:14:00 +02:00
Marc-André Bombeck 407f7cc044 test(02-12): exercise scoped record doorways
- seed isolated records for all eight shared domains\n- verify each browser switch lands on its scoped read surface
2026-08-06 05:12:33 +02:00
Marc-André Bombeck d07b9249c8 fix: complete dashboard hydration 2026-08-05 23:03:17 +02:00
Marc-André Bombeck 20f5ae27a0 test(e2e): give the account-switching journey its own session
The switch is stamped on the session row, not held in the tab. Every
authenticated spec opens its context from the same storageState file, so
the sharing journey switching that jar into the owner's record switched
it for whatever spec happened to be mid-navigation on the other worker.

The failures land nowhere near the cause: /settings/ai painting "Not
part of shared access" instead of the AI panel, an axe scan reading a
banner its spec never asked for, a waitForResponse waiting on a request
the refused surface never sends. Which spec loses depends on scheduling,
which is why it moved between runs.

Log the delegate in a second time and hand that jar to the journey. Same
account, its own session row, no reach outside the file. The three login
captures collapse into one helper while the third one is added, and the
fixture's login-bucket reset already covers the extra attempt.

The guard matches the two things a switching spec cannot avoid naming,
the switcher entry it clicks and the endpoint that entry calls, and
checks both that the match set is non-empty and that the shared jar is
still what the ordinary specs read. Verified red by pointing the journey
back at the shared jar.
2026-08-02 16:49:47 +02:00
Marc-André Bombeck cacf802b8d test(e2e): the sharing journey, and an honest account of the stale paint
Two browser contexts, two seeded accounts, one database: the owner invites,
the delegate accepts and opens the record, the owner takes it back. Every step
through the shipped UI, because the half of this feature that can go wrong is
the half a person touches.

The stale-paint check is a negative one and deliberately not "the new record's
data arrives" — a check that waits for the new data says nothing about what
was on screen while it arrived. An init script arms a MutationObserver before
any page script on every navigation, including the reload the switch performs,
and latches the delegate's own marker weight. The observer's positive control
runs first on a page that definitely shows it, so its later silence is not the
silence of a broken observer.

Then it was broken on purpose, and the result is written into the spec rather
than assumed. Removing the switch-time cache wipe, the persister's restore
guard and the record-scoped hash — all three — leaves the spec GREEN: the
restore already refuses to clobber a query the live cache holds, and the
dashboard's query is created during render, so the snapshot never reaches that
route's screen at all. Removing the reload turns it red on the banner rather
than on the marker. So this suite is a regression net over the journey, not a
proof of any one control; the controls are proven where they can be broken and
observed, in the unit suites, and the spec's header says so.

The nav assertion turned up a real gap while it was failing: the avatar block
still linked into the account settings from inside a shared record. Those
routes refuse under a switch, so the link led only to an explanation of why it
did not work. It renders as a plain identity block now, and the mobile user
menu drops the same utilities.

Setup gains a second account, a marker weight in each record, a grant reset,
and a clear of the login bucket — this setup signs in twice now, and the
ceiling is five attempts a quarter-hour, so three local runs used to end with a
429 from the fixture rather than a failure from the product.
2026-08-02 15:14:35 +02:00
Marc-André Bombeck 883fd2d047 docs(api): publish the account-access block and the selector header
Three additions to the contract, all of them things a native client cannot
work out on its own.

`GET /api/auth/me` enters the spec for the first time, and enters it loose:
`accountAccess` is specified in full, the remaining preference fields are
declared open rather than half-enumerated. The endpoint has never been in this
document and its payload carries thirty-odd keys no contract has ever named;
specifying a subset and closing the object would publish something the server
does not keep.

`X-HealthLog-Account` is published as a reusable parameter with no route
referencing it, which is the honest state of the contract — the routes that
read another account's record arrive with the surface migration and will
reference it as they land. The header is live in the resolver today, and a
client needs the description before it can be built against a tagged spec: the
value comes from `accountAccess.accounts`, it is a selector and never a
permission, and it must never ride the sync engine, device registration, or an
auth call.

`GET /api/account/activity` carries `retentionDays` in the response and says
why: the window is operator-configurable, so a client that assumed 365 would
tell somebody their record was never opened when the honest answer is "further
back than this reaches".

The parameter is defined in the sharing route module rather than the shared
components block, so the boundary guard keeps holding the header's name to two
files — the resolver that reads it, and the one place that publishes it.
2026-08-02 14:50:05 +02:00
Marc-André Bombeck b0a94b3423 fix(web): stabilize document and example downloads 2026-07-29 15:57:20 +02:00
Marc-André Bombeck a23ee61184 test(e2e): route cleanup happens by construction, not per spec
A spec that arms `page.route(...)` in a `beforeEach` and never drops the
handler races its own teardown. The context closes, an async handler is
still awaiting, its next call rejects with "Test ended.", and Playwright
attributes that to the test that had just passed. With `retries: 2` plus
`failOnFlakyTests`, which spec bites is a matter of timing.

`e2e/setup/test.ts` re-exports Playwright's `test` extended with an auto
fixture that unroutes every page and the context after each test, and
clears the page event listeners a spec subscribes to; `expect` rides
along so a spec needs one import. Auto means a spec author cannot forget
it and does not have to know it exists. Every spec now takes `test` from
there, and `e2e-route-cleanup-guard.test.ts` keeps the import boundary
closed with an allowlist that carries its reason.

The per-file `afterEach` on the cycle spec goes away — leaving both would
read as not trusting the fixture.

Not covered: a page inside a context a spec builds itself. Only
`share-documents.spec.ts` does that, and it owns those contexts.
2026-07-25 23:02:03 +02:00
Marc-André Bombeck 62eb431d66 release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0

* test: stabilize water capture re-entry
2026-07-22 04:14:15 +02:00