test_org_node_trigger.py 11 KB
"""U-005 tests: c_org_node_trigger's subprocess wrapper, mocked at the
subprocess boundary (CLAUDE.md Testing constraint). Real end-to-end coverage
is in src/test_agent_e2e.py (RUN_REAL_AGENT_E2E=1).

Run: python3 -m unittest src.test_org_node_trigger -v
"""

import json
import subprocess
import unittest
from unittest.mock import patch

from src import org_node_trigger


def _completed_process(returncode=0, stdout_obj=None, stderr=""):
    stdout = json.dumps(stdout_obj) if stdout_obj is not None else ""
    return subprocess.CompletedProcess(args=["claude"], returncode=returncode, stdout=stdout, stderr=stderr)


class TriggerOrgNodeTest(unittest.TestCase):
    def test_rejects_unknown_org_node_without_shelling_out(self):
        with patch("src.org_node_trigger.subprocess.run") as run:
            with self.assertRaises(ValueError):
                org_node_trigger.trigger_org_node("not-a-real-node", "hi", "/tmp/some-project")
            run.assert_not_called()

    def test_happy_path_returns_result_text(self):
        with patch("src.org_node_trigger.subprocess.run") as run:
            run.return_value = _completed_process(stdout_obj={"is_error": False, "result": "hello from planner"})
            answer = org_node_trigger.trigger_org_node("planner", "say hi", "/tmp/some-project")
        self.assertEqual(answer, "hello from planner")

    def test_invokes_claude_with_expected_args_and_cwd(self):
        with patch("src.org_node_trigger.subprocess.run") as run:
            run.return_value = _completed_process(stdout_obj={"is_error": False, "result": "ok"})
            org_node_trigger.trigger_org_node("coder", "do the thing", "/tmp/some-project")

        args, kwargs = run.call_args
        argv = args[0]
        self.assertEqual(argv[:3], ["claude", "--agent", "coder"])
        self.assertIn("-p", argv)
        self.assertIn("do the thing", argv)
        self.assertIn("--permission-mode", argv)
        self.assertIn("auto", argv)
        self.assertNotIn("--bare", argv)
        self.assertNotIn("--dangerously-skip-permissions", argv)
        self.assertNotIn("bypassPermissions", argv)
        self.assertEqual(kwargs["cwd"], "/tmp/some-project")
        self.assertEqual(kwargs["stdin"], subprocess.DEVNULL)

    def test_nonzero_exit_raises(self):
        with patch("src.org_node_trigger.subprocess.run") as run:
            run.return_value = _completed_process(returncode=1, stdout_obj=None, stderr="boom")
            with self.assertRaises(org_node_trigger.OrgNodeTriggerError):
                org_node_trigger.trigger_org_node("planner", "hi", "/tmp/some-project")

    def test_is_error_true_raises(self):
        with patch("src.org_node_trigger.subprocess.run") as run:
            run.return_value = _completed_process(stdout_obj={"is_error": True, "result": ""})
            with self.assertRaises(org_node_trigger.OrgNodeTriggerError):
                org_node_trigger.trigger_org_node("validator", "hi", "/tmp/some-project")


_SCHEMA = {"type": "object", "required": ["ok", "issues"], "properties": {
    "ok": {"type": "boolean"}, "issues": {"type": "array"},
}}


def _fenced(payload):
    return "Here you go:\n\n```json\n" + json.dumps(payload) + "\n```\n\nDone."


