11.1.6 End-to-end conformance suite — drive `/api/v1` as an EXTERNAL HTTP client with a real PAT against the real route + real Postgres
Repo: motir-core. One PR. The story's mandatory end-to-end test subtask — the one that proves the API works for the client it is built for: something outside the process, holding only a token and a URL.
blocked_by 11.1.2 + 11.1.3 + 11.1.4.
⚠️ Playwright is the WRONG tool here — a justified deviation, stated on the card
The canonical story composition asks for a Playwright E2E. This Story has no browser surface (the whole story is headless HTTP; the API reference page is 11.4's and carries its own E2E). Driving an HTTP API through a browser would test Playwright's request fixture, not the product, and would pay the E2E lane's cost for nothing.
The honest analogue — and the shipped precedent — is an external-client suite over real HTTP: boot the real server, mint a real PAT, and call it across a real socket. tests/cli/cli-story.test.ts already does exactly this for the CLI (built binary → real /api/mcp → real Postgres) using tests/helpers/mcpHttpServer.ts. Reuse that harness. This is a deviation in TOOL, not in rigour: the end-to-end user flow is still driven end to end, against the real thing.
What this covers that 11.1.5 cannot
The vitest gate exercises handlers and their composition IN-PROCESS. It cannot see anything that only exists once a request crosses a socket:
- The route is actually mounted and reachable at the
/api/v1/…path — a handler that exists but is wired at the wrong path passes every in-process test. - Real header transport — that
Authorization: Bearer …survives the framework's parsing, and that theX-RateLimit-*and request-id headers reach a client rather than being set on an object nobody serialises. - Real status codes and real bodies as an HTTP client sees them, including that a 500 does not leak a stack over the wire.
- The documented client journey works verbatim — the exact sequence the getting-started docs will promise.
The journey to drive (the Story's verification_recipe, automated)
- Mint a PAT with
read(via the shipped token service, as a user would via settings). GET /api/v1/me→ 200; the identity and granted scopes are correct.GET /api/v1/workspaces→ page through a collection larger than one page using ONLY the returned cursor, as an external client must.- Call with no token, a garbage token, and a revoked token → 401 each time, indistinguishable bodies.
- Call an endpoint whose scope the token lacks → 403 naming no internal detail.
- Exhaust the budget → 429 with usable
X-RateLimit-Reset; confirm the headers were present on the preceding 200s too. - Request a resource in ANOTHER workspace → 404, not 403.
- Send a malformed cursor → 422 with a
code.
Scope BOUNDARY
Ends at the HTTP conformance of THIS story's endpoints (/me, /workspaces) and the envelope behaviours. It does NOT test resource endpoints that do not exist yet (11.2 / 11.3 carry their own, and pulling their cases forward would premise this card on unbuilt code) — but the harness it builds MUST be extensible so those stories add cases rather than a second harness. It does NOT use Playwright, does NOT add a browser test, does NOT test the API reference page, and does NOT re-cover what 11.1.5 asserts in-process. It changes no production code.
No acceptance video — this Story has no user-observable surface, so it is exempt under the acceptance-video rule's non-UI carve-out.
Acceptance criteria
- The suite boots the REAL server and calls
/api/v1over a real socket with a real PAT against real Postgres — no in-process handler invocation, no mocked fetch. - Every step of the journey above is asserted, in order, as a single coherent flow rather than eight unrelated cases.
- Pagination is walked using ONLY the cursor the previous response returned — never a hand-constructed one, since that is precisely what an external client cannot do.
- The three unauthenticated cases produce byte-identical bodies (the non-disclosure property, verified at the wire, not just at the handler).
X-RateLimit-*headers are asserted present on a SUCCESSFUL response, not only on the 429.- A forced 500 returns no stack, no Prisma text and no
codeover the wire. - The harness is reusable: adding a case for a new v1 endpoint requires no new server-boot or auth plumbing — demonstrated by the file's structure.
- The suite is deterministic under CI load: every assertion waits on the response it depends on, and no step relies on a fixed sleep.
Context refs
tests/cli/cli-story.test.ts— the shipped built-binary-over-real-HTTP suite; the pattern and rigour bar to mirror.tests/helpers/mcpHttpServer.ts—startMcpHttpServer, the real-server harness to reuse/extend for/api/v1.tests/helpers/db.ts— the real-Postgres truncate harness.lib/services/apiTokensService.ts—create(...), how the suite mints a real scoped PAT.lib/mcp/scopes.ts— the scopes the suite grants and withholds.- Blockers: 11.1.2 · 11.1.3 · 11.1.4. Sibling gate: 11.1.5. Parent story: 11.1.