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[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 { return JSON.parse(String(init?.body ?? "{}")) as Record; } function stubHelix(state: StubState): typeof fetch { return async (input: Parameters[0], init?: RequestInit): Promise => { 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; } 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, secret = WEBHOOK_SECRET, ): { headers: Record; 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); }); });