Per the owner's spec: each social connection gets publishGovernance in its fields — unset = manual publish (today), auto = dispatch on approval, admin1/admin_majority/admin_consensus = a publish-phase chatgov poll (subject draft_publish, its own open-poll slot, no collision with review gates). Dispatch holds until the publish poll passes (E3 pre-dispatch check); auto dispatches on approval. Includes migration 033, the PATCH /connections/:id/publish-governance route (chatgov.manage), and 19+31 new tests. Implementation by the branding-panel process (claude/opus), lint and integration fixes here.
108 lines
7 KiB
TypeScript
108 lines
7 KiB
TypeScript
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("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,
|
|
}));
|
|
});
|
|
});
|