Commit Graph
283 Commits
Author SHA1 Message Date
Marc-André Bombeck 03d2a712d0 Move the contract tests to the end that now writes
Three integration cases pinned the old behaviour: the sync handshake stamping
the checkpoint, and the calendar read persisting the prediction. Both are the
faults this release removes, so the cases were asserting a bug.

Each file now asserts the read writes nothing AND that the other end does. The
second half is not decoration: without it, "nothing was written" would pass
just as well against a column nothing writes at all.

The cycle case seeds its own history and its own profile row rather than
reaching a route to get one, because reaching the route is exactly what the
prediction must no longer depend on.
2026-08-02 02:00:08 +02:00
Marc-André Bombeck 3b37d73f31 Merge branch 'work/ecg-ingest' into release/v1.35.3 2026-08-01 19:50:52 +02:00
Marc-André Bombeck 42e16a6e12 Stop the plaintext-waveform check passing on a coincidence
It asserted the stored ciphertext does not contain each sample rendered
as a string. Base64 is full of digits, so a short sample value like 3
matches by accident: the check failed on a mutation that had nothing to
do with encryption, and would equally have passed while leaking. It now
asserts the column holds the keyId.base64 envelope and nothing else,
which a serialised sample array cannot satisfy.
2026-08-01 19:35:16 +02:00
Marc-André Bombeck 8f90fd3174 Prove the ECG ingest, the dedupe and the migration on dirty data
A round trip through the real handlers: post a recording, read it back
through the list and the detail routes, and check what a client actually
receives rather than a fixture rebuilt at each end. Post it twice and
the second call says updated, not inserted, and the table does not grow.
Feed the same strip to the export-archive importer afterwards and it
says skipped; feed it to the importer first and the route says
duplicate. Both directions leave one row.

The migration test drops the index, manufactures the collisions the
resolution step exists for, and runs the migration file through the same
executor the deploy uses. It checks which row survived each tiebreak,
that the salvaged event link followed the keeper, that rows differing
only by user, source or sampling rate were left alone, and that the
constraint rejects a new twin afterwards. Applying the migration to the
empty testcontainer proves none of that.

The Withings ECG tests now hand the transaction the same delegate the
shared writer uses, so they still watch the real write instead of a stub
standing in for it.
2026-08-01 19:26:47 +02:00
Marc-André Bombeck ad1554a36f Make the round-trip case actually round-trip
The flip from an authored recipe back to the defaults was written with the
retired pillar as the thing left out. With that pillar gone, both halves of the
case sent the same full list, so the first read false where it expected true and
the flip was never exercised at all.

It now takes out a pillar that still exists, and says at the assertion why the
two halves have to differ.
2026-08-01 17:56:20 +02:00
Marc-André Bombeck 96c10f07e6 Give the today-just-in spec an account that earns a hero
The spec watched the day flip from provisional to final on a hero that
only its own subject was keeping on screen: the arrival marker was the
last thing in the calm-degrade guard holding the hero up for the bare
fixture account, so removing the arrival chip took the hero with it and
left the spec nothing to observe.

Putting the arrival back into the guard would bring back a worse
problem. An arrival-only digest lands in the compact all-clear
composition, which would tell someone with no readings at all that
everything is clear - a claim about their health made from nothing, and
made only on the days something happened to sync. The guard stays as it
is; the fixture was what was wrong.

So the spec now seeds its own precondition: one integration needing a
reconnect, which is an ordinary Today for anyone whose readings come
from a device. It is read fresh on every request rather than through the
dashboard snapshot cache, so unlike a seeded score or briefing it cannot
lose a race with a cached snapshot, and the rail item admits no module
gate and no dismiss path. The row is removed again afterwards.

While the spec was open: it now pins the state it starts from. It only
ever asserted that the pending-sleep note was absent at the end, which a
note that never rendered also satisfies. It asserts the note is up and
the day reads provisional first, so the two halves are one assertion
with the number flipped.

