"""Unit tests for agent_team.dispatcher — the trusted apply-path transport (§4.3). Fully hermetic: the branch-push and workflow-dispatch side effects are injected fakes, so no git, no ``gh``, and no network are exercised. The tests pin the pure input-assembly + validation contract and the push-before-dispatch order. """ from __future__ import annotations import base64 import pytest from agent_team.dispatcher import ( MAX_DIFF_BYTES, DispatcherError, DispatchInputs, build_dispatch_inputs, dispatch_apply_verify, head_branch_for, ) from agent_team.state_store import compute_content_hash TASK = "0a1b2c3d4e5f6071" DIFF = "diff --git a/README.md b/README.md\n--- a/README.md\n+++ b/README.md\n@@ -1 +1,2 @@\n title\n+added line\n" SCOPE = "README.md\ndocs/**" # --------------------------------------------------------------------------- # # build_dispatch_inputs (pure) # --------------------------------------------------------------------------- # def test_build_dispatch_inputs_binds_hash_and_b64() -> None: di = build_dispatch_inputs(task_id=TASK, diff_text=DIFF, declared_scope=SCOPE) # expected_diff_hash is the plain sha256 the workflow's sha256sum reproduces. assert di.expected_diff_hash == compute_content_hash(DIFF.encode("utf-8")) # diff_b64 round-trips back to the exact diff bytes. assert base64.b64decode(di.diff_b64).decode("utf-8") == DIFF assert di.head_branch == f"agent-team/apply/{TASK}" assert di.diff_artifact_name == f"agent-team-diff-{TASK}" assert di.declared_scope == SCOPE def test_as_inputs_keys_match_the_six_workflow_inputs() -> None: di = build_dispatch_inputs(task_id=TASK, diff_text=DIFF, declared_scope=SCOPE) assert set(di.as_inputs()) == { "task_id", "diff_artifact_name", "expected_diff_hash", "declared_scope", "diff_b64", "head_branch", } @pytest.mark.parametrize("bad_diff", ["", " ", "\n\n"]) def test_empty_diff_rejected(bad_diff: str) -> None: with pytest.raises(DispatcherError): build_dispatch_inputs(task_id=TASK, diff_text=bad_diff, declared_scope=SCOPE) def test_oversized_diff_rejected() -> None: # The diff rides a base64 workflow_dispatch input (GitHub ~64 KB cap); a diff # over MAX_DIFF_BYTES must fail closed in the dispatcher, not be dispatched. big = "diff --git a/x b/x\n" + "+" + ("x" * (MAX_DIFF_BYTES + 1)) + "\n" with pytest.raises(DispatcherError): build_dispatch_inputs(task_id=TASK, diff_text=big, declared_scope=SCOPE) @pytest.mark.parametrize("bad_scope", ["", " "]) def test_empty_scope_rejected(bad_scope: str) -> None: # An empty declared scope would let a diff touch ANY path — fail closed. with pytest.raises(DispatcherError): build_dispatch_inputs(task_id=TASK, diff_text=DIFF, declared_scope=bad_scope) @pytest.mark.parametrize( "bad_task", ["", "has space", "semi;colon", "../escape", "a/b", "x" * 201] ) def test_unsafe_task_id_rejected(bad_task: str) -> None: with pytest.raises(DispatcherError): head_branch_for(bad_task) with pytest.raises(DispatcherError): build_dispatch_inputs(task_id=bad_task, diff_text=DIFF, declared_scope=SCOPE) # --------------------------------------------------------------------------- # # dispatch_apply_verify (injected seams) # --------------------------------------------------------------------------- # class _Recorder: def __init__(self) -> None: self.calls: list[dict] = [] def test_dispatch_pushes_then_fires_with_correct_inputs() -> None: order: list[str] = [] pushed = _Recorder() fired = _Recorder() def pusher(*, owner, repo, base, head_branch, diff_text): order.append("push") pushed.calls.append( { "owner": owner, "repo": repo, "base": base, "head": head_branch, "diff": diff_text, } ) def dispatcher(*, owner, repo, inputs, ref): order.append("dispatch") fired.calls.append({"owner": owner, "repo": repo, "inputs": inputs, "ref": ref}) di = dispatch_apply_verify( owner="Sea-Haven-Industries", repo="orchestrator", task_id=TASK, diff_text=DIFF, declared_scope=SCOPE, pusher=pusher, dispatcher=dispatcher, ) assert isinstance(di, DispatchInputs) # Branch is pushed BEFORE the workflow is dispatched (the draft-PR step opens # against an already-pushed --head). assert order == ["push", "dispatch"] assert pushed.calls[0]["head"] == f"agent-team/apply/{TASK}" assert pushed.calls[0]["diff"] == DIFF # The dispatch carries all six inputs, including the b64 diff + head branch. inputs = fired.calls[0]["inputs"] assert inputs["head_branch"] == f"agent-team/apply/{TASK}" assert base64.b64decode(inputs["diff_b64"]).decode("utf-8") == DIFF assert inputs["expected_diff_hash"] == compute_content_hash(DIFF.encode("utf-8")) assert fired.calls[0]["ref"] == "main" def test_dispatch_does_not_fire_if_push_fails() -> None: fired = _Recorder() def failing_pusher(**_kw): raise RuntimeError("push failed") def dispatcher(**kw): fired.calls.append(kw) with pytest.raises(RuntimeError): dispatch_apply_verify( owner="o", repo="r", task_id=TASK, diff_text=DIFF, declared_scope=SCOPE, pusher=failing_pusher, dispatcher=dispatcher, ) # A failed push must NOT dispatch a run (no orphan run against a missing head). assert fired.calls == [] @pytest.mark.parametrize( "owner,repo", [("", "r"), ("o", ""), ("bad owner", "r"), ("o", "r/x")] ) def test_unsafe_owner_repo_rejected(owner: str, repo: str) -> None: with pytest.raises(DispatcherError): dispatch_apply_verify( owner=owner, repo=repo, task_id=TASK, diff_text=DIFF, declared_scope=SCOPE, pusher=lambda **_k: None, dispatcher=lambda **_k: None, )