T-HUB/packages/connector-runtime/src/publish.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

355 lines
15 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { ConnectorConnection, NormalizedPost, Publication } from "@tactical-media-broadcast-hub/core";
import {
AuthError,
ConnectorError,
ProviderRejectedError,
RateLimitedHttpClient,
type PlatformConnector,
} from "@tactical-media-broadcast-hub/connector-sdk";
import {
DispatchFenceError,
ExecutionMetadataError,
MASTODON_DEDUPE_WINDOW_MS,
preparePostForDispatch,
publishingIdempotencyKey,
publishingPolicyFor,
publishWithPolicy,
stripExecutionMetadata,
} from "./index.js";
const KEY = "wp3:ws-1:draft-1:a1:publish";
const ORIGIN = "https://provider.example";
/** Records sends independently of the responses the connector sees. */
interface Wire {
posts: { key: string | null; body: Record<string, unknown> }[];
gets: number;
}
type Reply = "ok" | "scheduled" | "500" | "429" | "401" | "422" | "drop";
/**
* Minimal connector over the real SDK HTTP client: an identity GET, then the
* publish POST. The scripted reply for each POST is consumed in order.
*/
function fakeConnector(replies: Reply[], wire: Wire): PlatformConnector {
const client = new RateLimitedHttpClient({
requestsPerSecond: 1000,
sleep: async () => Promise.resolve(),
fetchImpl: (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
const method = init?.method ?? "GET";
if (method === "GET") {
wire.gets += 1;
return new Response("{}");
}
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
wire.posts.push({ key: new Headers(init?.headers).get("idempotency-key"), body });
const reply = replies.shift() ?? "ok";
switch (reply) {
case "ok":
return Response.json({ id: `r${wire.posts.length}`, created_at: "2026-10-09T12:00:00Z" });
case "scheduled":
return Response.json({ id: "s9", scheduled_at: "2026-10-09T15:00:00.000Z" });
case "500":
return new Response("", { status: 500 });
case "429":
return new Response("", { status: 429, headers: { "retry-after": "12" } });
case "401":
return new Response("", { status: 401 });
case "422":
return new Response("", { status: 422 });
case "drop":
throw new TypeError("socket hang up");
}
void input;
throw new Error("unreachable");
}) as typeof fetch,
});
return {
manifest: () => {
throw new Error("not needed");
},
authorize: async () => {
throw new Error("not needed");
},
getIdentity: async () => {
throw new Error("not needed");
},
async publish(connection: ConnectorConnection, post: NormalizedPost): Promise<Publication> {
if (post.text === "too-long") throw new ConnectorError("text over limit", connection.provider);
await client.request(connection.provider, `${ORIGIN}/me`);
const key = post.providerMetadata["idempotencyKey"];
const response = await client.request(connection.provider, `${ORIGIN}/statuses`, {
method: "POST",
headers: typeof key === "string" ? { "idempotency-key": key } : {},
body: JSON.stringify({ status: post.text, scheduled_at: post.scheduledAt?.toISOString() }),
});
if (response.status === 401) throw new AuthError("revoked", connection.provider, false);
if (!response.ok) throw new ProviderRejectedError("refused", connection.provider, response.status);
const data = (await response.json()) as { id: string; created_at?: string; scheduled_at?: string };
const scheduled = data.scheduled_at !== undefined;
return {
id: "local-placeholder",
workspaceId: connection.workspaceId,
draftId: "direct",
connectionId: connection.id,
remoteId: scheduled ? `scheduled:${data.id}` : data.id,
publishedAt: new Date(data.scheduled_at ?? data.created_at ?? 0),
providerMetadata: scheduled
? { outcome: "provider_scheduled", scheduledAt: data.scheduled_at }
: { outcome: "published" },
};
},
};
}
function connectionFor(provider: string): ConnectorConnection {
return {
id: `conn-${provider}`,
workspaceId: "ws-1",
provider,
channelId: "c",
credentialId: { kind: "credential-ref", id: "cred" },
fields: {},
createdAt: new Date(),
};
}
const post = (text = "hello", extra: Partial<NormalizedPost> = {}): NormalizedPost => ({
text,
mediaAssetIds: [],
visibility: "public",
providerMetadata: { connectionId: "conn-x", draftId: "user-draft", idempotencyKey: "user-key" },
...extra,
});
function setup(provider: string, replies: Reply[]): { wire: Wire; connector: PlatformConnector; connection: ConnectorConnection } {
const wire: Wire = { posts: [], gets: 0 };
return { wire, connector: fakeConnector(replies, wire), connection: connectionFor(provider) };
}
describe("idempotency key + dispatch preparation", () => {
it("formats the per-attempt key and validates its parts", () => {
expect(publishingIdempotencyKey({ workspaceId: "ws-1", draftId: "draft-1", attemptNo: 1 })).toBe(KEY);
expect(() => publishingIdempotencyKey({ workspaceId: "ws:1", draftId: "d", attemptNo: 1 })).toThrow(RangeError);
expect(() => publishingIdempotencyKey({ workspaceId: "ws", draftId: "d", attemptNo: 0 })).toThrow(RangeError);
});
it("injects the key, drops author execution keys and strips scheduledAt", () => {
const original = post("x", { scheduledAt: new Date("2026-12-01T00:00:00Z") });
const prepared = preparePostForDispatch(original, { idempotencyKey: KEY });
expect(prepared.providerMetadata).toEqual({ connectionId: "conn-x", idempotencyKey: KEY });
expect(prepared.scheduledAt).toBeUndefined();
// Input untouched.
expect(original.providerMetadata["draftId"]).toBe("user-draft");
expect(original.scheduledAt).toBeDefined();
expect(preparePostForDispatch(original, { idempotencyKey: KEY, allowProviderScheduling: true }).scheduledAt).toEqual(
original.scheduledAt,
);
});
it("refuses author-supplied remote media ids", () => {
expect(() => preparePostForDispatch(post("x", { providerMetadata: { mediaIds: ["m1"] } }), { idempotencyKey: KEY })).toThrow(
ExecutionMetadataError,
);
});
it("stripExecutionMetadata removes only runtime-owned keys (approval hash input)", () => {
expect(stripExecutionMetadata(post()).providerMetadata).toEqual({ connectionId: "conn-x" });
});
it("declares token dedupe for Mastodon and none for Telegram/unknown providers", () => {
expect(publishingPolicyFor("mastodon")).toEqual({ idempotency: "token", dedupeWindowMs: MASTODON_DEDUPE_WINDOW_MS });
expect(publishingPolicyFor("telegram")).toEqual({ idempotency: "none" });
expect(publishingPolicyFor("bluesky")).toEqual({ idempotency: "none" });
});
});
describe("publishWithPolicy outcomes", () => {
it("published: receipt carries the injected key, never the local id/draftId", async () => {
const { wire, connector, connection } = setup("telegram", ["ok"]);
const outcome = await publishWithPolicy(connector, connection, post(), { idempotencyKey: KEY });
expect(wire.posts).toHaveLength(1);
expect(wire.posts[0]?.key).toBe(KEY);
expect(wire.posts[0]?.body["scheduled_at"]).toBeUndefined();
expect(outcome).toMatchObject({ kind: "published", receipt: { remoteId: "r1", idempotencyKey: KEY, connectionId: "conn-telegram" } });
expect(outcome.kind === "published" && "id" in outcome.receipt).toBe(false);
expect(outcome.kind === "published" && "draftId" in outcome.receipt).toBe(false);
});
it("provider_scheduled when native scheduling is explicitly allowed", async () => {
const { connector, connection } = setup("mastodon", ["scheduled"]);
const outcome = await publishWithPolicy(connector, connection, post("x", { scheduledAt: new Date("2026-10-09T15:00:00Z") }), {
idempotencyKey: KEY,
allowProviderScheduling: true,
});
expect(outcome).toMatchObject({
kind: "provider_scheduled",
scheduledRemoteId: "scheduled:s9",
scheduledAt: new Date("2026-10-09T15:00:00.000Z"),
});
});
it("429 → retryable RATE_LIMITED with Retry-After, one send", async () => {
const { wire, connector, connection } = setup("telegram", ["429"]);
const outcome = await publishWithPolicy(connector, connection, post(), { idempotencyKey: KEY });
expect(wire.posts).toHaveLength(1);
expect(outcome).toMatchObject({ kind: "retryable", code: "RATE_LIMITED", retryAfterMs: 12_000, resend: "not_delivered", dispatched: true });
});
it("401 → rejected_permanent needing reauthorization; 422 → PROVIDER_REJECTED", async () => {
const auth = setup("telegram", ["401"]);
expect(await publishWithPolicy(auth.connector, auth.connection, post(), { idempotencyKey: KEY })).toMatchObject({
kind: "rejected_permanent",
code: "CREDENTIAL_REJECTED",
needsReauthorization: true,
});
const invalid = setup("telegram", ["422"]);
expect(await publishWithPolicy(invalid.connector, invalid.connection, post(), { idempotencyKey: KEY })).toMatchObject({
kind: "rejected_permanent",
code: "PROVIDER_REJECTED",
status: 422,
needsReauthorization: false,
});
});
it("connector validation before any send → INVALID_POST, not dispatched", async () => {
const { wire, connector, connection } = setup("telegram", []);
const outcome = await publishWithPolicy(connector, connection, post("too-long"), { idempotencyKey: KEY });
expect(outcome).toMatchObject({ kind: "rejected_permanent", code: "INVALID_POST", dispatched: false });
expect(wire.posts).toHaveLength(0);
});
it("author-supplied media ids → INVALID_POST with zero HTTP", async () => {
const { wire, connector, connection } = setup("telegram", []);
const outcome = await publishWithPolicy(connector, connection, post("x", { providerMetadata: { mediaIds: ["1"] } }), {
idempotencyKey: KEY,
});
expect(outcome).toMatchObject({ kind: "rejected_permanent", code: "INVALID_POST" });
expect(wire.gets + wire.posts.length).toBe(0);
});
});
describe("ambiguous sends", () => {
it("no-token provider: 5xx and dropped responses are unknown, never retryable", async () => {
for (const reply of ["500", "drop"] as const) {
const { wire, connector, connection } = setup("telegram", [reply]);
const outcome = await publishWithPolicy(connector, connection, post(), { idempotencyKey: KEY });
expect(outcome).toMatchObject({ kind: "unknown", code: "PUBLISH_OUTCOME_UNKNOWN", dispatched: true });
expect(wire.posts).toHaveLength(1);
}
});
it("token provider on a verified origin inside the window: retryable deduplicated", async () => {
const { connector, connection } = setup("mastodon", ["500"]);
const outcome = await publishWithPolicy(connector, connection, post(), { idempotencyKey: KEY, dedupeVerified: true });
expect(outcome).toMatchObject({ kind: "retryable", code: "PUBLISH_AMBIGUOUS_DEDUPED", resend: "deduplicated", status: 500 });
});
it("token provider on an unverified origin is unknown", async () => {
const { connector, connection } = setup("mastodon", ["500"]);
const outcome = await publishWithPolicy(connector, connection, post(), { idempotencyKey: KEY });
expect(outcome.kind).toBe("unknown");
});
it("token provider past the window (measured from first dispatch) is unknown", async () => {
const { connector, connection } = setup("mastodon", ["drop"]);
const outcome = await publishWithPolicy(connector, connection, post(), {
idempotencyKey: KEY,
dedupeVerified: true,
firstDispatchAt: new Date(Date.now() - MASTODON_DEDUPE_WINDOW_MS - 1000),
});
expect(outcome.kind).toBe("unknown");
});
it("unsettled prior dispatch without dedupe: unknown with zero HTTP", async () => {
const { wire, connector, connection } = setup("telegram", ["ok"]);
const outcome = await publishWithPolicy(connector, connection, post(), {
idempotencyKey: KEY,
unsettledPriorDispatch: true,
firstDispatchAt: new Date(),
dedupeVerified: true,
});
expect(outcome).toMatchObject({ kind: "unknown", dispatched: true });
expect(wire.gets + wire.posts.length).toBe(0);
});
it("unsettled prior dispatch with verified dedupe: replays the same key", async () => {
const { wire, connector, connection } = setup("mastodon", ["ok"]);
const outcome = await publishWithPolicy(connector, connection, post(), {
idempotencyKey: KEY,
unsettledPriorDispatch: true,
firstDispatchAt: new Date(Date.now() - 60_000),
dedupeVerified: true,
});
expect(outcome.kind).toBe("published");
expect(wire.posts.map((p) => p.key)).toEqual([KEY]);
});
it("a definite refusal of a replay does not claim the earlier send was not delivered", async () => {
const { connector, connection } = setup("mastodon", ["429"]);
const outcome = await publishWithPolicy(connector, connection, post(), {
idempotencyKey: KEY,
unsettledPriorDispatch: true,
firstDispatchAt: new Date(),
dedupeVerified: true,
});
expect(outcome).toMatchObject({ kind: "retryable", code: "RATE_LIMITED", resend: "deduplicated" });
});
});
describe("dispatch fence", () => {
it("a failing fence blocks the mutating send; reads are not fenced", async () => {
const { wire, connector, connection } = setup("telegram", ["ok"]);
const error = await publishWithPolicy(connector, connection, post(), {
idempotencyKey: KEY,
beforeMutation: () => {
throw new Error("lease lost");
},
}).catch((e: unknown) => e);
expect(error).toBeInstanceOf(DispatchFenceError);
expect(error).toMatchObject({ dispatched: false });
expect(wire.gets).toBe(1);
expect(wire.posts).toHaveLength(0);
});
it("concurrent executions keep their own fences (AsyncLocalStorage isolation)", async () => {
const blocked = setup("telegram", ["ok"]);
const allowed = setup("telegram", ["ok"]);
const seen: string[] = [];
const [a, b] = await Promise.allSettled([
publishWithPolicy(blocked.connector, blocked.connection, post(), {
idempotencyKey: "wp3:ws-1:a:a1:publish",
beforeMutation: async () => {
await new Promise((r) => setTimeout(r, 5));
seen.push("blocked");
throw new Error("revoked");
},
}),
publishWithPolicy(allowed.connector, allowed.connection, post(), {
idempotencyKey: "wp3:ws-1:b:a1:publish",
beforeMutation: () => void seen.push("allowed"),
}),
]);
expect(a.status).toBe("rejected");
expect(b).toMatchObject({ status: "fulfilled", value: { kind: "published" } });
expect(blocked.wire.posts).toHaveLength(0);
expect(allowed.wire.posts.map((p) => p.key)).toEqual(["wp3:ws-1:b:a1:publish"]);
expect(seen.sort()).toEqual(["allowed", "blocked"]);
});
it("calls outside a runtime execution are unaffected by the hook", async () => {
const { wire, connector, connection } = setup("telegram", ["ok"]);
await publishWithPolicy(connector, connection, post(), {
idempotencyKey: KEY,
beforeMutation: () => {
throw new Error("only inside");
},
}).catch(() => undefined);
const direct = await connector.publish?.(connection, post());
expect(direct?.remoteId).toBe("r1");
expect(wire.posts).toHaveLength(1);
});
});