243 lines
7.9 KiB
TypeScript
243 lines
7.9 KiB
TypeScript
import type { CapabilityId } from "@suaveplan/api-types/capabilities";
|
|
import type { Principal } from "@suaveplan/api-types/principal";
|
|
import type { TenantId } from "@suaveplan/api-types/tenant";
|
|
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import {
|
|
DEFAULT_BACKPRESSURE_POLICY,
|
|
evaluateBackpressure,
|
|
} from "../backpressure/backpressure.js";
|
|
import type { WsBroker } from "../broker/broker.js";
|
|
import type { WsConnection } from "../connection/connection.js";
|
|
import type { ConnectionRegistry } from "../connection-registry/connection-registry.js";
|
|
import { InMemoryConnectionRegistry } from "../connection-registry/connection-registry.js";
|
|
import type { WsEnvelope } from "../envelope/envelope.js";
|
|
import {
|
|
DEFAULT_HEARTBEAT_POLICY,
|
|
evaluateHeartbeat,
|
|
} from "../heartbeat/heartbeat.js";
|
|
import {
|
|
signReconnectToken,
|
|
verifyReconnectToken,
|
|
} from "../reconnect-token/reconnect-token.js";
|
|
import type { SubscriptionBus } from "../subscription-bus/subscription-bus.js";
|
|
import {
|
|
type BusCaller,
|
|
InMemorySubscriptionBus,
|
|
} from "../subscription-bus/subscription-bus.js";
|
|
|
|
/** Engine + broker pair supplied by the package under test. */
|
|
export interface WsEngineFactoryResult<Payload = unknown> {
|
|
readonly bus: SubscriptionBus<Payload>;
|
|
readonly registry: ConnectionRegistry;
|
|
readonly broker: WsBroker<Payload>;
|
|
readonly makeConnection: (spec: {
|
|
id: string;
|
|
tenant: TenantId;
|
|
}) => WsConnection;
|
|
readonly dispose: () => Promise<void> | void;
|
|
}
|
|
|
|
export interface WsEngineConformanceOptions<Payload = unknown> {
|
|
readonly label: string;
|
|
readonly factory: () =>
|
|
| Promise<WsEngineFactoryResult<Payload>>
|
|
| WsEngineFactoryResult<Payload>;
|
|
}
|
|
|
|
const tenantA = "tenant-a" as unknown as TenantId;
|
|
const tenantB = "tenant-b" as unknown as TenantId;
|
|
|
|
function caller(
|
|
tenant: TenantId,
|
|
caps: readonly CapabilityId[] = []
|
|
): BusCaller {
|
|
return { tenant, capabilities: new Set<CapabilityId>(caps) };
|
|
}
|
|
|
|
/**
|
|
* Reusable harness that every concrete `(engine, broker)` pair MUST pass.
|
|
* Categories cover:
|
|
*
|
|
* - Handshake / connection lifecycle (registry add/remove).
|
|
* - Subscribe + broadcast fan-out.
|
|
* - Backpressure policy evaluation.
|
|
* - Heartbeat policy evaluation.
|
|
* - Reconnect-token round trip.
|
|
* - Cross-tenant broadcast gate.
|
|
*/
|
|
export function runWsEngineConformance<Payload = unknown>(
|
|
options: WsEngineConformanceOptions<Payload>
|
|
): void {
|
|
describe(`WS engine conformance: ${options.label}`, () => {
|
|
it("handshake registers and removes a connection", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const f = await options.factory();
|
|
try {
|
|
const conn = f.makeConnection({ id: "c1", tenant: tenantA });
|
|
f.registry.add(conn);
|
|
expect(f.registry.countByTenant(tenantA)).toBe(1);
|
|
f.registry.remove(conn);
|
|
expect(f.registry.countByTenant(tenantA)).toBe(0);
|
|
} finally {
|
|
await f.dispose();
|
|
}
|
|
});
|
|
|
|
it("subscribe + broadcast fan out to matching connections", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const f = await options.factory();
|
|
try {
|
|
const conn = f.makeConnection({ id: "c1", tenant: tenantA });
|
|
f.registry.add(conn);
|
|
f.bus.subscribe(conn, "orders");
|
|
const delivered = f.bus.broadcast(caller(tenantA), "orders", {
|
|
tenant: tenantA,
|
|
payload: undefined as Payload,
|
|
});
|
|
expect(delivered).toBe(1);
|
|
} finally {
|
|
await f.dispose();
|
|
}
|
|
});
|
|
|
|
it("backpressure evaluates eviction at the cap", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
expect(
|
|
evaluateBackpressure(
|
|
DEFAULT_BACKPRESSURE_POLICY.maxQueuedMessages + 1
|
|
)
|
|
).toBe("evict");
|
|
});
|
|
|
|
it("heartbeat evaluates abort at two missed pongs", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
expect(
|
|
evaluateHeartbeat(0, DEFAULT_HEARTBEAT_POLICY.maxMissedPongs)
|
|
).toBe("abort");
|
|
});
|
|
|
|
it("reconnect tokens round-trip end-to-end", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const secret = new Uint8Array(32).fill(13);
|
|
const token = await signReconnectToken(
|
|
{ tenant: "t1", principalId: "p1", lastEventId: null },
|
|
{ secret }
|
|
);
|
|
const payload = await verifyReconnectToken(token, { secret });
|
|
expect(payload.tenant).toBe("t1");
|
|
});
|
|
|
|
it("broker publish/subscribe/dispose are wired", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const f = await options.factory();
|
|
try {
|
|
await f.broker.publish("orders", {
|
|
topic: "orders",
|
|
tenant: tenantA,
|
|
payload: undefined as Payload,
|
|
});
|
|
const handler = (envelope: { topic: string }): void => {
|
|
expect(envelope.topic).toBe("orders");
|
|
};
|
|
const sub = await f.broker.subscribe("orders", handler);
|
|
handler({ topic: "orders" });
|
|
await sub.unsubscribe();
|
|
await f.broker.dispose();
|
|
} finally {
|
|
await f.dispose();
|
|
}
|
|
});
|
|
|
|
it("cross-tenant broadcast denied without capability", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const f = await options.factory();
|
|
try {
|
|
const conn = f.makeConnection({ id: "c1", tenant: tenantB });
|
|
f.registry.add(conn);
|
|
f.bus.subscribe(conn, "orders", { tenant: tenantB });
|
|
expect(() =>
|
|
f.bus.broadcast(caller(tenantA), "orders", {
|
|
tenant: tenantB,
|
|
payload: undefined as Payload,
|
|
})
|
|
).toThrow();
|
|
} finally {
|
|
await f.dispose();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Default factory used to self-test the harness against the in-memory bus
|
|
* shipped in this package. Concrete engine packages re-use the public
|
|
* `runWsEngineConformance` with their own factory.
|
|
*/
|
|
export function inMemoryFactory<
|
|
Payload = unknown,
|
|
>(): WsEngineFactoryResult<Payload> {
|
|
const registry = new InMemoryConnectionRegistry();
|
|
const bus = new InMemorySubscriptionBus<Payload>(registry);
|
|
const noopBroker: WsBroker<Payload> = {
|
|
publish() {
|
|
/* no-op */
|
|
},
|
|
subscribe() {
|
|
return {
|
|
topic: "*",
|
|
unsubscribe() {
|
|
/* no-op */
|
|
},
|
|
};
|
|
},
|
|
dispose() {
|
|
/* no-op */
|
|
},
|
|
};
|
|
return {
|
|
bus,
|
|
registry,
|
|
broker: noopBroker,
|
|
makeConnection({ id, tenant }) {
|
|
const sink: WsEnvelope[] = [];
|
|
const conn: WsConnection = {
|
|
id,
|
|
principal: {
|
|
id: "p" as unknown as Principal["id"],
|
|
scopes: [],
|
|
claims: {},
|
|
},
|
|
tenantId: tenant,
|
|
lastEventId: null,
|
|
state: "open",
|
|
subscribe(): void {
|
|
/* no-op */
|
|
},
|
|
unsubscribe(): void {
|
|
/* no-op */
|
|
},
|
|
send(envelope: WsEnvelope): void {
|
|
sink.push(envelope);
|
|
},
|
|
close(): void {
|
|
/* no-op */
|
|
},
|
|
ack(): void {
|
|
/* no-op */
|
|
},
|
|
};
|
|
return conn;
|
|
},
|
|
dispose() {
|
|
/* no-op */
|
|
},
|
|
};
|
|
}
|