import { describe, expect, it } from "vitest"; import { CHAT_CONTRACT_EXAMPLES, CHAT_CONTRACT_SCHEMAS, CHAT_MESSAGE_SCHEMA, CHAT_HUB_FRAME_SCHEMA, } from "./chat-contract.schema.js"; import { authorColor, isValidChatMessage, type ChatMessage } from "./chat-contract.js"; /** Minimal draft-2020 subset validator — enough to prove schema/type parity * without a dependency. Checks the constraints this contract relies on. */ function validate(schema: unknown, value: unknown, path = "$"): string[] { const errors: string[] = []; const s = schema as Record; if (Array.isArray(s.oneOf) && s.oneOf.filter(branch => validate(branch, value).length === 0).length !== 1) errors.push(`${path}: oneOf`); if (s.if && validate(s.if, value).length === 0 && s.then) errors.push(...validate(s.then, value, path)); if (Array.isArray(value)) { if (typeof s.minItems === "number" && value.length < s.minItems) errors.push(`${path}: minItems`); if (typeof s.maxItems === "number" && value.length > s.maxItems) errors.push(`${path}: maxItems`); } if (s.const !== undefined && JSON.stringify(value) !== JSON.stringify(s.const)) errors.push(`${path}: const mismatch`); if (s.enum !== undefined && !(s.enum as unknown[]).some(v => v === value)) errors.push(`${path}: not in enum`); if (s.type !== undefined) { const t = s.type as string; const ok = (t === "object" && typeof value === "object" && value !== null && !Array.isArray(value)) || (t === "array" && Array.isArray(value)) || (t === "string" && typeof value === "string") || (t === "boolean" && typeof value === "boolean") || (t === "integer" && typeof value === "number" && Number.isInteger(value)); if (!ok) { errors.push(`${path}: expected ${t}`); return errors; } } if (typeof value === "number") { if (s.minimum !== undefined && value < (s.minimum as number)) errors.push(`${path}: below minimum`); if (s.maximum !== undefined && value > (s.maximum as number)) errors.push(`${path}: above maximum`); } if (typeof value === "string") { if (s.minLength !== undefined && value.length < (s.minLength as number)) errors.push(`${path}: too short`); if (s.maxLength !== undefined && value.length > (s.maxLength as number)) errors.push(`${path}: too long`); if (s.pattern !== undefined && !new RegExp(s.pattern as string).test(value)) errors.push(`${path}: pattern`); } if (Array.isArray(value) && s.items !== undefined) value.forEach((v, i) => errors.push(...validate(s.items, v, `${path}[${i}]`))); if (typeof value === "object" && value !== null && !Array.isArray(value)) { const props = (s.properties ?? {}) as Record; const record = value as Record; if (s.required !== undefined) for (const key of s.required as string[]) if (record[key] === undefined) errors.push(`${path}.${key}: required`); if (s.additionalProperties === false) for (const key of Object.keys(record)) if (props[key] === undefined) errors.push(`${path}.${key}: additional property`); for (const [key, sub] of Object.entries(props)) if (record[key] !== undefined) errors.push(...validate(sub, record[key], `${path}.${key}`)); } return errors; } describe("S5 chat contract (frozen §3.5 shapes)", () => { it("freezes pairing and op/liveId WS payloads, including zero resume cursor", () => { expect(validate(CHAT_CONTRACT_SCHEMAS.ws, { op: "subscribe", id: "chat", topic: "livechat", liveId: "live-1", lastSeq: 0 })).toEqual([]); expect(validate(CHAT_CONTRACT_SCHEMAS.ws, { op: "subscribe", id: "chat", topic: "livechat", liveStreamId: "live-1" })).not.toEqual([]); expect(validate(CHAT_CONTRACT_SCHEMAS.devicePairing, { pairingCode: "ABCD-2345", qrPayload: "{}", expiresAt: "2026-10-08T00:00:00Z" })).toEqual([]); expect(validate(CHAT_CONTRACT_SCHEMAS.sendBody, { text: "hi", targets: [] })).not.toEqual([]); expect(validate(CHAT_CONTRACT_SCHEMAS.moderateBody, { action: "timeout" })).not.toEqual([]); expect(validate(CHAT_CONTRACT_SCHEMAS.moderateBody, { action: "timeout", durationSeconds: 600 })).toEqual([]); }); it("the schema examples satisfy the TypeScript types and the schema", () => { const message: ChatMessage = CHAT_CONTRACT_EXAMPLES.chatMessage; expect(isValidChatMessage(message)).toBe(true); expect(validate(CHAT_CONTRACT_SCHEMAS.chatMessage, CHAT_CONTRACT_EXAMPLES.chatMessage)).toEqual([]); expect(validate(CHAT_CONTRACT_SCHEMAS.delivery, CHAT_CONTRACT_EXAMPLES.delivery)).toEqual([]); expect(validate(CHAT_CONTRACT_SCHEMAS.capability, CHAT_CONTRACT_EXAMPLES.capability)).toEqual([]); expect(validate(CHAT_CONTRACT_SCHEMAS.chatHubFrame, { kind: "chat", liveStreamId: "live-1", channelId: "ch-1", seq: 1, message: CHAT_CONTRACT_EXAMPLES.chatMessage, })).toEqual([]); }); it("the message schema rejects unknown fields, bad seq and bad source", () => { const base = CHAT_CONTRACT_EXAMPLES.chatMessage; expect(validate(CHAT_MESSAGE_SCHEMA, { ...base, extra: true })).toEqual([expect.stringContaining("extra")]); expect(validate(CHAT_MESSAGE_SCHEMA, { ...base, seq: 0 })).toEqual([expect.stringContaining("seq")]); expect(validate(CHAT_MESSAGE_SCHEMA, { ...base, source: "rumour" })).toEqual([expect.stringContaining("source")]); expect(validate(CHAT_MESSAGE_SCHEMA, { ...base, author: { handle: "x" } })).toEqual([expect.stringContaining("color")]); }); it("delivery only allows the four statuses; capability states include the paused reasons", () => { expect(validate(CHAT_CONTRACT_SCHEMAS.delivery, { ...CHAT_CONTRACT_EXAMPLES.delivery, status: "flapped" })).not.toEqual([]); for (const state of ["ok", "paused:quota", "paused:rate", "degraded"]) { expect(validate(CHAT_CONTRACT_SCHEMAS.capability, { ...CHAT_CONTRACT_EXAMPLES.capability, state })).toEqual([]); } }); it("a hub frame carries kind chat and a channel-scoped seq", () => { const frame = { kind: "chat", liveStreamId: "live-1", channelId: "ch-1", seq: 3, message: CHAT_CONTRACT_EXAMPLES.chatMessage }; expect(validate(CHAT_HUB_FRAME_SCHEMA, frame)).toEqual([]); expect(validate(CHAT_HUB_FRAME_SCHEMA, { ...frame, kind: "state" })).toEqual([expect.stringContaining("kind")]); }); it("author colour is a stable function of provider:handle, never identity-fused", () => { expect(authorColor("twitch", "viewer")).toBe(authorColor("twitch", "viewer")); expect(authorColor("twitch", "viewer")).not.toBe(authorColor("youtube", "viewer")); expect(authorColor("twitch", "viewer")).toMatch(/^hsl\(\d+ 70% 62%\)$/); expect(authorColor("twitch", "viewer")).toMatch(/^hsl\([0-5]\d |hsl\([1-9]\d\d /); }); it("isValidChatMessage rejects a non-integer seq and a missing author", () => { const base = CHAT_CONTRACT_EXAMPLES.chatMessage; expect(isValidChatMessage({ ...base, seq: 1.5 })).toBe(false); expect(isValidChatMessage({ ...base, author: { handle: "" , color: "hsl(1 70% 62%)" } })).toBe(false); expect(isValidChatMessage({ ...base, author: undefined })).toBe(false); }); });