class TriggerOrgNodeStructuredTest(unittest.TestCase):
    def test_rejects_unknown_org_node_without_shelling_out(self):
        with patch("src.org_node_trigger.subprocess.run") as run:
            with self.assertRaises(ValueError):
                org_node_trigger.trigger_org_node_structured(
                    "not-a-real-node", "hi", "/tmp/some-project", _SCHEMA
                )
            run.assert_not_called()

    def test_extracts_bare_json_result(self):
        payload = {"ok": True, "issues": []}
        with patch("src.org_node_trigger.subprocess.run") as run:
            run.return_value = _completed_process(stdout_obj={"is_error": False, "result": json.dumps(payload)})
            extracted = org_node_trigger.trigger_org_node_structured(
                "validator", "check this", "/tmp/some-project", _SCHEMA
            )
        self.assertEqual(extracted, payload)

    def test_extracts_a_single_fenced_json_block_with_prose_around_it(self):
        payload = {"ok": False, "issues": ["bug 1"]}
        with patch("src.org_node_trigger.subprocess.run") as run:
            run.return_value = _completed_process(stdout_obj={"is_error": False, "result": _fenced(payload)})
            extracted = org_node_trigger.trigger_org_node_structured(
                "validator", "check this", "/tmp/some-project", _SCHEMA
            )
        self.assertEqual(extracted, payload)

    def test_multiple_fenced_blocks_returns_first_matching_one(self):
        unrelated = "```json\n" + json.dumps({"unrelated": True}) + "\n```\n\n"
        payload = {"ok": True, "issues": []}
        text = unrelated + _fenced(payload)
        with patch("src.org_node_trigger.subprocess.run") as run:
            run.return_value = _completed_process(stdout_obj={"is_error": False, "result": text})
            extracted = org_node_trigger.trigger_org_node_structured(
                "validator", "check this", "/tmp/some-project", _SCHEMA
            )
        self.assertEqual(extracted, payload)

    def test_no_matching_json_anywhere_raises_extraction_error(self):
        with patch("src.org_node_trigger.subprocess.run") as run:
            run.return_value = _completed_process(
                stdout_obj={"is_error": False, "result": "sorry, I can't produce that"}
            )
            with self.assertRaises(org_node_trigger.StructuredOutputExtractionError):
                org_node_trigger.trigger_org_node_structured(
                    "validator", "check this", "/tmp/some-project", _SCHEMA
                )

    def test_appends_json_fence_instruction_and_schema_to_prompt(self):
        with patch("src.org_node_trigger.subprocess.run") as run:
            run.return_value = _completed_process(
                stdout_obj={"is_error": False, "result": json.dumps({"ok": True, "issues": []})}
            )
            org_node_trigger.trigger_org_node_structured("validator", "check this", "/tmp/some-project", _SCHEMA)

        argv = run.call_args[0][0]
        prompt_arg = argv[argv.index("-p") + 1]
        self.assertIn("check this", prompt_arg)
        self.assertIn("```json fenced code block", prompt_arg)
        self.assertIn(json.dumps(_SCHEMA), prompt_arg)

    def test_model_override_passed_through_as_cli_flag(self):
        with patch("src.org_node_trigger.subprocess.run") as run:
            run.return_value = _completed_process(
                stdout_obj={"is_error": False, "result": json.dumps({"ok": True, "issues": []})}
            )
            org_node_trigger.trigger_org_node_structured(
                "validator", "check this", "/tmp/some-project", _SCHEMA, model="claude-fable-5",
            )

        argv = run.call_args[0][0]
        self.assertIn("--model", argv)
        self.assertEqual(argv[argv.index("--model") + 1], "claude-fable-5")

    def test_no_model_means_no_model_flag(self):
        with patch("src.org_node_trigger.subprocess.run") as run:
            run.return_value = _completed_process(
                stdout_obj={"is_error": False, "result": json.dumps({"ok": True, "issues": []})}
            )
            org_node_trigger.trigger_org_node_structured("validator", "check this", "/tmp/some-project", _SCHEMA)

        argv = run.call_args[0][0]
        self.assertNotIn("--model", argv)

    def test_nonzero_exit_with_usage_limit_phrase_raises_usage_limit_error_with_type(self):
        with patch("src.org_node_trigger.subprocess.run") as run:
            run.return_value = _completed_process(
                returncode=1, stdout_obj=None, stderr="Claude AI usage limit reached, you've hit your session limit"
            )
            with self.assertRaises(org_node_trigger.UsageLimitReachedError) as ctx:
                org_node_trigger.trigger_org_node_structured(
                    "validator", "check this", "/tmp/some-project", _SCHEMA
                )
        self.assertEqual(ctx.exception.quota_limit_type, "session")

    def test_nonzero_exit_without_usage_limit_phrase_raises_generic_error(self):
        with patch("src.org_node_trigger.subprocess.run") as run:
            run.return_value = _completed_process(returncode=1, stdout_obj=None, stderr="boom")
            with self.assertRaises(org_node_trigger.OrgNodeTriggerError):
                org_node_trigger.trigger_org_node_structured(
                    "validator", "check this", "/tmp/some-project", _SCHEMA
                )

    def test_is_error_with_weekly_limit_phrase_raises_usage_limit_error(self):
        with patch("src.org_node_trigger.subprocess.run") as run:
            run.return_value = _completed_process(
                stdout_obj={"is_error": True, "result": "You've hit your weekly limit"}
            )
            with self.assertRaises(org_node_trigger.UsageLimitReachedError) as ctx:
                org_node_trigger.trigger_org_node_structured(
                    "validator", "check this", "/tmp/some-project", _SCHEMA
                )
        self.assertEqual(ctx.exception.quota_limit_type, "weekly")

    def test_is_error_with_opus_quota_phrase_raises_usage_limit_error(self):
        with patch("src.org_node_trigger.subprocess.run") as run:
            run.return_value = _completed_process(
                stdout_obj={"is_error": True, "result": "You have reached your opus quota limit for this period"}
            )
            with self.assertRaises(org_node_trigger.UsageLimitReachedError) as ctx:
                org_node_trigger.trigger_org_node_structured(
                    "validator", "check this", "/tmp/some-project", _SCHEMA
                )
        self.assertEqual(ctx.exception.quota_limit_type, "opus")

    def test_is_error_without_usage_limit_phrase_raises_generic_error(self):
        with patch("src.org_node_trigger.subprocess.run") as run:
            run.return_value = _completed_process(stdout_obj={"is_error": True, "result": "something else failed"})
            with self.assertRaises(org_node_trigger.OrgNodeTriggerError):
                org_node_trigger.trigger_org_node_structured(
                    "validator", "check this", "/tmp/some-project", _SCHEMA
                )

    def test_invokes_claude_with_expected_cwd_and_stdin(self):
        with patch("src.org_node_trigger.subprocess.run") as run:
            run.return_value = _completed_process(
                stdout_obj={"is_error": False, "result": json.dumps({"ok": True, "issues": []})}
            )
            org_node_trigger.trigger_org_node_structured("coder", "do the thing", "/tmp/some-project", _SCHEMA)

        args, kwargs = run.call_args
        argv = args[0]
        self.assertEqual(argv[:3], ["claude", "--agent", "coder"])
        self.assertIn("--permission-mode", argv)
        self.assertIn("auto", argv)
        self.assertNotIn("--bare", argv)
        self.assertNotIn("--dangerously-skip-permissions", argv)
        self.assertNotIn("bypassPermissions", argv)
        self.assertEqual(kwargs["cwd"], "/tmp/some-project")
        self.assertEqual(kwargs["stdin"], subprocess.DEVNULL)


if __name__ == "__main__":
    unittest.main()