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

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:

  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).