84 lines
3.3 KiB
TypeScript
84 lines
3.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
MODULE_REALTIME_CHANNEL_MAX_LENGTH,
|
|
MODULE_REALTIME_ERROR_CODES,
|
|
MODULE_REALTIME_MAX_CHANNELS_PER_MODULE,
|
|
MODULE_REALTIME_MAX_PAYLOAD_BYTES,
|
|
MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL,
|
|
ModuleRealtimeError,
|
|
type ModuleRealtimePublisher,
|
|
parseModuleRealtimeChannel,
|
|
parseModuleRealtimeChannelDefinition,
|
|
safeParseModuleRealtimeChannel,
|
|
safeParseModuleRealtimeChannelDefinition,
|
|
} from "../src/index.js";
|
|
|
|
describe("module realtime contracts", () => {
|
|
it("accepts an ordinary named state interface without an index signature or cast", () => {
|
|
interface SlideshowState {
|
|
readonly currentSlide: number;
|
|
readonly mode: "on" | "off";
|
|
readonly slides: readonly string[];
|
|
}
|
|
const state: SlideshowState = { currentSlide: 1, mode: "on", slides: ["/welcome.png"] };
|
|
const received: unknown[] = [];
|
|
const publisher: ModuleRealtimePublisher = {
|
|
publish: (_channel, payload) => received.push(payload),
|
|
};
|
|
|
|
publisher.publish("state", state);
|
|
expect(received).toEqual([state]);
|
|
});
|
|
|
|
it("accepts flat local kebab-case channels and rejects hierarchy and wildcards", () => {
|
|
expect(parseModuleRealtimeChannel("state")).toBe("state");
|
|
expect(parseModuleRealtimeChannel("slide-state")).toBe("slide-state");
|
|
for (const invalid of ["", "State", "slide_state", "slide.state", "slide/state", "*", "a.*"]) {
|
|
expect(safeParseModuleRealtimeChannel(invalid).success).toBe(false);
|
|
}
|
|
expect(
|
|
safeParseModuleRealtimeChannel("a".repeat(MODULE_REALTIME_CHANNEL_MAX_LENGTH)).success,
|
|
).toBe(true);
|
|
expect(
|
|
safeParseModuleRealtimeChannel("a".repeat(MODULE_REALTIME_CHANNEL_MAX_LENGTH + 1)).success,
|
|
).toBe(false);
|
|
});
|
|
|
|
it("parses only the exact channel and permission definition shape", () => {
|
|
expect(
|
|
parseModuleRealtimeChannelDefinition({
|
|
channel: "state",
|
|
permission: "announcements.slideshow.view",
|
|
}),
|
|
).toEqual({ channel: "state", permission: "announcements.slideshow.view" });
|
|
expect(
|
|
safeParseModuleRealtimeChannelDefinition({
|
|
channel: "state",
|
|
permission: "announcements.slideshow.view",
|
|
topic: "legacy",
|
|
}),
|
|
).toEqual({ success: false, code: "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID" });
|
|
expect(safeParseModuleRealtimeChannelDefinition({ channel: "state", permission: "*" })).toEqual(
|
|
{ success: false, code: "MODULE_REALTIME_PERMISSION_INVALID" },
|
|
);
|
|
});
|
|
|
|
it("publishes finite platform limits as explicit contract constants", () => {
|
|
expect(MODULE_REALTIME_MAX_CHANNELS_PER_MODULE).toBe(16);
|
|
expect(MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL).toBe(100);
|
|
expect(MODULE_REALTIME_MAX_PAYLOAD_BYTES).toBe(65_536);
|
|
});
|
|
|
|
it("uses a closed error-code set with fixed sanitized messages and stacks", () => {
|
|
expect(MODULE_REALTIME_ERROR_CODES).toContain("REALTIME_PAYLOAD_INVALID");
|
|
const error = new ModuleRealtimeError("REALTIME_PAYLOAD_INVALID");
|
|
expect(error).toMatchObject({
|
|
code: "REALTIME_PAYLOAD_INVALID",
|
|
message: "The realtime payload is not JSON-compatible.",
|
|
name: "ModuleRealtimeError",
|
|
});
|
|
expect(error.stack).toBe("ModuleRealtimeError: The realtime payload is not JSON-compatible.");
|
|
expect(Object.isFrozen(error)).toBe(true);
|
|
});
|
|
});
|