test(web-next): e2e realtime-ping (4 checkpoints)
This commit is contained in:
@@ -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:*",
|
||||||
|
|||||||
97
apps/web-next/src/__tests__/realtime-ping.test.ts
Normal file
97
apps/web-next/src/__tests__/realtime-ping.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user