# 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` — 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).