test(web-next): e2e realtime-ping (4 checkpoints)

This commit is contained in:
2026-05-09 00:39:44 +02:00
parent 2351ca6249
commit b9caf605d2
5 changed files with 120 additions and 3 deletions

View File

@@ -40,6 +40,7 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.50.0", "@playwright/test": "^1.50.0",
"socket.io-client": "^4.7.0",
"@repo/core-eslint": "workspace:*", "@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*", "@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*", "@repo/core-typescript": "workspace:*",

View File

@@ -0,0 +1,97 @@
// e2e proof-of-life: connect → subscribe → emit ping → receive pong via the
// production-shaped binder + Socket.IO server, with cookie-session auth
// against a seeded test session.
import "reflect-metadata";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createServer, type Server as HttpServer } from "node:http";
import { Server as IOServer } from "socket.io";
import { io as ioClient } from "socket.io-client";
import type { AddressInfo } from "node:net";
import {
RealtimeHandlerRegistry,
SocketIORealtimeBroadcaster,
SocketIORealtimeServer,
realtimePingInboundDescriptor,
realtimePongChannel,
type IRealtimeAuthenticator,
} from "@repo/core-realtime";
describe("e2e: realtime-ping exercises all four checkpoints", () => {
let httpServer: HttpServer;
let realtimeServer: SocketIORealtimeServer;
let port: number;
beforeEach(async () => {
httpServer = createServer();
const io = new IOServer(httpServer);
const broadcaster = new SocketIORealtimeBroadcaster(io);
const registry = new RealtimeHandlerRegistry();
registry.register(realtimePingInboundDescriptor(broadcaster));
registry.registerChannel(realtimePongChannel);
const authenticator: IRealtimeAuthenticator = {
authenticate: async ({ cookies }) =>
cookies.session === "valid-session"
? { userId: "user_test", roles: [] }
: null,
};
realtimeServer = new SocketIORealtimeServer({
httpServer,
io,
authenticator,
registry,
});
await realtimeServer.start();
await new Promise<void>((r) => httpServer.listen(0, r));
port = (httpServer.address() as AddressInfo).port;
});
afterEach(async () => {
await realtimeServer.stop();
await new Promise<void>((r) => httpServer.close(() => r()));
});
it("authenticated client gets pong after ping", async () => {
const client = ioClient(`http://localhost:${port}`, {
extraHeaders: { Cookie: "session=valid-session" },
});
await new Promise<void>((r) => client.on("connect", () => r()));
const subAck = await new Promise<{ ok: boolean }>((r) =>
client.emit("subscribe", "realtime.pong", r),
);
expect(subAck.ok).toBe(true);
const pongs: { at: string; echo: string }[] = [];
client.on("realtime.pong", (p) => pongs.push(p));
const sentAt = "2026-05-08T12:00:00.000Z";
const pingAck = await new Promise<{ ok: boolean }>((r) =>
client.emit("realtime.ping", { at: sentAt }, r),
);
expect(pingAck.ok).toBe(true);
// Pong arrives synchronously after handler runs.
await new Promise<void>((r) => setImmediate(r));
expect(pongs).toHaveLength(1);
expect(pongs[0]).toEqual({ at: sentAt, echo: "user_test" });
client.disconnect();
});
it("anonymous client cannot subscribe to pong (authenticated scope)", async () => {
const client = ioClient(`http://localhost:${port}`);
await new Promise<void>((r) => client.on("connect", () => r()));
const subAck = await new Promise<{ ok: boolean; error?: string }>((r) =>
client.emit("subscribe", "realtime.pong", r),
);
expect(subAck.ok).toBe(false);
expect(subAck.error).toBe("forbidden");
client.disconnect();
});
});

View File

