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 { 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); }); });