Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
108 lines
6.9 KiB
TypeScript
108 lines
6.9 KiB
TypeScript
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<string, unknown>;
|
|
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<string, unknown>;
|
|
const record = value as Record<string, unknown>;
|
|
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);
|
|
});
|
|
});
|