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/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
100
apps/scheduler/src/activities/publish.activity.gate.test.ts
Normal file
100
apps/scheduler/src/activities/publish.activity.gate.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
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<Response>): Promise<GateOutcome<Response>> => ({ 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("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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue