ADR-0015: Acceptance tests against the reference applications¶
Status: Accepted, amended by artifactr ADR-0061 and reflexr ADR-0060, and by artifactr ADR-0063 and reflexr ADR-0061 Date: 2026-10-12 Deciders: Alex Nodeland
Context¶
stackr RFC-0003's phase 5 adds acceptance tests:
- Each FastAPI surface's OpenAPI document, committed in its package's
schemas/with a drift test. - A Bruno collection per package, in
packages/<name>/bruno/, run by itsapitask (D5). - An Acceptance job that starts docplan, oncall and the console, runs
:apiand:bdd, and runs Schemathesis over the documents.
The RFC also has the api tasks depend on docplan's and oncall's image tasks, since the collections run against those applications. Phase 6 deferred Schemathesis to this phase, which has the started applications it needs.
The rehearsal left gaps:
- The
imagetasks stop what they start. Each builds its application, starts it withdocker compose up --wait, and runsdocker compose down --volumes. A job that started docplan, then ranartifactr:apiwith animagedependency, would have it stopped under the collection, since both use the same compose project. - CI runs
scripts/affected.py, notmoon ci. It finds the affected projects, by their files and their tasks' inputs, and their dependents (ADR-0002). artifactr doesn't depend on docplan, so a change to docplan alone reachesartifactr:apionly through an input. - The RFC fixes no prefix. docplan and oncall mount their routers at
/v1, the template at/artifactr/v1and/reflexr/v1, and grantr's document mounts its router at/grantr/v1. - The applications' agents need a model. CI has no provider keys.
Decision¶
- Each library's
apitask starts its own application.- The tag's task, in
.moon/tasks/api.yml, runsscripts/api SERVICEfrom the package's directory. - The script starts the service from the package's
compose.yaml, and runs the Bruno collection, then Schemathesis over the committed document. - It stops the service however they end, printing its logs if anything failed, and Schemathesis writes a JUnit report, which the job uploads when it fails.
- Each run is a compose project of its own (
<package>-api-<token>, since #222; it removes only itself, when it ends), on host ports the system picks, which the script reads back withdocker compose port. So the two libraries' tasks run side by side, and beside theimagetasks, with no mutex. - artifactr's runs against docplan and reflexr's against oncall. Each
moon.ymlnames its service and adds/examples/<app>/**/*to the task's inputs, so a change to the application runs the library's acceptance tests. The root'spyproject.tomlanduv.lockare inputs of every Python project's tasks already.
- The tag's task, in
- The Acceptance job runs
scripts/affected.py api, afterbun install. It starts nothing itself, and the script installs Schemathesis withuv run --group acceptanceonly when it runs, so a pull request that affects no library with anapitag costs a checkout, a Bun install and moon's query.:bddjoins it with portalr's collection, and the console starts in portalr's own task. - The applications' agents run on pydantic-ai's test model. The task sets
DOCPLAN_MODELandONCALL_MODELtotest, which their compose files pass through. No provider is called, so a message still starts a run. - The documents are the routers' own.
- A bare application mounts each router at the root, and admits no one:
artifactr.fastapi.openapiandreflexr.fastapi.openapi. moon run <package>:schemawritesschemas/<package>.openapi.json, beside the JSON Schemas, andtests/test_openapi.pyholds it to the router, as grantr's test does.- Applications mount the routers where they choose, so Schemathesis's
--urlcarries the prefix: docplan's and oncall's/v1. - artifactr's
fastapilayer may now import pydantic-ai, for the runner of its bare application, whose agent never runs.
- A bare application mounts each router at the root, and admits no one:
- The collections follow one shape.
bruno.jsonandcollection.bru(with thex-userheader the demo applications trust) sit beside one environment named after the application.- Requests are numbered kebab-case files. Each makes its assertions in a
testsblock. - The ids are fixed, since the application starts empty each time.
- The collections cover each surface's reads, commands and rejections, rather than every case, which the libraries' own tests cover.
- Schemathesis is the root's
acceptancedependency group, run withuv run --locked --group acceptance, and configured in the root'sschemathesis.toml. It fails on a server error, and on every check that file doesn't turn off:status_code_conformanceandresponse_schema_conformanceare on: the routers document their refusals, and every protocol model's tag is required (#66).positive_data_acceptance, for the commands and the log's reads only: a command's body can be well formed and still not apply, andlimitandlastexclude each other, which the document can't say.negative_data_rejection, for the reads that take string ids in their query only: a value Schemathesis means as a boolean, such astrue, is a valid string id.allow_header_conformance, for good: Starlette's 405 names only the first matching route's methods.- It generates no NUL bytes. The libraries refuse one in any string as
validation_failed(#67), which the document can't say of every string, so a NUL would failpositive_data_acceptance. - The "errored" cases its summary can count are stateful steps it generated and never sent, such as a link that would leave a path parameter empty: they have no response to check, and aren't failures.
- What the acceptance tests found and this change fixes:
- reflexr answered 500 to a negative
limiton its run listing. - Both libraries answered 500 to a
seqor a count past PostgreSQL'sbigintin a read of the log. Each workspace refuses one now (validation_failed), and the routers' queries state the bounds,0toMAX_COUNT, in their documents. - Six routes answered 500, not 403, to a read
authorizerefuses, since they didn't turn a rejection into its status: artifactr's artifact, thread and proposal listings, and reflexr's rule statuses, dead letters and schedule statuses. The matrix tests now refuse every action but opening, and expect 403 from every route.
- reflexr answered 500 to a negative
- The Bruno CLI is a root dev tool, at an exact version, run as
bun x --bun --no-install bru, as every tool in the Bun workspace runs (portalr ADR-0008).--bunis load-bearing: the CLI starts with#!/usr/bin/env node, and CI has no Node.js. knip can't see it called from a shell script, soknip.jsonnames it inignoreDependencies. - The root's
overrideshold the Bruno CLI's dependencies at fixed versions. Its dependency tree brought seven packages with advisories that have fixes: axios, form-data, js-yaml and yaml within their majors, and @faker-js/faker 10, csv-parse 7 and uuid 11 a major up. osv-scanner then finds nothing new inbun.lock. The collections pass with all seven, but they exercise none of the three a major up: they use no generated data (faker), CSV iteration (csv-parse) or UUIDs of the CLI's own (uuid), so a break in those paths would show only where the CLI uses them. Bun'soverridesare workspace-wide, so the web packages get the same versions if they ever depend on these. Drop each override once the CLI's own ranges reach it.
Options considered¶
| Option | For | Against |
|---|---|---|
The api task starts and stops its application (chosen) |
Runs alike in CI and locally, with one command; nothing to start when nothing is affected; isolated, so both run at once | Builds the image again where the Images job also builds it |
The job starts the applications, and api depends on image |
As the RFC wrote it | image stops the application the job started; the job starts both whatever changed |
The job starts the applications, and api has no dependency |
No double build | A local run needs the applications started by hand, and the job always pays for both |
Schemathesis:
| Option | For | Against |
|---|---|---|
| Every check but those recorded, per operation where a gap is, with the gaps tracked (chosen) | Catches server errors now; the off checks are listed, each with its reason and scope | The documents' gaps stay until #66 |
| Every check | The full contract | Fails today on every route's undocumented rejections |
not_a_server_error alone |
Simplest | Drops checks that pass, such as content types, headers and authentication |
Consequences¶
- Easier:
moon run artifactr:apichecks the REST surface end to end against a real application on PostgreSQL, as CI does. - Easier: a change to a router that changes its document fails the drift test until the document is regenerated, and the change shows in review.
- Harder: the Acceptance job builds docplan's and oncall's images when either library or application changes, as the Images job does.
- Revisit: the excluded checks, when #66 and #67 are fixed, and portalr's collection,
bddtask and the console, with the portal.