import { describe, expect, it } from "vitest"; import type { ConnectorConnection, NormalizedPost } from "@tactical-media-broadcast-hub/core"; import { connectorContractTests, videoConnectorContractTests, } from "@tactical-media-broadcast-hub/connector-sdk/testing"; import { createThreadsConnector, type ThreadsConnectorOptions } from "./index.js"; interface StubContainer { id: string; params: Record; } interface StubCall { method: string; url: URL; body: URLSearchParams; authorization: string | null; } interface StubState { calls: StubCall[]; containers: Map; nextContainer: number; nextPublication: number; } const TOKEN = "THQVJstubbed-long-lived-token"; const THREADS_USER_ID = "10001234567890"; const connection: ConnectorConnection = { id: "conn-threads", workspaceId: "ws-1", provider: "threads", channelId: "threads-channel", credentialId: { kind: "credential-ref", id: "cred-threads" }, fields: { accessToken: TOKEN, threadsUserId: THREADS_USER_ID }, createdAt: new Date(), }; function state(): StubState { return { calls: [], containers: new Map(), nextContainer: 1, nextPublication: 1, }; } function json(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" }, }); } function graphError(message: string, code: number, status = 400): Response { return json({ error: { message, code } }, status); } function stubGraph(stub: StubState): typeof fetch { return async (input: Parameters[0], init?: RequestInit): Promise => { const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const url = new URL(href); const method = init?.method ?? "GET"; const body = new URLSearchParams(typeof init?.body === "string" ? init.body : ""); const authorization = new Headers(init?.headers).get("authorization"); stub.calls.push({ method, url, body, authorization }); if (url.pathname === "/refresh_access_token") { return json({ access_token: "THQVJrefreshed-token", token_type: "bearer", expires_in: 5_184_000, }); } if (authorization !== `Bearer ${TOKEN}` && authorization !== "Bearer THQVJrefreshed-token") { return graphError("Invalid OAuth access token.", 190, 401); } const parts = url.pathname.split("/").filter((part) => part.length > 0); if (parts[0] === "v1.0") parts.shift(); const [node, edge] = parts; if (node === "me" && method === "GET") { return json({ id: THREADS_USER_ID, username: "anarchoposter", threads_profile_picture_url: "https://cdn.example.com/avatar.jpg", }); } if (node === THREADS_USER_ID && edge === "threads" && method === "POST") { const id = `container-${stub.nextContainer++}`; stub.containers.set(id, { id, params: Object.fromEntries(body) }); return json({ id }); } if (node === THREADS_USER_ID && edge === "threads_publish" && method === "POST") { const creationId = body.get("creation_id") ?? ""; if (!stub.containers.has(creationId)) return graphError("Unknown container", 100); return json({ id: `thread-${stub.nextPublication++}` }); } if (node === THREADS_USER_ID && edge === "threads" && method === "GET") { return json({ data: [ { id: "thread-feed-1", text: "Own-thread read surface", timestamp: "2026-10-05T12:00:00+0000", permalink: "https://www.threads.net/@tactical-media-broadcast-hub/post/abc", reply_count: 7, }, ], paging: { cursors: { before: "before-1", after: "after-1" }, next: "https://graph.threads.net/v1.0/next", }, }); } if (node === THREADS_USER_ID && edge === "threads_insights" && method === "GET") { return json({ data: [ { name: "views", period: "day", values: [ { value: 10, end_time: "2026-10-01T07:00:00+0000" }, { value: 12, end_time: "2026-10-02T07:00:00+0000" }, ], total_value: { value: 22 }, }, { name: "likes", period: "day", total_value: { value: 5 } }, ], }); } if (node !== undefined && stub.containers.has(node) && edge === undefined && method === "GET") { return json({ id: node, status: "FINISHED" }); } if (node !== undefined && edge === "repost" && method === "POST") { return json({ id: `repost-${node}` }); } if (node !== undefined && edge === "unrepost" && method === "POST") { return json({ success: true }); } if (node !== undefined && edge === undefined && method === "DELETE") { return json({ success: true }); } return graphError(`Unknown path ${url.pathname}`, 100, 404); }; } interface Harness { state: StubState; connector: ReturnType; } function harness(extra: ThreadsConnectorOptions = {}): Harness { const stub = state(); return { state: stub, connector: createThreadsConnector({ fetchImpl: stubGraph(stub), requestsPerSecond: 100_000, pollIntervalMs: 0, maxPollAttempts: 2, sleep: async () => undefined, ...extra, }), }; } function post( text: string, mediaAssetIds: string[] = [], providerMetadata: Record = {}, ): NormalizedPost { return { text, mediaAssetIds, visibility: "public", providerMetadata, }; } const contractHarness = harness(); connectorContractTests(contractHarness.connector, { connection }); videoConnectorContractTests(contractHarness.connector, { connection }); describe("threads connector: manifest and identity", () => { it("declares the OAuth scopes and honest read capabilities", async () => { const { connector } = harness(); const manifest = connector.manifest(); expect(manifest.oauth).toEqual({ kind: "oauth2-redirect", authorizeUrlTemplate: "https://threads.net/oauth/authorize", scopes: [ "threads_basic", "threads_content_publish", "threads_manage_replies", "threads_manage_insights", ], }); expect(manifest.capabilities).toMatchObject({ feed: true, replies: true, comments: false, directMessages: false, webhooks: false, }); const identity = await connector.getIdentity(connection); expect(identity).toEqual({ provider: "threads", remoteId: THREADS_USER_ID, handle: "@anarchoposter", displayName: "anarchoposter", avatarUrl: "https://cdn.example.com/avatar.jpg", }); }); it("rejects a missing token without making a request", async () => { const { connector, state: stub } = harness(); const missing: ConnectorConnection = { ...connection, fields: { threadsUserId: THREADS_USER_ID }, }; await expect(connector.getIdentity(missing)).rejects.toMatchObject({ name: "AuthError" }); expect(stub.calls).toHaveLength(0); }); }); describe("threads connector: two-step publishing", () => { it("creates and publishes a text container", async () => { const { connector, state: stub } = harness(); const result = await connector.publish?.(connection, post("Hello Threads")); expect(result?.remoteId).toBe("thread-1"); expect(result?.providerMetadata).toMatchObject({ creationId: "container-1", kind: "text", dailyPostCap: 250, }); expect(stub.calls.map((call) => [call.method, call.url.pathname])).toEqual([ ["POST", `/v1.0/${THREADS_USER_ID}/threads`], ["POST", `/v1.0/${THREADS_USER_ID}/threads_publish`], ]); expect(stub.calls[0]?.body.get("media_type")).toBe("TEXT"); expect(stub.calls[0]?.body.get("text")).toBe("Hello Threads"); expect(stub.calls[1]?.body.get("creation_id")).toBe("container-1"); }); it("creates carousel children, a parent, then publishes the parent", async () => { const { connector, state: stub } = harness(); const result = await connector.publish?.( connection, post("A carousel", [], { media: [ { url: "https://cdn.example.com/one.jpg", kind: "image" }, { url: "https://cdn.example.com/two.jpg", kind: "image" }, ], }), ); const createCalls = stub.calls.filter((call) => call.url.pathname.endsWith("/threads")); expect(createCalls).toHaveLength(3); expect(createCalls[0]?.body.get("is_carousel_item")).toBe("true"); expect(createCalls[1]?.body.get("is_carousel_item")).toBe("true"); expect(createCalls[2]?.body.get("media_type")).toBe("CAROUSEL"); expect(createCalls[2]?.body.get("children")).toBe("container-1,container-2"); expect(result?.providerMetadata).toMatchObject({ kind: "carousel", children: ["container-1", "container-2"], }); }); it("polls a video container before publishing it", async () => { const { connector, state: stub } = harness(); const result = await connector.publish?.( connection, post("Video", ["https://cdn.example.com/video.mp4"]), ); expect(result?.remoteId).toBe("thread-1"); expect(stub.calls.map((call) => call.method)).toEqual(["POST", "GET", "POST"]); expect(stub.calls[0]?.body.get("media_type")).toBe("VIDEO"); expect(stub.calls[1]?.url.pathname).toBe("/v1.0/container-1"); }); it("enforces the 500-character text limit before the API call", async () => { const { connector, state: stub } = harness(); await expect(connector.publish?.(connection, post("x".repeat(501)))).rejects.toThrow( /500 characters/, ); expect(stub.calls).toHaveLength(0); }); }); describe("threads connector: replies and amplification", () => { it("publishes replies through a TEXT container with reply_to_id", async () => { const { connector, state: stub } = harness(); const result = await connector.reply?.(connection, { onRemoteId: "thread-original", text: "A reply", }); expect(stub.calls[0]?.body.get("reply_to_id")).toBe("thread-original"); expect(result?.providerMetadata).toMatchObject({ kind: "reply", inReplyTo: "thread-original", }); }); it("uses the native repost and unrepost endpoints", async () => { const { connector, state: stub } = harness(); const result = await connector.amplify?.(connection, { kind: "repost", targetRemoteId: "thread-original", }); await connector.unamplify?.(connection, "thread-original"); expect(result?.remoteId).toBe("repost-thread-original"); expect(stub.calls.map((call) => call.url.pathname)).toEqual([ "/v1.0/thread-original/repost", "/v1.0/thread-original/unrepost", ]); }); it("publishes a quote through quote_post_id", async () => { const { connector, state: stub } = harness(); const result = await connector.amplify?.(connection, { kind: "quote", targetRemoteId: "thread-original", commentary: "Worth reading", }); expect(stub.calls[0]?.body.get("quote_post_id")).toBe("thread-original"); expect(stub.calls[0]?.body.get("text")).toBe("Worth reading"); expect(result?.providerMetadata.kind).toBe("quote"); }); }); describe("threads connector: own feed and analytics", () => { it("maps only the connected account's own threads", async () => { const { connector, state: stub } = harness(); await connector.getIdentity(connection); const feed = await connector.getFeed?.(connection, { kind: "home", limit: 25 }); expect(feed).toEqual({ items: [ { id: "thread-feed-1", provider: "threads", remoteId: "thread-feed-1", authorHandle: "@anarchoposter", text: "Own-thread read surface", mediaUrls: [], createdAt: new Date("2026-10-05T12:00:00+0000"), metrics: { replies: 7 }, }, ], nextCursor: "after-1", }); const feedCall = stub.calls.find((call) => call.url.pathname.endsWith("/threads")); expect(feedCall?.url.searchParams.get("fields")).toBe( "id,text,timestamp,permalink,reply_count", ); await expect( connector.getFeed?.(connection, { kind: "search", query: "news", limit: 10 }), ).rejects.toThrow(/own threads/); }); it("maps account insight totals and daily series", async () => { const { connector, state: stub } = harness(); const analytics = await connector.getAnalytics?.(connection, { from: new Date("2026-10-01T00:00:00Z"), to: new Date("2026-10-03T00:00:00Z"), }); expect(analytics?.values).toEqual({ views: 22, likes: 5 }); expect(analytics?.series).toEqual([ { at: new Date("2026-10-01T07:00:00+0000"), values: { views: 10 } }, { at: new Date("2026-10-02T07:00:00+0000"), values: { views: 12 } }, ]); const call = stub.calls[0]; expect(call?.url.pathname).toBe(`/v1.0/${THREADS_USER_ID}/threads_insights`); expect(call?.url.searchParams.get("metric")).toContain("views"); }); });