@@ -1,17 +1,24 @@
import type { z } from "zod"; import type { z } from "zod";
import type { RealtimeChannelDescriptor } from "./realtime-channel";
import type { IInboundDescriptor } from "./realtime-handler.interface"; import type { IInboundDescriptor } from "./realtime-handler.interface";
export interface IRealtimeHandlerRegistry { export interface IRealtimeHandlerRegistry {
register<T>(entry: IInboundDescriptor<string, z.ZodType<T>>): void; register<T>(entry: IInboundDescriptor<string, z.ZodType<T>>): void;
getInboundDescriptor(channelName: string): IInboundDescriptor<string, z.ZodType> | null; getInboundDescriptor(channelName: string): IInboundDescriptor<string, z.ZodType> | null;
list(): IInboundDescriptor<string, z.ZodType>[]; list(): IInboundDescriptor<string, z.ZodType>[];
/** Register an outbound-only channel so Gate 2 can authorize subscriptions to it. */
registerChannel(descriptor: RealtimeChannelDescriptor<string, z.ZodType>): void;
listChannels(): RealtimeChannelDescriptor<string, z.ZodType>[];
} }
export class RealtimeHandlerRegistry implements IRealtimeHandlerRegistry { export class RealtimeHandlerRegistry implements IRealtimeHandlerRegistry {
private readonly entries = new Map<string, IInboundDescriptor<string, z.ZodType>>(); private readonly entries = new Map<string, IInboundDescriptor<string, z.ZodType>>();
private readonly channels = new Map<string, RealtimeChannelDescriptor<string, z.ZodType>>();
register<T>(entry: IInboundDescriptor<string, z.ZodType<T>>): void { register<T>(entry: IInboundDescriptor<string, z.ZodType<T>>): void {
this.entries.set(entry.descriptor.name, entry as IInboundDescriptor<string, z.ZodType>); this.entries.set(entry.descriptor.name, entry as IInboundDescriptor<string, z.ZodType>);
// Also add the descriptor to the channel map so Gate 2 can authorize subscriptions.
this.channels.set(entry.descriptor.name, entry.descriptor);
} }
getInboundDescriptor(channelName: string): IInboundDescriptor<string, z.ZodType> | null { getInboundDescriptor(channelName: string): IInboundDescriptor<string, z.ZodType> | null {
@@ -21,4 +28,12 @@ export class RealtimeHandlerRegistry implements IRealtimeHandlerRegistry {
list(): IInboundDescriptor<string, z.ZodType>[] { list(): IInboundDescriptor<string, z.ZodType>[] {
return Array.from(this.entries.values()); return Array.from(this.entries.values());
} }
registerChannel(descriptor: RealtimeChannelDescriptor<string, z.ZodType>): void {
this.channels.set(descriptor.name, descriptor);
}
listChannels(): RealtimeChannelDescriptor<string, z.ZodType>[] {
return Array.from(this.channels.values());
}
} }

View File

@@ -55,11 +55,12 @@ export class SocketIORealtimeServer implements IRealtimeServer {
// Gate 2: subscribe. // Gate 2: subscribe.
socket.on("subscribe", async (requestedName: string, ack?: (r: unknown) => void) => { socket.on("subscribe", async (requestedName: string, ack?: (r: unknown) => void) => {
// Find a registered descriptor whose name (or template) matches requestedName. // Find a registered descriptor whose name (or template) matches requestedName.
// listChannels() covers both inbound descriptors and outbound-only channels.
let matched: { descriptor: { name: string; scope: unknown }; params: Record<string, string> } | null = null; let matched: { descriptor: { name: string; scope: unknown }; params: Record<string, string> } | null = null;
for (const entry of registry.list()) { for (const descriptor of registry.listChannels()) {
const m = matchChannelTemplate(entry.descriptor.name, requestedName); const m = matchChannelTemplate(descriptor.name, requestedName);
if (m) { if (m) {
matched = { descriptor: entry.descriptor, params: m.params }; matched = { descriptor, params: m.params };
break; break;
} }
} }

3
pnpm-lock.yaml generated
View File

@@ -245,6 +245,9 @@ importers:
jsdom: jsdom:
specifier: ^25.0.0 specifier: ^25.0.0
version: 25.0.1 version: 25.0.1
socket.io-client:
specifier: ^4.7.0
version: 4.8.3
tsx: tsx:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.21.0 version: 4.21.0