T-HUB/packages/connector-twitch/src/contract.test.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
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/.
2026-10-10 11:56:42 +00:00

415 lines
14 KiB
TypeScript

import { createHmac } from "node:crypto";
import { describe, expect, it } from "vitest";
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
import {
connectorContractTests,
livestreamConnectorContractTests,
} from "@tactical-media-broadcast-hub/connector-sdk/testing";
import { createTwitchConnector, type TwitchConnectorOptions } from "./index.js";
const CLIENT_ID = "client-id-for-tests";
const TOKEN = "oauth-token-for-tests";
const BROADCASTER_ID = "141981764";
const WEBHOOK_SECRET = "eventsub-secret-for-tests";
const connection: ConnectorConnection = {
id: "conn-twitch",
workspaceId: "ws-1",
provider: "twitch",
channelId: BROADCASTER_ID,
credentialId: { kind: "credential-ref", id: "cred-twitch" },
fields: {
clientId: CLIENT_ID,
accessToken: TOKEN,
broadcasterId: BROADCASTER_ID,
},
createdAt: new Date(),
};
interface StubCall {
url: URL;
init: RequestInit | undefined;
}
interface StubState {
calls: StubCall[];
live: boolean;
subscriptions: number;
}
const user = {
id: BROADCASTER_ID,
login: "tmbh_live",
display_name: "TMBH Live",
profile_image_url: "https://static.example/avatar.png",
broadcaster_type: "partner",
};
const stream = {
id: "stream-9001",
user_id: BROADCASTER_ID,
user_login: user.login,
user_name: user.display_name,
type: "live",
title: "Building in public",
viewer_count: 321,
started_at: "2026-10-07T10:00:00Z",
game_name: "Software and Game Development",
thumbnail_url: "https://static.example/live/{width}x{height}.jpg",
};
function json(value: unknown, status = 200): Response {
return new Response(JSON.stringify(value), {
status,
headers: { "content-type": "application/json" },
});
}
function requestUrl(input: Parameters<typeof fetch>[0]): URL {
if (typeof input === "string") return new URL(input);
if (input instanceof URL) return input;
return new URL(input.url);
}
function header(init: RequestInit | undefined, name: string): string | null {
return new Headers(init?.headers).get(name);
}
function body(init: RequestInit | undefined): Record<string, unknown> {
return JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
}
function stubHelix(state: StubState): typeof fetch {
return async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
const url = requestUrl(input);
state.calls.push({ url, init });
expect(header(init, "client-id")).toBe(CLIENT_ID);
expect(header(init, "authorization")).toBe(`Bearer ${TOKEN}`);
if (url.pathname === "/helix/users") return json({ data: [user] });
if (url.pathname === "/helix/streams") {
return json({ data: state.live ? [stream] : [], pagination: {} });
}
if (url.pathname === "/helix/analytics/streams") {
return json({
data: [
{
started_at: "2026-10-06T10:00:00Z",
metrics: { unique_viewers: 80, average_viewers: 25 },
},
{
started_at: "2026-10-07T10:00:00Z",
metrics: { unique_viewers: 100, average_viewers: 30 },
},
],
pagination: {},
});
}
if (url.pathname === "/helix/eventsub/subscriptions" && init?.method === "POST") {
state.subscriptions += 1;
return json({ data: [{ id: `eventsub-${state.subscriptions}`, status: "webhook_callback_verification_pending" }] });
}
return json({ error: "Not Found", status: 404, message: `${init?.method ?? "GET"} ${url.pathname}` }, 404);
};
}
interface Harness {
state: StubState;
connector: ReturnType<typeof createTwitchConnector>;
}
function harness(overrides: TwitchConnectorOptions = {}): Harness {
const state: StubState = { calls: [], live: true, subscriptions: 0 };
return {
state,
connector: createTwitchConnector({
fetchImpl: stubHelix(state),
requestsPerSecond: 1000,
sleep: async () => Promise.resolve(),
webhookPublicUrl: "https://app.example.com/webhooks/twitch",
webhookSecret: WEBHOOK_SECRET,
env: {},
...overrides,
}),
};
}
function signedWebhook(
payload: Record<string, unknown>,
secret = WEBHOOK_SECRET,
): { headers: Record<string, string>; body: string } {
const raw = JSON.stringify(payload);
const messageId = "evt-message-1";
const timestamp = "2026-10-07T10:01:00Z";
const signature = `sha256=${createHmac("sha256", secret)
.update(messageId)
.update(timestamp)
.update(raw)
.digest("hex")}`;
return {
headers: {
"Twitch-Eventsub-Message-Id": messageId,
"Twitch-Eventsub-Message-Timestamp": timestamp,
"Twitch-Eventsub-Message-Signature": signature,
"Twitch-Eventsub-Message-Type": "notification",
},
body: raw,
};
}
connectorContractTests(harness().connector, { connection });
livestreamConnectorContractTests(harness().connector, { connection });
describe("twitch connector: manifest and identity", () => {
it("declares the requested honest capability surface", () => {
const manifest = harness().connector.manifest();
expect(manifest).toMatchObject({
id: "twitch",
displayName: "Twitch",
capabilities: {
publishText: false,
feed: false,
comments: true,
amplifyRepost: false,
amplifyQuote: false,
amplifyShare: false,
livestreams: true,
createLivestream: false,
manageLivestream: false,
livePlaybackSource: true,
liveChatRead: true,
liveChatModeration: true,
analytics: true,
livestreamAnalytics: true,
webhooks: true,
},
});
expect(manifest.connectionFields).toEqual([
expect.objectContaining({ name: "clientId", required: true, secret: false }),
expect.objectContaining({ name: "accessToken", required: true, secret: true }),
expect.objectContaining({ name: "broadcasterId", required: true, secret: false }),
]);
});
it("maps /helix/users and sends both required auth headers", async () => {
const { state, connector } = harness();
await expect(connector.getIdentity(connection)).resolves.toEqual({
provider: "twitch",
remoteId: BROADCASTER_ID,
handle: "tmbh_live",
displayName: "TMBH Live",
avatarUrl: "https://static.example/avatar.png",
});
expect(state.calls[0]?.url.pathname).toBe("/helix/users");
});
});
describe("twitch connector: livestreams", () => {
it("maps a live Helix stream and replaces the thumbnail template", async () => {
const { state, connector } = harness();
const result = await connector.getLiveStreams?.(connection, { limit: 10 });
expect(result).toEqual({
items: [
{
id: stream.id,
provider: "twitch",
connectionId: connection.id,
remoteId: stream.id,
title: stream.title,
state: "live",
startedAt: new Date(stream.started_at),
playbackUrls: ["https://static.example/live/1280x720.jpg"],
},
],
});
const call = state.calls[0];
expect(call?.url.searchParams.get("user_id")).toBe(BROADCASTER_ID);
expect(call?.url.searchParams.get("first")).toBe("10");
});
it("maps empty stream data to offline (an empty page)", async () => {
const { state, connector } = harness();
state.live = false;
await expect(connector.getLiveStreams?.(connection, { limit: 10 })).resolves.toEqual({ items: [] });
});
it("resolves a channel live source by channelId through /helix/streams?user_login=", async () => {
const { state, connector } = harness();
await expect(
connector.resolveLiveSource?.(connection, { channelId: "tmbh_live" }),
).resolves.toEqual({
playbackHlsUrl: "https://usher.ttvnw.net/api/channel/hls/tmbh_live.m3u8",
live: true,
});
const call = state.calls[0];
expect(call?.url.pathname).toBe("/helix/streams");
expect(call?.url.searchParams.get("user_login")).toBe("tmbh_live");
});
it("derives the login from an external URL (path and ?channel=)", async () => {
const { state, connector } = harness();
await expect(
connector.resolveLiveSource?.(connection, { externalUrl: "https://www.twitch.tv/TMBH_Live" }),
).resolves.toMatchObject({ live: true });
expect(state.calls[0]?.url.searchParams.get("user_login")).toBe("tmbh_live");
await expect(
connector.resolveLiveSource?.(connection, { externalUrl: "https://player.example.com/embed?channel=TMBH_Live" }),
).resolves.toMatchObject({ live: true });
});
it("reports offline with a null playback URL and no caller error", async () => {
const offline = harness();
offline.state.live = false;
const { connector } = offline;
await expect(
connector.resolveLiveSource?.(connection, { channelId: "tmbh_live" }),
).resolves.toEqual({ playbackHlsUrl: null, live: false });
});
it("constructs the Twitch HLS source for the requested live stream", async () => {
const { connector } = harness();
await expect(
connector.getStreamPlaybackSources?.(connection, { remoteId: stream.id }),
).resolves.toEqual([
{
remoteId: stream.id,
url: "https://usher.ttvnw.net/api/channel/hls/tmbh_live.m3u8",
kind: "hls",
protocol: "https",
},
]);
await expect(
connector.getStreamPlaybackSources?.(connection, { remoteId: "not-live" }),
).resolves.toEqual([]);
});
});
describe("twitch connector: EventSub chat and webhooks", () => {
it("creates EventSub subscriptions with condition, callback and secret", async () => {
const { state, connector } = harness();
const result = await connector.subscribeWebhooks?.(connection, {
topics: ["stream.online", "channel.chat.message"],
});
expect(result?.subscriptionId).toBe("eventsub-1,eventsub-2");
const calls = state.calls.filter((call) => call.url.pathname === "/helix/eventsub/subscriptions");
expect(calls).toHaveLength(2);
expect(body(calls[0]?.init)).toEqual({
type: "stream.online",
version: "1",
condition: { broadcaster_user_id: BROADCASTER_ID },
transport: {
method: "webhook",
callback: "https://app.example.com/webhooks/twitch",
secret: WEBHOOK_SECRET,
},
});
expect(body(calls[1]?.init)).toMatchObject({
type: "channel.chat.message",
condition: { broadcaster_user_id: BROADCASTER_ID, user_id: BROADCASTER_ID },
});
});
it("verifies HMAC-SHA256, normalizes, and buffers chat for both read surfaces", async () => {
const { connector } = harness();
await connector.getLiveStreams?.(connection, { limit: 10 });
await connector.subscribeWebhooks?.(connection, { topics: ["stream.online"] });
const signed = signedWebhook({
subscription: { id: "sub-chat", type: "channel.chat.message", status: "enabled" },
event: {
broadcaster_user_id: BROADCASTER_ID,
chatter_user_login: "viewer_one",
message_id: "chat-1",
message: { text: "hello chat" },
},
});
const event = await connector.handleWebhook?.(signed);
expect(event).toMatchObject({
provider: "twitch",
eventType: "channel.chat.message",
connectionKey: connection.id,
receivedAt: new Date("2026-10-07T10:01:00Z"),
});
const chat = await connector.getLiveChat?.(connection, { liveRemoteId: stream.id, limit: 10 });
expect(chat?.items[0]).toMatchObject({
id: "chat-1",
authorHandle: "viewer_one",
text: "hello chat",
liveStreamId: stream.id,
isModerated: false,
});
const comments = await connector.getComments?.(connection, { onRemoteId: stream.id, limit: 10 });
expect(comments?.items[0]).toMatchObject({
remoteId: "chat-1",
onRemoteId: stream.id,
authorHandle: "viewer_one",
text: "hello chat",
});
});
it("rejects an invalid EventSub signature and non-raw bodies", async () => {
const { connector } = harness();
await connector.subscribeWebhooks?.(connection, { topics: ["stream.online"] });
const payload = {
subscription: { id: "sub-1", type: "stream.online", status: "enabled" },
event: { broadcaster_user_id: BROADCASTER_ID },
};
await expect(connector.handleWebhook?.(signedWebhook(payload, "wrong-secret"))).rejects.toThrow(
/signature mismatch/,
);
await expect(
connector.handleWebhook?.({ headers: signedWebhook(payload).headers, body: payload }),
).rejects.toThrow(/raw body/);
});
it("documents the IRC requirement and delegates when an adapter is supplied", async () => {
const withoutIrc = harness().connector;
await expect(
withoutIrc.moderateLiveChat?.(connection, {
liveRemoteId: stream.id,
messageRemoteId: "chat-1",
action: "delete",
}),
).rejects.toThrow(/IRC adapter/);
const calls: string[] = [];
const withIrc = harness({
ircModerate: async (_connection, request) => {
calls.push(`${request.action}:${request.messageRemoteId}`);
},
}).connector;
await withIrc.moderateLiveChat?.(connection, {
liveRemoteId: stream.id,
messageRemoteId: "chat-1",
action: "delete",
});
expect(calls).toEqual(["delete:chat-1"]);
});
});
describe("twitch connector: analytics", () => {
it("maps stream analytics totals and chronological series", async () => {
const { state, connector } = harness();
const result = await connector.getAnalytics?.(connection, {
subjectRemoteId: stream.id,
from: new Date("2026-10-06T00:00:00Z"),
to: new Date("2026-10-08T00:00:00Z"),
});
expect(result).toEqual({
values: { unique_viewers: 180, average_viewers: 55 },
series: [
{
at: new Date("2026-10-06T10:00:00Z"),
values: { unique_viewers: 80, average_viewers: 25 },
},
{
at: new Date("2026-10-07T10:00:00Z"),
values: { unique_viewers: 100, average_viewers: 30 },
},
],
});
const call = state.calls[0];
expect(call?.url.pathname).toBe("/helix/analytics/streams");
expect(call?.url.searchParams.get("broadcaster_id")).toBe(BROADCASTER_ID);
expect(call?.url.searchParams.get("stream_id")).toBe(stream.id);
});
});