72 lines
5.2 KiB
Markdown
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).
|