"""Unit tests for scripts/ci/review_pr.py.""" import http import json from unittest.mock import MagicMock, patch import click import pytest from click.testing import CliRunner from gitea_runner_manager.exceptions import APIError from scripts.ci.review_pr import main, parse_comments class TestParseComments: def test_parse_from_json_file(self, tmp_path) -> None: comments = [{"path": "a.py", "body": "fix", "new_position": 1}] f = tmp_path / "comments.json" f.write_text(json.dumps(comments)) assert parse_comments(str(f), False) == comments def test_parse_from_stdin(self) -> None: comments = [{"path": "a.py", "body": "fix", "new_position": 1}] with patch("scripts.ci.review_pr.sys.stdin") as mock_stdin: mock_stdin.read.return_value = json.dumps(comments) assert parse_comments(None, True) == comments def test_no_comments_returns_empty(self) -> None: assert parse_comments(None, False) == [] def test_invalid_json_file_raises(self, tmp_path) -> None: f = tmp_path / "comments.json" f.write_text("not json{") with pytest.raises(click.ClickException): parse_comments(str(f), False) def test_non_list_json_raises(self, tmp_path) -> None: f = tmp_path / "comments.json" f.write_text(json.dumps({"path": "a.py"})) with pytest.raises(click.ClickException): parse_comments(str(f), False) def test_stdin_non_list_raises(self) -> None: with patch("scripts.ci.review_pr.sys.stdin") as mock_stdin: mock_stdin.read.return_value = json.dumps({"path": "a.py"}) with pytest.raises(click.ClickException): parse_comments(None, True) def test_stdin_invalid_json_raises(self) -> None: with patch("scripts.ci.review_pr.sys.stdin") as mock_stdin: mock_stdin.read.return_value = "not json{" with pytest.raises(click.ClickException): parse_comments(None, True) def test_stdin_empty_returns_empty(self) -> None: with patch("scripts.ci.review_pr.sys.stdin") as mock_stdin: mock_stdin.read.return_value = " " assert parse_comments(None, True) == [] class TestMain: @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch("scripts.ci.review_pr.GiteaClient") def test_successful_comment_review(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.create_review.return_value = {"id": 42} mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke( main, ["5", "owner/repo", "--event", "COMMENT", "--body", "LGTM"], ) assert result.exit_code == 0 assert "Review #42" in result.output mock_client.create_review.assert_called_once_with("5", event="COMMENT", body="LGTM", comments=[]) @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch("scripts.ci.review_pr.GiteaClient") def test_successful_approve_review(self, mock_client_cls: MagicMock) -> None: """APPROVE requires --checklist-confirmed and substantive body.""" mock_client = MagicMock() mock_client.create_review.return_value = {"id": 7} mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke( main, [ "5", "owner/repo", "--event", "APPROVE", "--checklist-confirmed", "--body", "All 10 checklist categories verified. Architecture OK, tests pass.", ], ) assert result.exit_code == 0 assert "Review #7" in result.output mock_client.create_review.assert_called_once_with( "5", event="APPROVE", body="All 10 checklist categories verified. Architecture OK, tests pass.", comments=[], ) @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch("scripts.ci.review_pr.GiteaClient") def test_approve_without_checklist_confirmed_fails(self, mock_client_cls: MagicMock) -> None: """APPROVE without --checklist-confirmed is rejected.""" mock_client = MagicMock() mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke( main, ["5", "owner/repo", "--event", "APPROVE", "--body", "Looks good to me"], ) assert result.exit_code != 0 assert "checklist" in result.output.lower() mock_client.create_review.assert_not_called() @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch("scripts.ci.review_pr.GiteaClient") def test_approve_with_trivial_body_fails(self, mock_client_cls: MagicMock) -> None: """APPROVE with trivial body (< 20 chars) and no comments is rejected.""" mock_client = MagicMock() mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke( main, ["5", "owner/repo", "--event", "APPROVE", "--checklist-confirmed", "--body", "LGTM"], ) assert result.exit_code != 0 assert "substantive" in result.output.lower() mock_client.create_review.assert_not_called() @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch("scripts.ci.review_pr.GiteaClient") def test_successful_with_inline_comments(self, mock_client_cls: MagicMock, tmp_path) -> None: comments = [{"path": "a.py", "body": "fix", "new_position": 1}] f = tmp_path / "comments.json" f.write_text(json.dumps(comments)) mock_client = MagicMock() mock_client.create_review.return_value = {"id": 9} mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke( main, ["5", "owner/repo", "--comments-json", str(f)], ) assert result.exit_code == 0 mock_client.create_review.assert_called_once_with("5", event="COMMENT", body="", comments=comments) @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch("scripts.ci.review_pr.GiteaClient") def test_successful_with_stdin_comments(self, mock_client_cls: MagicMock) -> None: comments = [{"path": "a.py", "body": "fix", "new_position": 1}] mock_client = MagicMock() mock_client.create_review.return_value = {"id": 11} mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke( main, ["5", "owner/repo", "--comments-stdin"], input=json.dumps(comments), ) assert result.exit_code == 0 mock_client.create_review.assert_called_once_with("5", event="COMMENT", body="", comments=comments) @patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) def test_missing_token_exits(self) -> None: runner = CliRunner() result = runner.invoke(main, ["5", "owner/repo", "--body", "x"]) assert result.exit_code == 1 assert "REPO_TOKEN" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch("scripts.ci.review_pr.GiteaClient") def test_no_body_or_comments_for_comment_event(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke(main, ["5", "owner/repo", "--event", "COMMENT"]) assert result.exit_code == 1 assert "required" in result.output mock_client.create_review.assert_not_called() @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch("scripts.ci.review_pr.GiteaClient") def test_no_body_or_comments_for_request_changes(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke(main, ["5", "owner/repo", "--event", "REQUEST_CHANGES"]) assert result.exit_code == 1 assert "required" in result.output mock_client.create_review.assert_not_called() @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch("scripts.ci.review_pr.GiteaClient") def test_api_error_raises_click(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.create_review.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error") mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke(main, ["5", "owner/repo", "--body", "x"]) assert result.exit_code == 1 assert "HTTP" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch("scripts.ci.review_pr.GiteaClient") def test_invalid_event_choice(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke(main, ["5", "owner/repo", "--event", "Bogus"]) assert result.exit_code != 0 mock_client.create_review.assert_not_called()