T-HUB/packages/connector-threads/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

391 lines
13 KiB
TypeScript

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<string, string>;
}
interface StubCall {
method: string;
url: URL;
body: URLSearchParams;
authorization: string | null;
}
interface StubState {
calls: StubCall[];
containers: Map<string, StubContainer>;
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<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
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<typeof createThreadsConnector>;
}
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<string, unknown> = {},
): 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");
});
});