The component side is pinned in unit tests, since the spec needs a built
app and a database to run.
2026-08-01 13:21:04 +02:00
Marc-André Bombeck 46146f389c Test the configured flag over the real snapshot and digest assembly
Five accounts, each writing its selection through the real PATCH route
and reading the flag back off the dashboard snapshot, the daily digest
and the derived-metric payload. Nothing is hand-built between the ends:
a builder that forgets to carry the field returns undefined and the
assertions fail.

The four negatives are the ways an account reaches the default
composition without having authored it, including one that kept every
pillar (hasSelection is true, the flag is not) and one that took out a
pillar its modules had already withdrawn. A sixth case walks the same
account through a save and an undo, so a value cached under a key that
does not include the recipe cannot pass.
2026-07-31 18:10:25 +02:00
Marc-André Bombeck 5d13f0e69c Prove the false drop cannot come back
The named failure now has a test that drives the whole chain a request
takes: fixture rows through the real reader, the real score record
write, the real dashboard snapshot, the real verdict rung, and the real
digest that gives the morning push its sentence. Only the IO around that
chain is faked. A version built on a hand-written snapshot literal would
have stayed green through a release where the assembly between the two
ends dropped the field, which has happened here before.

Every case runs twice on the same data. Once with the recipe changed
three days ago, once with it changed a month ago. The second run is what
proves the fixture really does produce an eleven-point fall, so the first
run's silence is the guard working rather than an account with nothing
to say. The decline is gradual on purpose: a cliff would raise each
pillar's own noise floor and the delta would be suppressed for a reason
that has nothing to do with the recipe.

Beside it, the boundary's edges, the notice key's two dimensions and the
seam's shape, each with a case proving the guard stays quiet on innocent
data. Against real Postgres, a row now has to come back carrying the
recipe version and change date the account's stored configuration
supplied.
2026-07-31 16:48:40 +02:00
Marc-André Bombeck ab8e9a164d Carry the recorded score days through wipe, backup and restore
A new user-scoped table is outside all three by default, and the two
structural guards said so the moment the model landed: the wipe plan and
the backup verdict list both named it as unclassified. It now joins the
wipe list, rides the backup payload as its own spread section, and comes
back through the restore route.

The classification guards only prove a helper exists in a declared file, so
they would pass an unreferenced restore helper. The integration round trip
goes through the real admin restore route instead, with the table emptied
first so a restore that carries nothing cannot pass by leaving what was
already there.
2026-07-31 15:13:47 +02:00
Marc-André Bombeck 5784d5ec72 Prove the blood-pressure basis survives the assembly, not just both ends
The grader returns the score and its basis together and the pillar builder
copies both across, and each of those ends already had its own test. Neither
sees what happens between them: the envelope hand-off, the reader's narrowing,
the pillar list, the JSON that finally goes out. A field can be produced
correctly, consumed correctly, and never reach anyone, which is exactly how a
payload field shipped earlier this month and carried nothing on the wire.

These tests read the assembled response and assert the basis that arrives, so a
future hand-picked key list on that path fails here rather than in someone's
browser.
2026-07-31 11:15:27 +02:00
Marc-André Bombeck af70312306 Merge work/onboarding-honesty 2026-07-31 08:10:39 +02:00
Marc-André Bombeck 80c8674ee1 Remove the Health-Score explainer switch, which switched nothing
The admin panel offered a toggle for the Health-Score explainer and
described it as the ? next to the delta line. That ? had already been
taken off the insights surface a release earlier, and the caption that
replaced it went away entirely when the health score was replaced by
the reference composite. Since then an operator could turn the switch
off, get a saved confirmation, and change absolutely nothing.

The toggle, its wording in all six languages, its place in the flag
matrix the app and the iOS client both read, and its column on
app_settings are all gone. Nothing is left to promise a control that
does not exist.

