Files
suaveplan/openspec/archive/2026-05-26-api-host-testing-kit/specs/api-host/spec.md
2026-05-27 23:29:53 +01:00

72 lines
5.2 KiB
Markdown

# api-host — Stage-5 testing-kit population
## MODIFIED Requirements
### Requirement: `./testing` subpath populates the Stage-5 keystone
The package SHALL declare `./testing` as a subpath export AND ship:
1. `createTestHost(config?: TestHostConfig): Promise<TestHost>` — Stage-5 keystone composing every in-memory adapter into a single line of consumer code.
2. `runPipelineRouterConformance(factory)` plus the `PipelineRouterConformanceFactory` + `PipelineRouteRegistration` types (kept from the prior requirement).
3. The legacy `STAGE_2_RESERVED` sentinel, retained for back-compat; its value SHALL flip from `true` to `false` in this release so consumers that branched on `=== true` can detect the populated surface.
`createTestHost` SHALL resolve each adapter slot in override-beats-default order:
- `router` → default `createInMemoryRouter()` from `@suaveplan/api-router-in-memory` (loaded via dynamic specifier to side-step the build-graph cycle the package's runtime dep on `api-host` introduces).
- `auth` → default `SessionResolverChain([createStaticPrincipalResolver({principal: seed.principal})])` when `seed.principal` is set, else `SessionResolverChain([createAnonymousResolver()])`.
- `tenant` → default `tenantResolverChainFromProvider(createMemoryTenantProvider({ tenants: seed.tenants ?? [] }), { headerName: "X-Tenant-Id" })`.
- `secrets` → `createEnvSecrets({ allowedKeys: Object.keys(seed.secrets), source: () => seed.secrets })` when `seed.secrets` is set, else `new EmptySecrets()`.
- `db` → `createSqliteMemoryAdapter()`.
- `ledgerDb` → a separate `bun:sqlite` `:memory:` instance, loaded via dynamic specifier; disjoint from `db`.
- `capabilities` / `rateLimit` / `idempotency` / `csrf` / `audit` / `span` / `adminPrincipal` — minimal in-process accept-all stubs.
`TestHost` SHALL extend `Host` with `installFixturePlugin(fixture)`, `fetchAs(principal, request)`, `waitForEvent(topic, timeoutMs?)`, `dispose()`, and a `readonly adapters: TestHostAdapters` surface exposing the wired `HostAdapters` bag, the `InMemoryEventsBus`, the `LifecycleEventMap` event bus, and the materialised fixture-route registrations.
`waitForEvent` SHALL first inspect the in-memory bus's recorded history (so events emitted BEFORE the call still resolve) then subscribe to future emissions; it SHALL reject the returned promise when `timeoutMs` elapses.
`dispose` SHALL stop the wrapped host, dispose the event bus, dispose the ws-broker, close the admin db (when `close` is available), and close the ledger-db handle. Calling `dispose` more than once is a no-op.
The package's vite build SHALL emit `dist/testing/testing.js` AND `dist/testing/create-test-host/create-test-host.js` AND `dist/testing/pipeline-router-conformance/pipeline-router-conformance.js` so the subpath resolves at runtime.
#### Scenario: zero-config boot yields a usable host with every default adapter
- **WHEN** a test calls `await createTestHost()` with no arguments
- **THEN** the returned `TestHost.plugins()` SHALL be empty AND `host.adapters.host.router` SHALL be defined AND `host.adapters.eventBus` SHALL be defined AND `host.fetch(new Request("http://t/internal/livez"))` SHALL resolve with status 200
#### Scenario: seed.principal reaches `ctx.principal`
- **WHEN** a test calls `createTestHost({ seed: { principal } })` then installs a fixture plugin with a route reading `ctx.principal`
- **THEN** the handler SHALL observe `ctx.principal.id === principal.id` on every request
#### Scenario: seed.tenants + X-Tenant-Id reaches `ctx.tenant`
- **WHEN** a test seeds `tenants: [{ id: "t-a", slug: "tenant-a", ... }]` AND issues `fetch(new Request(url, { headers: { "X-Tenant-Id": "t-a" } }))`
- **THEN** the matched route handler SHALL observe `ctx.tenant.id === "t-a"` AND `ctx.tenant.slug === "tenant-a"`
#### Scenario: seed.secrets exposes `ctx.secrets.require`
- **WHEN** a test seeds `secrets: { FOO: "bar" }` AND a route handler calls `await ctx.secrets.require("FOO")`
- **THEN** the handler SHALL receive the string `"bar"`
#### Scenario: ctx.db round-trips a real bun:sqlite write/read
- **WHEN** the smoke server provisions a tenant-aware table via the admin db AND the per-request handler INSERTs + SELECTs through `ctx.db` under an `X-Tenant-Id` header
- **THEN** the SELECT SHALL return the inserted row
#### Scenario: waitForEvent resolves on bus emission
- **WHEN** a test calls `host.waitForEvent("topic.x", 2000)` AND a route handler (or test) calls `host.adapters.eventBus.publish("topic.x", payload)`
- **THEN** the `waitForEvent` promise SHALL resolve with the payload
#### Scenario: waitForEvent rejects on timeout
- **WHEN** a test calls `host.waitForEvent("topic.never", 25)` AND no emission lands within 25ms
- **THEN** the promise SHALL reject with an `Error` whose `message` contains `"timed out"`
#### Scenario: dispose is idempotent
- **WHEN** a test calls `host.dispose()` twice in succession
- **THEN** the second call SHALL be a no-op (no thrown error, no double-close on the ledger handle)
**Verified by:** `src/testing/create-test-host/create-test-host.test.ts` (13 unit tests against a hand-rolled router adapter) AND `e2e/foundation-stack-test-host/tests/test-host.spec.ts` (8 Playwright smoke groups against a Bun.serve listener).