Extending
Conformance blocks
Prove a custom executor, cache adapter, sink, auth resolver, framework adapter, generator or plugin meets its contract.
better-supabase/testing has a block per extension interface. A block runs every
check and resolves with a report, or throws a ConformanceError that lists
all failed checks. Blocks don't depend on a test runner, so they work in vitest,
node:test and bun.
import { testExecutor } from "better-supabase/testing";
import { it } from "vitest";
it("conforms", () =>
testExecutor(kyselyExecutor(db), {
betterSupabase: defineSupabase(schema),
table: "tags",
create: { organizationId: ORG, name: "conformance" },
}));| Block | Checks |
|---|---|
testExecutor(executor, { betterSupabase, table?, create? }) | rows keyed by selection aliases, limits, counts, aborted errors, failures returned instead of thrown, rpc failures, and with create a create, read and delete round trip plus a deleteMany past maxAffected that fails with max_affected and deletes nothing |
testCacheAdapter(adapter, { table? }) | table and row targets, tenants, repeated and concurrent calls, unknown tables, no mutation of the target |
testEventSink(sink, { received? }) | empty and full batches, no mutation of events, and with received delivery of every event |
testQueueBackend(backend, { queue, dedupe? }) | API v1, payload round trip, claimed messages hidden for their lease, attempt counts, retry then dead letter at max_attempts, and with leases stale attempts rejected and lease extension; dedupe keys return the first id; with stats and listDead, the dead letter is counted and listed, and with retryDead it is claimable again |
testSupportSessionStore(store, { admin, targets }) | API v1, a started session matches its input and only its admin sees it, a second start ends the first, ending works once and the ended session is listed |
testNotificationChannel(channel, { email?, received? }) | API v1, a name, a frozen message sent without mutating it, a valid result, and with received delivery of the message |
testStreamStore(store, { id?, timeoutMs? }) | API v1, a name, one open per stream, idempotent appends that refuse a gap, reads from any index that end once the stream closes, a live read that sees later appends, a cancel the writer sees, appends refused after close, a missing stream read as empty, and purge with a count |
testChatState(state, { message, id?, ttlMs? }) | subscriptions, one lock holder at a time with token-checked release and extension, lock and cache TTLs, setIfNotExists, list trimming, and a per-thread queue that keeps the newest entries, reports its depth and drops expired ones |
testCredentialProvider(provider, { ref, seed, userRef?, inbound? }) | API v1, invalid_input for a ref of another provider, a stored token with headers, a new value seen after storing it, separate credentials per user, revoke then not_found, and with inbound a signed request accepted and a tampered one refused |
testWebhookSigner(signer, { verify? }) | API v1, a name, string headers that are stable for the same input and change with the body, and with verify a signature the receiver accepts |
testWebhookTransport(transport, { url, received? }) | API v1, a name, an HTTP status and string body for a POST to url, and with received delivery of the body |
testWebhookSecretStore(store, { endpointId }) | API v1, secrets as strings, and with rotate a new secret listed first while the previous one keeps signing |
testAuthResolver(resolver, { invalid, valid?, unrelated? }) | undefined for requests without its credentials, invalid (never anon) for bad ones, the expected user for good ones, never throws |
testAdapter(name, { betterSupabase, serve, errorsInBody?, cookies? }) | a 401 without running the handler for a refused caller, data for an allowed one, a DbError answered with its status, no leaked message for other errors, bs-primary-until after a write, pending event sends handed to waitUntil |
testGenerator(generator, { meta, config?, model? }) | apiVersion 1 or unset, relative, unique paths inside the project, deterministic output, no mutation of the input |
testBlockTransportMiddleware(middleware) | API v1, a name, next called at most once, an unchanged request, and a rejection that keeps the database error's code and hint |
testAuthorizationProvider(provider) | API v1, plain JSON, valid and unique scopes with a known tenantScope, templates that use only their placeholders, requires lists every function the templates call, permissions listed once at known scopes |
testAuthorizer(authorizer, { permissions, granted?, unknown? }) | API v1, a name, stable non-empty key() strings, a known outcome for every request without mutating it, evaluations that agree with evaluate and keep the order, decisions from the subject and never from a forged input, an unknown permission never granted, and with granted a grant through the same check the server runs |
testPlugin(plugin, { betterSupabase, table?, context?, create? }) | API v1, installs alone and next to the first-party plugins in either order, a known enforce, repository methods that never replace base ones, pure and deterministic context, transformQuery and beforeMutation, wrapExecutor keeps results intact, mapError returns a DbError or undefined |
Inputs that should stay untouched are deep-frozen, so an implementation that mutates them fails with the check that caught it.
The first-party executors, adapters, generators and plugins run these blocks in the package's own test suite.
Last updated on