import { describe, expect, it, vi } from "vitest"; import { RateLimitedHttpClient, type PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk"; import type { AttemptRef, ClaimedJob, ClaimOutcome, GateOutcome, Lease, StoredPublication } from "@tactical-media-broadcast-hub/publishing-execution"; import { PublishActivity, type ActivityRepository } from "./publish.activity.js"; import type { DispatchGateService, E3Outcome } from "./dispatch-gate.js"; /** * E3 integration into the dispatch path (chatgov-design.md ยง4 step 5): the * gate runs before any provider call, settles a blocked destination as a * known no-send, and is skipped (ungated, dispatch as today) when absent. */ const ref: AttemptRef = { workspaceId: "workspace", scheduleId: "schedule", generation: "1", idempotencyKey: "key" }; const job: ClaimedJob = { lease: { ...ref, attemptId: "attempt", draftId: "draft", ownerToken: "owner", claimEpoch: "1", leaseUntil: new Date(Date.now() + 90_000) }, attemptNo: "1", retryCount: 0, post: { text: "hello", mediaAssetIds: [], visibility: "public", providerMetadata: {} }, connection: { id: "connection", provider: "telegram", channelId: "channel", fields: {}, createdAt: new Date() }, credential: { id: "credential-uuid", generation: "3" }, priorDispatch: undefined, }; const publication: StoredPublication = { id: "publication", workspaceId: ref.workspaceId, draftId: "draft", connectionId: "connection", idempotencyKey: "key", remoteId: "remote", publishedAt: new Date(), providerMetadata: {} }; function harness(claim: ClaimOutcome = { kind: "claimed", job }, gate?: DispatchGateService | null) { let stored = false; const repository: ActivityRepository = { claim: vi.fn(async () => stored ? { kind: "done" as const, publication } : claim), heartbeat: vi.fn(async () => true), withDispatchGate: vi.fn(async (_lease: Lease, _policy: { allowRedispatch?: boolean; dedupeWindowMs?: number }, send: () => Promise): Promise> => ({ kind: "sent", value: await send() })), recordReceipt: vi.fn(async () => ({ inserted: true, receipt: { id: "receipt", workspaceId: ref.workspaceId, attemptId: "attempt", remoteId: "remote", receivedAt: new Date(), providerMetadata: {} } })), finalize: vi.fn(async () => { stored = true; return { kind: "done" as const, publication }; }), retryLater: vi.fn(async () => ({ kind: "settled" as const })), retryDeduplicated: vi.fn(async () => ({ kind: "settled" as const })), pauseForReauthorization: vi.fn(async () => ({ kind: "settled" as const })), fail: vi.fn(async () => ({ kind: "settled" as const })), markUnknown: vi.fn(async () => ({ kind: "settled" as const })), }; const fetchImpl = vi.fn(async () => new Response("{}")); const http = new RateLimitedHttpClient({ fetchImpl }); const publish = vi.fn(async () => { await http.request("telegram", "https://api.telegram.org/sendMessage", { method: "POST" }, { retry: false }); return publication; }); const connector = { publish } as unknown as PlatformConnector; const activity = new PublishActivity(repository, { get: () => connector }, () => ({ heartbeat: vi.fn() }), gate); return { activity, repository, publish, fetchImpl }; } function gateReturning(outcome: E3Outcome): DispatchGateService { return { check: vi.fn(async () => outcome), } as unknown as DispatchGateService; } describe("E3 gate in the dispatch path", () => { it("no gate configured: dispatches exactly as today (existing drafts unchanged)", async () => { const h = harness(); await expect(h.activity.publishScheduled(ref)).resolves.toMatchObject({ kind: "done", publicationId: "publication" }); expect(h.publish).toHaveBeenCalledTimes(1); expect(h.repository.fail).not.toHaveBeenCalled(); }); it("gate fires: send proceeds and no policy settlement occurs", async () => { const gate = gateReturning({ outcome: "fired", reasonCodes: [], policyVersion: "pv1", mode: "immediate", approvedHash: "abc", pollState: null }); const h = harness({ kind: "claimed", job }, gate); await expect(h.activity.publishScheduled(ref)).resolves.toMatchObject({ kind: "done" }); expect(h.publish).toHaveBeenCalledTimes(1); expect(h.repository.fail).not.toHaveBeenCalled(); }); it("approval revoked post-approve: blocks with RESET_ON_CHANGE before any send", async () => { const gate = gateReturning({ outcome: "blocked", reasonCodes: ["RESET_ON_CHANGE"], policyVersion: "pv1", mode: "immediate", approvedHash: null, pollState: null }); const h = harness({ kind: "claimed", job }, gate); await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "failed", code: "POLICY_RESET_ON_CHANGE" }); expect(h.fetchImpl).not.toHaveBeenCalled(); expect(h.publish).not.toHaveBeenCalled(); expect(h.repository.fail).toHaveBeenCalledWith(job.lease, "POLICY_RESET_ON_CHANGE"); }); it("no snapshot: fails closed with POLICY_OUTSIDE_POLICY and zero HTTP requests", async () => { const gate = gateReturning({ outcome: "blocked", reasonCodes: ["OUTSIDE_POLICY"], policyVersion: "none", mode: "none", approvedHash: null, pollState: null }); const h = harness({ kind: "claimed", job }, gate); await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "failed", code: "POLICY_OUTSIDE_POLICY" }); expect(h.fetchImpl).not.toHaveBeenCalled(); }); it("stale settle (lease lost) yields wait, never a second send", async () => { const gate = gateReturning({ outcome: "blocked", reasonCodes: ["RESET_ON_CHANGE"], policyVersion: "pv1", mode: "immediate", approvedHash: null, pollState: null }); const h = harness({ kind: "claimed", job }, gate); vi.mocked(h.repository.fail).mockResolvedValue({ kind: "stale" }); const outcome = await h.activity.publishScheduled(ref); expect(outcome).toEqual({ kind: "wait", until: expect.any(String) }); expect(h.publish).not.toHaveBeenCalled(); }); it("publish gate pending (admin poll not passed): known no-send with zero HTTP requests", async () => { const gate = gateReturning({ outcome: "blocked", reasonCodes: ["INSUFFICIENT_QUORUM", "PENDING_POLL"], policyVersion: "pv1", mode: "immediate", approvedHash: "abc", pollState: "open" }); const h = harness({ kind: "claimed", job }, gate); await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "failed", code: "POLICY_INSUFFICIENT_QUORUM" }); expect(h.fetchImpl).not.toHaveBeenCalled(); expect(h.publish).not.toHaveBeenCalled(); }); it("gate evaluated per destination with the lease's idempotency key", async () => { const gate = gateReturning({ outcome: "fired", reasonCodes: [], policyVersion: "pv1", mode: "immediate", approvedHash: "abc", pollState: null }); const h = harness({ kind: "claimed", job }, gate); await h.activity.publishScheduled(ref); expect(gate.check).toHaveBeenCalledWith(expect.objectContaining({ workspaceId: job.lease.workspaceId, draftId: job.lease.draftId, idempotencyKey: job.lease.idempotencyKey, connectionId: job.connection.id, platform: job.connection.provider, })); }); });