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/.
391 lines
13 KiB
TypeScript
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");
|
|
});
|
|
});
|