Files
suaveplan/packages/api-stack/api/api-ws-broker-in-memory/src/testing/testing.ts
2026-05-27 23:29:53 +01:00

114 lines
4.2 KiB
TypeScript

import type { TenantId } from "@suaveplan/api-types/tenant";
import type { BrokerEnvelope } from "@suaveplan/api-ws/broker";
import {
createInMemoryWsBroker,
type InMemoryWsBroker,
} from "../broker/broker.js";
import type { InMemoryWsBrokerOptions } from "../options/options.js";
/**
* Result returned by {@link createInMemoryBrokerHarness}.
*
* The harness ships everything a test needs to drive the broker:
*
* - `broker` — the broker instance under test.
* - `received` — a chronological array of every envelope delivered
* to the auto-subscribed observer, useful for `expect(...).toEqual`.
* - `observerSubscription` — disposal handle for the observer.
* - `publishOne(payload, opts?)` — convenience for publishing a
* single envelope on the harness's default topic + tenant.
* - `dispose()` — tears down the observer and disposes the broker
* (idempotent).
*/
export interface InMemoryBrokerHarness<Payload = unknown> {
readonly broker: InMemoryWsBroker<Payload>;
readonly received: ReadonlyArray<BrokerEnvelope<Payload>>;
readonly observerSubscription: { readonly topic: string };
publishOne(
payload: Payload,
opts?: { readonly topic?: string; readonly tenant?: TenantId }
): Promise<void>;
dispose(): void;
}
/**
* Options accepted by {@link createInMemoryBrokerHarness}. The defaults
* cover the 90% test path; supply `topic` / `tenant` only when the
* scenario needs to vary them.
*/
export interface CreateInMemoryBrokerHarnessOptions<Payload = unknown> {
readonly broker?: InMemoryWsBrokerOptions;
readonly topic?: string;
readonly tenant?: TenantId;
readonly extraTopics?: ReadonlyArray<string>;
readonly initialPayload?: Payload;
}
const DEFAULT_TOPIC = "test.events";
const DEFAULT_TENANT = "tenant-test" as unknown as TenantId;
/**
* Build a ready-to-use broker harness for tests. The harness wires up
* a single observer subscription on `topic` so callers can `publish`
* and assert on `received` without any boilerplate.
*
* The harness is intentionally additive: it does NOT replace the
* broker's public surface — `harness.broker` is the real
* {@link InMemoryWsBroker} returned by
* {@link createInMemoryWsBroker}, so any test can drop down to direct
* subscribe/publish/dispose when the convenience helpers don't fit.
*/
export function createInMemoryBrokerHarness<Payload = unknown>(
options: CreateInMemoryBrokerHarnessOptions<Payload> = {}
): InMemoryBrokerHarness<Payload> {
const topic = options.topic ?? DEFAULT_TOPIC;
const tenant = options.tenant ?? DEFAULT_TENANT;
const broker = createInMemoryWsBroker<Payload>(options.broker);
const received: BrokerEnvelope<Payload>[] = [];
const observerSubscription = broker.subscribe(topic, (envelope) => {
received.push(envelope);
});
// Pre-warm extra topics by registering a no-op subscriber so
// subscriberCount reports them. Useful for tests that probe the
// registry shape before publishing.
const extraHandles = (options.extraTopics ?? []).map((extra) =>
broker.subscribe(extra, () => {
/* probe subscriber */
})
);
let disposed = false;
return {
broker,
received,
observerSubscription: { topic: observerSubscription.topic },
async publishOne(
payload: Payload,
opts?: { readonly topic?: string; readonly tenant?: TenantId }
): Promise<void> {
const envelope: BrokerEnvelope<Payload> = {
topic: opts?.topic ?? topic,
tenant: opts?.tenant ?? tenant,
payload,
};
await broker.publish(envelope.topic, envelope);
},
dispose(): void {
if (disposed) return;
disposed = true;
try {
observerSubscription.unsubscribe();
} catch {
/* already torn down by broker.dispose */
}
for (const handle of extraHandles) {
try {
handle.unsubscribe();
} catch {
/* same */
}
}
broker.dispose();
},
};
}