A guard goes in with it: every remaining assistant sub-flag has to be
read by some surface, and the panel and the plumbing that carry all of
them by construction do not count as proof.
2026-07-31 08:06:07 +02:00
Marc-André Bombeck 92da02f824 fix(backup): carry how hard a symptom hit through export and restore
A cycle symptom can be recorded with a 1-4 intensity. The log-day sheet
writes it, the day-log reads it back, and the backup never carried it:
the file held the symptom keys and nothing else. Export a day recorded
as "cramps, and they were a 4", restore it, and you get "cramps". The
symptom is there, the number is gone, and nothing fails, which is why
nobody noticed.

The file now carries `symptomSeverities` alongside `symptomKeys` — one
entry per symptom that was actually rated. `symptomKeys` still decides
which symptoms a day had, so a release that only knows that field reads
every symptom out of a new file unchanged, and the two lists can never
disagree about presence. A link nobody rated is simply not listed: no
entry means never rated, which is not the same as rated zero.

A file written before this restores exactly as it did, with every
intensity absent rather than invented. An intensity outside 1-4, from a
hand-edited file or some later scale, costs that one number and leaves
the symptom and the rest of the restore alone.
2026-07-31 07:37:48 +02:00
Marc-André Bombeck d8ddf97d9e test(step-up): wait for the audit breadcrumb instead of sleeping and hoping
Five assertions slept a fixed 100 ms and then read the audit ledger. The write is
fire-and-forget, so on a busy runner the read lands first and the assertion fails
with "expected [] to include 'consumed'" — which reads as a broken refusal when
the refusal worked and only its breadcrumb was still in flight. CI failed on
exactly that while nothing in the branch touches step-up or MFA.

Replaced with a poll that returns the moment the reason appears and, on timeout,
asserts against the real list so a genuine regression still says what WAS
written rather than reporting a bare timeout.
2026-07-31 01:12:22 +02:00
Marc-André Bombeck 12d3239de0 feat(nutrients): tell the account holder when a sync arrived but nothing landed
GET /api/nutrients now looks up the most recent nutrient.batch.ingest
audit row when the window is genuinely empty, and surfaces it as
lastAttempt only when that call landed nothing (inserted === 0 &&
updated === 0). A real success elsewhere is left alone, so an empty
window for an unrelated reason never gets reframed as a failure.

The lookup runs on the zero-rows path only, never on the happy path,
and stays counts-and-reason-codes only - no user-typed content, no
per-entry health values. The reason code never reaches the page as
text: each of the four closed skip reasons maps to its own human
sentence, and the two that have no useful action for the account
holder (unit_mismatch, upsert_failed) say plainly that the entries
couldn't be stored rather than inventing advice. day_invalid is the
one case worth naming a real cause for - a clock or time zone mismatch
on the sending side - without claiming it was a phone, since any
token holder can call this route.

This depends on AuditLog retention: cleanupOldAuditLogs prunes rows
past 365 days by default, so a dormant account's last failed attempt
can age out and the explanation silently reverts to the plain empty
state. Documented at the lookup for whoever next touches that job.
2026-07-30 23:30:28 +02:00
Marc-André Bombeck 845b89255d fix(nutrients): audit every batch ingest call, not only the ones that wrote a row
nutrient.batch.ingest only reached the durable AuditLog table when
inserted + updated > 0, so a batch where every entry was rejected
(unit mismatch, out-of-range value, invalid day, a write failure) left
the exact same empty trail as a phone that never called the endpoint
at all. That made an empty nutrient_intake_days table unanswerable
from the durable record - the question this surface exists to answer.

Record the call unconditionally, the same shape measurement.batch.ingest
and workout.batch.ingest already use (counts only, no per-entry health
values, no user-typed content), plus an aggregate skip-reason breakdown
so an operator can tell, from the row alone, that the phone posted and
why nothing landed.

HTTP status and the per-entry response shape are unchanged - a batch
endpoint returning 200 with per-entry outcomes on a partial failure is
the correct, documented contract and native clients already decode it.
2026-07-30 23:18:48 +02:00
Marc-André Bombeck ee2c93a67b test(medications): assert bounded replay details 2026-07-29 16:43:22 +02:00
Marc-André Bombeck 271f0ed28a fix(medications): make import replay results honest 2026-07-29 16:11:45 +02:00
Marc-André Bombeck 5b695f43ea fix(analytics): restore device-priority sleep selection 2026-07-29 16:08:41 +02:00
Marc-André Bombeck fb02cf89b1 fix(jobs): reconcile durable fact inventory 2026-07-29 15:59:27 +02:00
Marc-André Bombeck 75aa850eae style: satisfy v1.34.1 format gate 2026-07-29 15:59:12 +02:00
Marc-André Bombeck 8db21ab718 fix(jobs): persist bounded success outcomes 2026-07-29 15:50:42 +02:00
Marc-André Bombeck 34372b57bd fix(01-11): use canonical compliance status fixture
- model resolved rolling doses with the valid upcoming status
- keep take-all suppression aligned with production DoseStatus values
2026-07-29 14:25:06 +02:00
Marc-André Bombeck 25612294d1 test(01-11): pin rolling overdue consumer parity
- compare persisted weekly occurrence across list, dashboard, GLP-1, and take-all
- protect exact intake retries and unrelated occurrence isolation
2026-07-29 14:23:52 +02:00
Marc-André Bombeck 6f84629482 test(01-13): add red Google sync terminal contracts
- cover authenticated status polling and bounded resource outcomes
- prove committed workouts invalidate server and client cache surfaces
2026-07-29 14:22:49 +02:00
Marc-André Bombeck 6cc3de8248 test(01-12): add failing Apple ECG import contracts
- pin bounded path-safe archive and parser behavior
- require encrypted idempotent fail-soft persistence and WHOOP regressions
2026-07-29 14:20:56 +02:00
Marc-André Bombeck d8e52b831d test(01-13): add red Google sync progress contracts
- pin workout-first fairness and bounded resource outcomes
- require owner-scoped race-safe current-run progress in PostgreSQL
2026-07-29 14:18:20 +02:00
Marc-André Bombeck 81893ac972 test(01-11): reproduce cross-midnight reminder identity bug
- pin prior-day occurrence lookup across timezone and DST boundaries
- prove real worker dedup and distinct next-dose behavior with PostgreSQL
2026-07-29 14:15:48 +02:00
Marc-André Bombeck b7b18a6da2 fix(01-08): align passkey request fixture typing
- construct NextRequest options with the framework-compatible init type
- preserve every enrollment security assertion unchanged
2026-07-29 13:55:57 +02:00
Marc-André Bombeck 0c29e6e6b0 test(01-01): keep bearer regression lint-clean
- avoid React hook-name classification for token fixture helpers
2026-07-29 13:44:28 +02:00
Marc-André Bombeck 390c718b74 test(01-01): reproduce MCP refresh replay race
- coordinate concurrent exchanges with a real PostgreSQL row-lock barrier
- verify family-wide successor revocation and cross-connection isolation
2026-07-29 13:42:30 +02:00
Marc-André Bombeck abea48026b test(01-03): add atomic reset revocation contracts
- execute the operator CLI against real PostgreSQL credential tables
- require complete target revocation and unrelated-admin isolation
- force a mid-transaction database failure to prove rollback
2026-07-29 13:42:04 +02:00
Marc-André Bombeck 9f6cd86c0d test(01-03): add passkey enrollment security contracts
- require fresh existing-factor proof on both cookie-only legs
- bind registration challenges to user, purpose, session, expiry and one use
- cover bearer, ambient-cookie, stale, foreign and replay attacks
2026-07-29 13:38:53 +02:00
Marc-André Bombeck 6c0f47acad test(01-01): pin MCP read scope boundaries
- cover health read and wildcard positive capability paths
- reject unrelated scopes before server registration and database reads
2026-07-29 13:38:41 +02:00
Marc-André Bombeck 1d784c6367 fix(analytics,ci): merge health-score meta annotations, migrate integration tests, remove dead exports, raise bundle budget 2026-07-28 23:02:00 +02:00
Marc-André Bombeck d9880b0562 test: cover every measurement source write path 2026-07-28 21:11:50 +02:00
Marc-André Bombeck 3aff2c16db fix(insights): scope generation guard to prompt fields, clear cache on restore 2026-07-28 21:11:43 +02:00
Marc-André Bombeck f201fb32e7 test: cover every measurement source write path 2026-07-28 15:12:23 +02:00
Marc-André Bombeck 666a85d311 feat(insights): persist correlation patterns 2026-07-28 14:01:11 +02:00
Marc-André Bombeck 8ece6f39cc fix(status): expose background failures 2026-07-28 14:00:44 +02:00
Marc-André Bombeck 81825ce23b merge: forward-port v1.33.2 into release/v1.34.0 2026-07-28 11:43:44 +02:00
Marc-André Bombeck dd3a7915d6 test(backup): cover the assembly a custom cycle symptom has to cross
Both ends of this defect had passing tests for the whole time it was broken.
The section builder returned the symptoms, the wire schema declared them, the
restore read them, and each of those three was covered on its own. Nothing
covered the object literal in between, which copied section keys out one at a
time and never listed `customSymptoms`.

