Initial Commit
This commit is contained in:
242
packages/api-stack/api/api-ws/src/testing/conformance.ts
Normal file
242
packages/api-stack/api/api-ws/src/testing/conformance.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
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 */
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user