--- id: testing title: A defect in a sequence of commands is caught here, not in production status: open repos: [atgc] dependsOn: [] exitCriterion: > Every command family has a flow test driving the real binary against a mock service, and the mock behaves the way the real service does. --- # testing Unit tests cannot see the thing that actually goes wrong here, which is the request side of a *sequence*: a round appended to the wrong record, a chain whose `dependentOn` points outside its own batch, a write made with the wrong account's credentials, a read-modify-write that drops the rounds it was not editing. So `tests/support/` drives the built binary against mock services on loopback ports. Two properties make it hermetic: `Command::env` is not `unsafe`, so a child may have its own `HOME`, and jacquard restores an unexpired fixture session without contacting anything. The argument against mocking a *parser* is unchanged and is in `docs/testing.md`. A model of the service that *reads* these records is a third thing again, and `tests/support/appview.rs` is the first of them. What is open is the coverage that is missing, how far that model reaches, and one way the mock is not faithful. ## What it needs - [ ] The model of the appview covers `GetStack` and pull state, and stops there. `state_of` models `stateWinner` (`appview/db/entity_state.go`, read 2026-08-28) — newest by `created_micros`, ties by the greater at-uri, no reference to who wrote the record — plus the `check (status in ('open','closed','merged'))` constraint that keeps an unknown variant from ever becoming a row. What it deliberately does not model is *which* records the ingest lets in: the ACL check that drops a status record from an account with no standing lives in a firehose consumer that a blobless clone could not be made to give up cheaply, so that half is taken from atgc's own `Standing` notes and is labelled in the module as the one rule not read off the Go. Reaching it is the next thing this model wants - [ ] The model of the appview still covers only two of its answers. It is the only place this suite asks what the *reader* of these records makes of them, and every other rule tangled.org applies is still asserted nowhere: how it resolves a pull's state from the status records it accepts from two accounts, what its own resubmit does with a change-id it has not seen before, what it makes of a patch with none. Each is the same shape of work, and the payoff is the one the retire fork demonstrated — legal on a PDS, wrong on the appview, and no mock of our own could have said so - [ ] Most flow tests are still a `create` and one command after it. That is the shortest sequence that can be wrong about anything and it is not the only length: the fork bug first became visible at the third command, and `every_edit_leaves_the_two_readers_agreeing` — amend, reorder, insert, retire, merge, rebase, with the invariant checked after each — is currently the only test in the file longer than five. What makes a long sequence affordable is having something to assert at every step, so this is the entry that depends on the one above - [ ] The `auth` subsystem has flow tests for the verbs that can be driven offline, and wants the two that cannot. `tests/auth_flows.rs` now covers `accounts.json` under a command that rewrites it and one that only reads it, `auth token`'s promise that stdout is the token and nothing else, and `auth logout` by name and `--all`. `login` needs a browser and `refresh` needs a token endpoint, so both want the mock to grow one before they can be driven; the original entry stands for them: - [ ] The `auth` subsystem still has no flow test for its own verbs. The store now has unit coverage — the elided write, the new inode and mode per write, the key spellings `logout` filters on, and a pretty-printed fixture standing in for every store written by a previous version — but nothing exercises `login`, `switch`, `logout`, `refresh` or `token` end to end, which is a large part of why these defects lasted. That wants `tests/support/` rather than unit tests: `Command::env` gives a child its own `HOME`, and jacquard restores a fixture session with no network. Whoever takes it should read `tests/support/mod.rs`, `tests/support/account.rs` and `docs/testing.md` first — this was left undone for want of those, not because the coverage is unwanted ## Done - [x] The `Evidence`-adoption audit is done and came back clean, recorded so it is not redone. Every listing read in the command layer either checks its own reach before concluding, or is best-effort with a written argument for why silence is right there — `pr create`'s two warnings, which may not block a create; `issue`'s state walk, which warns and shows `?` because a listing *shows* a state where a write path *decides* on one. `dependents_above` was the single gap and is fixed. `pr checkout` is the case worth copying: `understack_patches` walks `dependentOn` record by record with direct `getRecord` reads, so there is no listing to be short and no index to be behind. Where the question is "what does this record point at", the links are the answer - [x] A PDS can be made unreachable, per account, which completes the set: index, appview, knot XRPC, knot push, PDS. Per-DID rather than a single switch, because atgc's *own* PDS going down is the uninteresting case — every command fails at its first read and says so. The interesting one is somebody else's answering while yours does not: a pull's author, or a repo owner, whose records decide a state you are about to write over. Two orderings are pinned. A close whose *author's* PDS cannot be read stops rather than writing a `closed` that would outrank whatever sits unread. And a merge whose *own* PDS is already down never reaches the knot — asking it first would land the patch and then certainly fail to record it, manufacturing by hand the half-state the code elsewhere apologises for. That is an ordering worth a test rather than a consequence of which line came first - [x] The audit the frame in `docs/testing.md` prescribes — every place a failed read becomes an empty answer — came back clean, which is worth recording so it is not redone. `pds_states` failing warns and returns `states_complete: false`, so the reach travels with the answer; `recorded_head` returning `None` means "no lease", which leaves a push fast-forward-only rather than overwriting on a guess; `absence_is_open` and `ownership` both swallow into the safe direction and say so. The one that did not carry its reach was `dependents_above`, fixed beside this - [x] Every service atgc leans on can now be made to fail, and the knot was the last one. `Checkout::refuse_pushes` installs a `pre-receive` hook in the bare repo pushes land in — a knot refuses at the protocol layer, and git reports that differently from a filesystem it cannot open, so the hook is the faithful shape rather than an unwritable directory. A refused push is the only knot failure that happens before any record exists, which makes what it leaves behind a branch and nothing else: `stack create --add-change-ids` puts the rewritten branch back, and a reconcile whose push is refused writes no records at all. Both were confirmed by mutation — removing the undo fails this test and the batch one together, through two entirely different roads to the same guarantee - [x] The appview can be made to fail too, and the three shapes that depend on it are told apart. `World::web_fails` is the lever; a 404 was already modelled and several commands read one as a real answer ("no number for this pull yet"), which is why an unreachable service needed its own switch rather than reusing it. A dead appview costs `pr close` its answer outright — resolving the repo owner is what stops a close replacing a merge, and the redirect is the only route from a repo DID to its owner — so it refuses and names the service. It costs `stack view` a *number* and nothing else, since the record key is the identifier that always exists. Separating those two is the point: losing a service must stop a write whose safety depended on it and must not stop a read it was only making prettier - [x] **The merge half-state has a test.** A merge is two writes to two services with no transaction over them: the knot moves the branch, then the PDS records a merged status per pull. The second failing leaves work that is genuinely landed and pulls every listing still calls open, and the refusal names them one per line so somebody can finish the job by hand — which is the message a person reads at the worst moment they will have with this command, and nothing checked that it said anything. It asserts the knot really was asked, as well: a half-state and a refusal-before-anything-happened want opposite responses, and getting that backwards would make the message alarming in the wrong direction - [x] An index that is *down* can be written as a test, which it could not be before: the mock answered 200 with rows or 200 with none, so every index fixture here modelled an index that answers. A refused request is a different fact — an empty answer is evidence about the world, a 500 is evidence about nothing — and the fallbacks for it were written and untested. `World::bobbin_fails` is the lever. Six tests now cover it: `pr list` falling back to the PDS with a warning that names what is missing, the same listing erroring rather than printing an empty one when there is nothing of your own to fall back to, `search` failing rather than reporting no matches (it is the one command with no non-index answer), both stack write paths saying the index they rely on is unreachable, and `stack view` staying silent about an index it never asked. The write paths carry on after warning, which is the right answer and worth writing down: two of the three ways a blind index could hurt them are structurally guarded, since a member *below* a visible one is named by its `dependentOn` and shows up as `missing_below`. The third cannot be guarded at all — nothing points upward, so a contributor's member stacked on top is invisible with no trace — and the warning is the whole of the available mitigation - [x] The mock PDS serves `listRecords` newest first, by descending record key, which is what a real one does — and its cursor walks down rather than up. It served them ascending for its whole life, which made every paging test arrange its keys backwards from reality and made the one rule that reads the *order* rather than the count untestable rather than untested: the backwards walks stop when a page ends below the subject's own key, a condition that against an ascending mock can never hold on the page it is meant to hold on. Reversing it broke no test, which is the finding rather than the relief: nothing in the suite depended on the order, so the paging rules were never being exercised. The test that was impossible before is now there — two hundred planted status records, and a state change that reads two pages rather than four because each walk stops at the floor. Stubbing the floor out makes it fail, which is the property the entry above asked for and could not have - [x] A model of the reader, beside the mocks of the writer. `tests/support/appview.rs` walks the records the way Tangled's own `GetStack` does and reports what it would arrive at — a chain, an ambiguity, a malformed link or a cycle — and `Scenario::assert_stack_reads_alike` asserts that atgc and the appview read the same stack off the same records. That is the half of the exit criterion above that the mocks cannot reach. They check what atgc sent, and a stack can satisfy every rule a PDS enforces — every write authorized, every precondition met, every record valid against its lexicon — and still be one tangled.org draws wrongly. The retire fork was exactly that, and the whole suite stayed green through it. A fork is not an *error* to the appview, which is the fact worth carrying out of this: `GetStack` asks for *the* pull depending on a given one and takes whichever row comes back, so a fork is a coin toss rather than a refusal, and the live member above can silently drop out of the stack view. Three tests plant chains directly — a fork, a dangling link, a cycle — because no current atgc can be made to write one and an oracle with no failing case proves nothing. It is a reading of somebody else's source and it goes stale; the module doc says which file and which date - [x] The index the stack writes actually read. `stack resubmit` and `stack merge` take `Source::EVERY` — the PDS, Bobbin and the web scrape — unconditionally and whatever `ATGC_USE_BOBBIN` says, because a merge has to see a member somebody else opened. So an index row is an input to a destructive decision rather than a convenience for a listing, and every stack test until now read one source, on a repo the acting account owns. Three tests cover the index against those records: the default asks no index at all, a member the index has not caught up with is still a member, and a stale row does not turn a retire into a deletion — which holds only because the state rule inverts on your own repo, where Bobbin has no third party to be better informed by. Three more drive a stack opened against a repo the account does not own, which the file had no coverage of at all: every member reads `?`, both write commands refuse rather than guess, and consulting the index is the whole of the recovery - [x] `tempfile` as a dev-dependency. Eleven hand-rolled temp-directory helpers across `testutil.rs`, `config/`, `cmd/`, `logging/` and `tests/` — not nine — plus seven more directories built inline at the test that used them, four with a `Drop` impl of their own, most disambiguating by pid, which is an assumption about how the suite runs. Dev-only, so it never ships. Production `write_atomic` stays as it is: it sets the mode on the `open(2)` call, which `NamedTempFile::persist` would regress. The one thing that did not survive a straight swap, recorded because it is invisible until something asserts on it: **`tempfile` creates a directory `0o777 & ~umask`**, where the helpers it replaced handed back a path that did not exist yet and let `create_private` make it `0700`. `create_private` is `DirBuilder::recursive(true).mode(…)`, and that applies its mode only to a directory it actually creates — so on an existing one it is a silent no-op. Two consequences. The narrowing test caught it directly, and now creates a child of the throwaway root so the directory under test is one `create_private` made. And the five helpers standing in for `~/.config/atgc` are pinned to `0700` through `Builder::permissions`, because a test that plants a `0600` store file inside a world-traversable directory is checking something weaker than the thing it names. The ordinary scratch directories are left at the default, which is what `create_dir_all` gave them before - [x] Integration environments: whole *sequences* of commands driven through the built binary against mock services on loopback ports (`tests/support/`, `tests/stack_flows.rs`, `tests/pr_flows.rs`). What they assert is the request side — what atgc sends, in what order, as whom — which no fixture can observe and no unit test can reach: a round appended to the wrong record, a chain whose `dependentOn` points outside its own batch, a write made with the wrong account's credentials, a read-modify-write that drops the rounds it was not editing. Two properties make it hermetic: `Command::env` is not `unsafe`, so a child may have its own `HOME`, and jacquard returns an unexpired session without contacting anything, so a fixture session restores offline. The argument against mocking a *parser* is unchanged; see docs/testing.md - [x] `Cli::command().debug_assert()` runs in `main.rs`'s `no_duplicate_ids_or_dangling_references_anywhere_in_the_command_tree`. It walks every subcommand, which is what nothing else in the suite does — parsing a line only builds the subcommands on that line, so a malformed definition three commands away stays invisible until somebody types it. Found nothing on the tree as it stands, which is worth saying only because the check was proven to bite first: giving `--no-input` a `-q` short makes it fail with "Short option names must be unique for each argument, but '-q' is in use by both 'quiet' and 'no_input'". The `--account` collision that `auth_switch_and_logout_do_not_collide_with_the_global_account_flag` documents is the same class, and was found by hand months late - [x] The fixture models Tangled's multi-account shape instead of collapsing it. Every account is served by its own mock PDS on its own port and answers only for itself, so asking the wrong host is a 404 the way it is in production; a write whose bearer does not own the repository is a 403, which the mock had never checked. A third account, Carol, and `Scenario::upstream` put the repo in the hands of somebody neither contributor is — the case where "the owner's PDS" and "my PDS" stop being the same host. All 1,230 tests pass against it, which is the evidence that the merge bug was the only one of its kind the suite reaches - [x] `scripts/test-isolated.sh` runs each test binary with a home of its own and fails the run if anything was left in it. Containment and detection are different problems and neither is enough alone: a sandbox stops a test writing to `~/.config/atgc` but says nothing, so the escape happens into a home that is thrown away and nobody learns of it. Four unit tests had been depositing a `did:plc:alice` directory in the real configuration directory of whoever ran the suite, green every time, because `HOME` cannot be redirected from inside the test process — a test that reaches the real directory finds it, works, and passes. Under bubblewrap the home is unreachable as well as empty; without it the script says so and still does the checking, so a CI image that forbids user namespaces gets the half that finds bugs