So the test spans the seam and asserts on rows. It populates an account with a
symptom of its own on a day-log, builds the file through the real
`buildFullBackupPayload`, serialises it, parses it back through the real
`parseBackupPayload`, and drives the real admin restore. Asserting that the
payload carries the field would only re-test the writer that was already green.

Deleting the symptom row before the restore is the case the field exists for,
not a contrivance: `restoreCycleData` clears day-logs, cycles, and the profile
but never the symptom catalogue, so a restore onto the same instance still
finds the row sitting there. That is why the older round-trip test stayed green
through the whole thing.

Mutation check: hand-pick the cycle section's keys again and the restore
answers 500 on `Unknown cycle symptom keys: custom:sacroiliac-ache`, which is
the production failure verbatim.
2026-07-28 00:09:22 +02:00
Marc-André Bombeck be348f8820 test(score): pin the weight yardstick, the mood gate and rollup/live parity
Covers what the required parameters now make impossible to forget: the
scorer takes the band whole and never widens it, a missing target scores
the bare trend, the resolved yardstick rides the weight component, the
shared builder refuses the reference band that sits under an override,
the snapshot gates its mood pillar on the module map, an explicit target
reaches both the score and the dashboard bands, and weight/BMI record
direction follows the user's own goal.

F1 adds the golden the score has never had: the rollup read branch and
the live read branch scored against one series encoded both ways. On the
canonical one-weigh-in-a-day cadence they agree exactly. On an uneven
cadence they part company, and the test pins that the whole difference is
the per-day mean collapse - the rollup branch equals the live branch fed
those same daily means - with a companion assertion proving the fixture
is not a no-op.
2026-07-27 23:15:38 +02:00
Marc-André Bombeck eba9f15539 test(integrations): prove the leg backfill against rows that carry the old value
A migration container starts empty, so applying 0281 there proves the
statements parse and nothing else — the backfill's WHERE matches no rows and a
broken expression passes green. Every self-hoster runs it against rows that do
carry a leg, and those are the only rows it exists for.

