5.2 KiB
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:
createTestHost(config?: TestHostConfig): Promise<TestHost>— Stage-5 keystone composing every in-memory adapter into a single line of consumer code.runPipelineRouterConformance(factory)plus thePipelineRouterConformanceFactory+PipelineRouteRegistrationtypes (kept from the prior requirement).- The legacy
STAGE_2_RESERVEDsentinel, retained for back-compat; its value SHALL flip fromtruetofalsein this release so consumers that branched on=== truecan detect the populated surface.
createTestHost SHALL resolve each adapter slot in override-beats-default order:
router→ defaultcreateInMemoryRouter()from@suaveplan/api-router-in-memory(loaded via dynamic specifier to side-step the build-graph cycle the package's runtime dep onapi-hostintroduces).auth→ defaultSessionResolverChain([createStaticPrincipalResolver({principal: seed.principal})])whenseed.principalis set, elseSessionResolverChain([createAnonymousResolver()]).tenant→ defaulttenantResolverChainFromProvider(createMemoryTenantProvider({ tenants: seed.tenants ?? [] }), { headerName: "X-Tenant-Id" }).secrets→createEnvSecrets({ allowedKeys: Object.keys(seed.secrets), source: () => seed.secrets })whenseed.secretsis set, elsenew EmptySecrets().db→createSqliteMemoryAdapter().ledgerDb→ a separatebun:sqlite:memory:instance, loaded via dynamic specifier; disjoint fromdb.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 ANDhost.adapters.host.routerSHALL be defined ANDhost.adapters.eventBusSHALL be defined ANDhost.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 readingctx.principal - THEN the handler SHALL observe
ctx.principal.id === principal.idon every request
Scenario: seed.tenants + X-Tenant-Id reaches ctx.tenant
- WHEN a test seeds
tenants: [{ id: "t-a", slug: "tenant-a", ... }]AND issuesfetch(new Request(url, { headers: { "X-Tenant-Id": "t-a" } })) - THEN the matched route handler SHALL observe
ctx.tenant.id === "t-a"ANDctx.tenant.slug === "tenant-a"
Scenario: seed.secrets exposes ctx.secrets.require
- WHEN a test seeds
secrets: { FOO: "bar" }AND a route handler callsawait 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.dbunder anX-Tenant-Idheader - 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) callshost.adapters.eventBus.publish("topic.x", payload) - THEN the
waitForEventpromise 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
Errorwhosemessagecontains"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).