3.6 KiB
runTenantProviderConformance — reusable conformance harness
(no submodules)
Purpose
Every concrete TenantProvider — the in-memory reference impl
(api-tenant-provider-memory, ), the host-DB-backed adapter,
any directory-service adapter — MUST pass the same behavioural suite.
Putting the suite here, importable as runTenantProviderConformance,
guarantees that adding a new provider source is a one-line job in the
adapter's test file and that every adapter agrees on the contract's
edge cases (active-only default, includeSuspended, lookup-returns-null,
cursor invalidation, dispose idempotency).
The harness uses describe / it / expect from
@suaveplan/testing/runner so it works under both the Vitest default
runner and the Bun-conditional runner (rule 11).
Features
- Eight tests covering
listdefault filter,includeSuspended,count,lookup-returns-record,lookup-returns-null, pagination round-trip, invalid-cursor, and dispose idempotency. factory(seed)callback constructs a fresh provider per test — no shared singleton, no leaked state.- Compatible with both Vitest and Bun via
@suaveplan/testing/runner. - Every
it(...)carries{ timeout: 5000 }per CLAUDE.md.
Usage: Basic
// In api-tenant-provider-memory/src/memory.test.ts (Stage 4):
import { runTenantProviderConformance } from "@suaveplan/api-tenant-provider/testing";
import { MemoryTenantProvider } from "./memory.js";
runTenantProviderConformance({
label: "in-memory",
factory: (seed) => new MemoryTenantProvider(seed),
});
Usage: Advanced
import { runTenantProviderConformance } from "@suaveplan/api-tenant-provider/testing";
import { SurrealTenantProvider } from "./surreal.js";
import { startSurrealdb } from "@suaveplan/testing-integration-surrealdb";
// Spin up a real SurrealDB container per provider instance — the
// factory can be async, so heavyweight fixtures are first-class.
runTenantProviderConformance({
label: "surrealdb",
factory: async (seed) => {
const db = await startSurrealdb();
const provider = new SurrealTenantProvider(db);
for (const record of seed) await provider.put(record);
// Wrap dispose so the container also stops when the test ends.
const originalDispose = provider.dispose.bind(provider);
provider.dispose = async () => {
await originalDispose();
await db.stop();
};
return provider;
},
});
API Reference
interface TenantProviderConformanceOptions { readonly label: string; readonly factory: (seed: readonly TenantRecord[]) => Promise<TenantProvider> | TenantProvider }.function runTenantProviderConformance(options: TenantProviderConformanceOptions): void— registers adescribeblock with the documented tests.
Implementation Notes
The harness is intentionally minimal: it does not test telemetry,
metrics, or implementation-specific properties — those belong in the
adapter's own test file. It only pins the cross-adapter contract that
every consumer of TenantProvider relies on.
The factory callback receives a readonly TenantRecord[] seed and
MUST return a provider in which list({ includeSuspended: true })
yields exactly the active and suspended subset of seed (deterministic
order is not required — assertions normalise via sort).
dispose idempotency is tested by calling dispose() twice and
asserting the second call resolves. Adapters whose dispose reasonably
fails on double-call must reset internal state on the first call to
make the second a no-op.