Seed the pre-migration shape, run the migration's own UPDATE read out of the
committed file, and assert an attributed row comes out still knowing which leg
broke, an unattributed one is left alone, and a second run does not nest the
array.
2026-07-27 14:35:34 +02:00
Marc-André Bombeck 4e95df9e8d feat(integrations): tell the Apple Health card how the last sync arrived
The batch route has accepted a `syncTrigger` since v1.32.8 and put it on the
wide event, where no human reads it. Half of what was promised got built: the
value is written and appears in three places in the codebase, none of which is
a screen anyone looks at.

Persist it on the account, alongside the instant of the most recent batch that
arrived without the app being opened, and publish both from
`GET /api/integrations/healthkit`. The card now names how the last sync arrived
and when data last came in on its own. `healthKitLastSyncedAt` alone reads
identically whether the phone delivers by itself or only while someone has the
app open, which is why "is background delivery working" could not be settled
from either side.

A batch that declares no trigger writes null rather than leaving the previous
one standing — the field describes this sync, and an older client's silence is
not evidence about it. A foreground batch leaves the background timestamp
alone; overwriting it would erase the only answer the card has.

Also here: WHOOP, Fitbit and Google Health join the workout-freshness table.
All three write workouts, and without an entry their cards could report every
measurement type fresh while the workout leg had been silent for weeks. And the
status pill drops a generic `aria-label` that replaced its own accessible name,
so a screen reader announced the same words for a healthy connection as for a
broken one.
2026-07-27 14:35:33 +02:00
Marc-André Bombeck 2a9b5ba59d test(coach): prove the availability probe's SQL against Postgres
The unit suite mocks Prisma, so it proves the discriminant and nothing about the
query underneath it — a wrong column would have left every test green and every
account answering no_data_unconfirmed in production. Seeds the reported case and
the four other shapes: an empty record, another account's rows, tombstoned rows,
and a history that mixes units.

Rounds the out-of-window mean to one decimal, like every other mean the snapshot
surfaces, and annotates a failed manifest probe rather than letting it degrade in
silence.
2026-07-26 18:34:42 +02:00
Marc-André Bombeck 9db7df1d4d test(db): follow the inventory repair to its new number 2026-07-26 18:02:35 +02:00
Marc-André Bombeck 7b59388887 test(medications): make the repair's idempotency assertion able to fail
The comparison now includes `updated_at`, so a second application that
rewrites an already-correct row is visible even when the value it writes is
identical. Without it the assertion passed whether or not the guard that
skips untouched rows was there.
2026-07-26 18:00:08 +02:00
Marc-André Bombeck f226a907cb fix(medications): clear the pending clock deadlines the repair would have missed
The first pass of migration 0276 restored the rows the in-use clock had
already flipped, but every clock-less container still IN_USE carried the same
clock-derived `expires_at`, most of them with the deadline still ahead. The
daily expire scan would have worked through them over the following weeks and
reproduced the whole defect one row at a time.

A clock-less container's `expires_at` is now set from its printed expiry —
usually nothing, which is how it stays out of that scan. Pens and ampoules
keep theirs; their clock is real.
2026-07-26 18:00:08 +02:00
Marc-André Bombeck bb1e01e456 fix(medications): restore the containers the in-use clock wrongly wrote off
Migration 0276 reverses the rows that reached EXPIRED only through the
post-opening clock on a container that never had one. It moves a row back to
IN_USE when an opening date is recorded and ACTIVE when none is, and sets
`expires_at` from the printed expiry — which for these rows is usually
nothing at all, because they recorded none.

The predicate is exact rather than approximate: nothing but the printed
expiry and the in-use clock can write EXPIRED, so a clock-less container
whose printed expiry is absent or still ahead can only have got there through
the clock. Pens and ampoules keep their EXPIRED, so does anything whose
printed date genuinely lapsed, and so does a drained container. No date is
invented and no printed expiry is fabricated.

The integration test seeds the before-state, applies the migration SQL from
disk, and asserts both what moves and what stays — the testcontainer starts
empty, so the migration running at boot proves nothing on its own.
2026-07-26 18:00:07 +02:00