T-HUB/apps/scheduler/src/publishing.integration.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

80 lines
7.7 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { Pool } from "pg";
import { ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk";
import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon";
import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram";
import { Vault, createPublishingCredentialAccessor } from "@tactical-media-broadcast-hub/connector-runtime";
import { PublishingExecutionRepository, canonicalPostHash, assertPublishingSchema, type AttemptRef } from "@tactical-media-broadcast-hub/publishing-execution";
import { PublishActivity } from "./activities/publish.activity.js";
const appUrl = process.env.PUBLISHING_IT_DATABASE_URL;
const ownerUrl = process.env.PUBLISHING_IT_SUPER_DATABASE_URL;
describe.skipIf(!appUrl || !ownerUrl)("TEST MODE real connectors with fenced DB activity", () => {
let pool: Pool; let owner: Pool; let repository: PublishingExecutionRepository; let workspaceId: string; let userId: string;
beforeAll(async () => {
pool = new Pool({ connectionString: appUrl, max: 12 }); owner = new Pool({ connectionString: ownerUrl });
await assertPublishingSchema(pool); repository = new PublishingExecutionRepository(pool);
workspaceId = String((await owner.query("INSERT INTO workspaces(name,slug) VALUES('Scheduler Test',$1) RETURNING id", [`scheduler-sol-${Date.now()}`])).rows[0].id);
userId = String((await owner.query("INSERT INTO users(external_id,username,display_name) VALUES($1,$1,'Scheduler Test') RETURNING id", [`scheduler-sol-${Date.now()}`])).rows[0].id);
await owner.query("INSERT INTO memberships(workspace_id,user_id,role,status) VALUES($1,$2,'Owner','active')", [workspaceId, userId]);
});
afterAll(async () => {
if (workspaceId) await owner.query("DELETE FROM workspaces WHERE id=$1", [workspaceId]);
if (userId) await owner.query("DELETE FROM users WHERE id=$1", [userId]);
await pool?.end(); await owner?.end();
});
async function fixture(provider: "mastodon" | "telegram"): Promise<AttemptRef> {
const fields = provider === "mastodon" ? { instanceUrl: "https://social.test" } : { channelId: "-10042" };
const connectionId = String((await owner.query("INSERT INTO connections(workspace_id,provider,channel_id,fields) VALUES($1,$2,$3,$4) RETURNING id", [workspaceId, provider, `channel-${provider}`, fields])).rows[0].id);
const sealed = await new Vault().seal(workspaceId, connectionId, Buffer.from(JSON.stringify(provider === "mastodon" ? { access_token: "canary-mastodon-secret" } : { botToken: "canary-telegram-secret" })));
await owner.query("INSERT INTO credentials(workspace_id,connection_id,kind,ciphertext,key_version) VALUES($1,$2,'connector-oauth',$3,$4)", [workspaceId, connectionId, sealed.ciphertext, sealed.keyVersion]);
const post = { text: "[TMBH TEST MODE] hello", mediaAssetIds: [], visibility: "public", providerMetadata: { connectionId } };
const draftId = String((await owner.query("INSERT INTO drafts(workspace_id,author_user_id,post,state,reviewer_user_id,approved_revision,approved_hash,approved_at) VALUES($1,$2,$3,'approved',$2,1,$4,now()) RETURNING id", [workspaceId, userId, post, canonicalPostHash(post)])).rows[0].id);
const client = await pool.connect();
try {
await client.query("BEGIN"); await client.query("SELECT set_config('app.workspace_id',$1,true)", [workspaceId]);
const attempt = await repository.scheduleAttempt(client, { workspaceId, draftId, connectionId, scheduledByUserId: userId, requiredPermission: "publication.publish", publishAt: new Date(Date.now() - 1000) });
await client.query("COMMIT"); return { workspaceId, scheduleId: attempt.scheduleId, generation: attempt.generation, idempotencyKey: attempt.idempotencyKey };
} catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); }
}
for (const provider of ["mastodon", "telegram"] as const) it(`publishes ${provider} through exact APV1 credentials, gate, receipt and one canonical Publication`, async () => {
const ref = await fixture(provider); let sends = 0; let observedKey: string | null = null;
const fetchImpl: typeof fetch = async (input, init) => {
const url = new URL(String(input));
if (url.pathname.endsWith("/getChat")) return new Response(JSON.stringify({ ok: true, result: { id: -10042, type: "channel" } }), { headers: { "content-type": "application/json" } });
sends += 1;
expect(init?.method).toBe("POST");
if (provider === "mastodon") {
expect(url.pathname).toBe("/api/v1/statuses"); expect(new Headers(init?.headers).get("authorization")).toBe("Bearer canary-mastodon-secret");
observedKey = new Headers(init?.headers).get("idempotency-key");
return new Response(JSON.stringify({ id: "10942", created_at: new Date().toISOString(), url: "https://social.test/@test/10942", visibility: "public" }), { headers: { "content-type": "application/json" } });
}
expect(url.pathname).toBe("/botcanary-telegram-secret/sendMessage");
expect(JSON.parse(String(init?.body))).toMatchObject({ chat_id: "-10042" });
return new Response(JSON.stringify({ ok: true, result: { message_id: 42, date: Math.floor(Date.now()/1000), chat: { id: -10042 } } }), { headers: { "content-type": "application/json" } });
};
const registry = new ConnectorRegistry(); const options = { credentials: createPublishingCredentialAccessor(pool), fetchImpl, requestsPerSecond: 1000 };
registry.register(createMastodonConnector(options)); registry.register(createTelegramConnector(options));
const activity = new PublishActivity(repository, registry, () => ({ heartbeat: () => undefined }));
const first = await activity.publishScheduled(ref); const second = await activity.publishScheduled(ref);
expect(first.kind).toBe("done"); expect(second).toEqual(first); expect(sends).toBe(1);
if (provider === "mastodon") expect(observedKey).toBe(ref.idempotencyKey);
const evidence = await owner.query("SELECT p.provider_metadata, a.dispatch_count, (SELECT count(*)::int FROM publications WHERE idempotency_key=$1) AS publications, (SELECT count(*)::int FROM publishing_receipts WHERE attempt_id=a.id) AS receipts FROM publishing_attempts a JOIN publications p ON p.idempotency_key=a.idempotency_key WHERE a.idempotency_key=$1", [ref.idempotencyKey]);
expect(evidence.rows[0]).toMatchObject({ dispatch_count: 1, publications: 1, receipts: 1 });
expect(JSON.stringify({ first, second, evidence: evidence.rows })).not.toContain("canary-");
});
it("retains accepted-response-loss as unknown with no Telegram resend", async () => {
const ref = await fixture("telegram"); let sends = 0;
const fetchImpl: typeof fetch = async (input) => {
const url = new URL(String(input));
if (url.pathname.endsWith("/getChat")) return new Response(JSON.stringify({ ok: true, result: { id: -10042, type: "channel" } }));
expect(url.pathname).toBe("/botcanary-telegram-secret/sendMessage"); sends += 1;
throw new Error("TEST_RESPONSE_LOST_AFTER_ACCEPT");
};
const registry = new ConnectorRegistry(); registry.register(createTelegramConnector({ credentials: createPublishingCredentialAccessor(pool), fetchImpl, requestsPerSecond: 1000 }));
const activity = new PublishActivity(repository, registry, () => ({ heartbeat: () => undefined }));
await expect(activity.publishScheduled(ref)).resolves.toMatchObject({ kind: "unknown", code: "PUBLISH_OUTCOME_UNKNOWN" });
await expect(activity.publishScheduled(ref)).resolves.toMatchObject({ kind: "unknown" });
expect(sends).toBe(1);
expect((await owner.query("SELECT count(*)::int AS n FROM publications WHERE idempotency_key=$1", [ref.idempotencyKey])).rows[0]?.n).toBe(0);
});
});