| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| |
| """Tests for bridge.groovy write subcommands. |
| |
| Strategy: spin up a lightweight HTTP mock server that records requests |
| and returns canned JIRA API responses, then invoke bridge.groovy via |
| subprocess against it. Requires ``groovy`` on PATH. |
| """ |
| |
| from __future__ import annotations |
| |
| import json |
| import os |
| import shutil |
| import subprocess |
| import threading |
| from http.server import BaseHTTPRequestHandler, HTTPServer |
| from pathlib import Path |
| from typing import Any |
| |
| import pytest |
| |
| BRIDGE = Path(__file__).parent.parent / "bridge.groovy" |
| GROOVY = shutil.which("groovy") |
| |
| pytestmark = pytest.mark.skipif(GROOVY is None, reason="groovy not found on PATH") |
| |
| |
| # --------------------------------------------------------------------------- |
| # Mock JIRA server |
| # --------------------------------------------------------------------------- |
| |
| |
| class _RequestLog: |
| def __init__(self) -> None: |
| self.requests: list[dict[str, Any]] = [] |
| |
| def clear(self) -> None: |
| self.requests.clear() |
| |
| |
| _log = _RequestLog() |
| _canned_responses: dict[str, tuple[int, Any]] = {} |
| |
| |
| class _JiraHandler(BaseHTTPRequestHandler): |
| def _read_body(self) -> bytes: |
| length = int(self.headers.get("Content-Length", 0)) |
| return self.rfile.read(length) if length else b"" |
| |
| def _handle(self, method: str) -> None: |
| body = self._read_body() |
| entry: dict[str, Any] = { |
| "method": method, |
| "path": self.path, |
| "headers": dict(self.headers), |
| } |
| if body: |
| content_type = self.headers.get("Content-Type", "") |
| if "application/json" in content_type: |
| entry["body"] = json.loads(body) |
| else: |
| entry["body_raw"] = body |
| _log.requests.append(entry) |
| |
| key = f"{method} {self.path.split('?')[0]}" |
| if key in _canned_responses: |
| status, payload = _canned_responses[key] |
| else: |
| status, payload = 200, {} |
| self.send_response(status) |
| self.send_header("Content-Type", "application/json") |
| self.end_headers() |
| if status != 204 and payload is not None: |
| self.wfile.write(json.dumps(payload).encode()) |
| |
| def do_GET(self) -> None: |
| self._handle("GET") |
| |
| def do_POST(self) -> None: |
| self._handle("POST") |
| |
| def do_PUT(self) -> None: |
| self._handle("PUT") |
| |
| def log_message(self, format: str, *args: Any) -> None: |
| pass # suppress console noise |
| |
| |
| @pytest.fixture(scope="module") |
| def mock_server(): |
| server = HTTPServer(("127.0.0.1", 0), _JiraHandler) |
| port = server.server_address[1] |
| thread = threading.Thread(target=server.serve_forever, daemon=True) |
| thread.start() |
| yield f"http://127.0.0.1:{port}" |
| server.shutdown() |
| |
| |
| @pytest.fixture(autouse=True) |
| def _clear_state(): |
| _log.clear() |
| _canned_responses.clear() |
| yield |
| _log.clear() |
| _canned_responses.clear() |
| |
| |
| # --------------------------------------------------------------------------- |
| # helpers |
| # --------------------------------------------------------------------------- |
| |
| |
| def _run( |
| mock_url: str, |
| args: list[str], |
| *, |
| token: str = "dGVzdDp0b2tlbg==", |
| env_extra: dict[str, str] | None = None, |
| ) -> subprocess.CompletedProcess: |
| env = { |
| "PATH": os.environ.get("PATH", "/usr/bin:/bin"), |
| "HOME": os.environ.get("HOME", "/tmp"), |
| "ISSUE_TRACKER_URL": mock_url, |
| "ISSUE_TRACKER_PROJECT": "TEST", |
| } |
| if token: |
| env["JIRA_API_TOKEN"] = token |
| if env_extra: |
| env.update(env_extra) |
| assert GROOVY is not None |
| return subprocess.run( |
| [GROOVY, str(BRIDGE)] + args, |
| env=env, |
| capture_output=True, |
| text=True, |
| timeout=30, |
| ) |
| |
| |
| # --------------------------------------------------------------------------- |
| # comment |
| # --------------------------------------------------------------------------- |
| |
| |
| class TestComment: |
| def test_post_comment(self, mock_server: str, tmp_path: Path) -> None: |
| body_file = tmp_path / "comment.txt" |
| body_file.write_text("This is a test comment.") |
| _canned_responses["POST /rest/api/2/issue/FOO-1/comment"] = ( |
| 201, |
| {"id": "12345", "body": "This is a test comment."}, |
| ) |
| result = _run(mock_server, ["comment", "FOO-1", "--body-file", str(body_file)]) |
| assert result.returncode == 0 |
| out = json.loads(result.stdout) |
| assert out["ok"] is True |
| assert out["key"] == "FOO-1" |
| assert out["commentId"] == "12345" |
| req = _log.requests[-1] |
| assert req["method"] == "POST" |
| assert req["body"]["body"] == "This is a test comment." |
| |
| def test_missing_body_file_flag(self, mock_server: str) -> None: |
| result = _run(mock_server, ["comment", "FOO-1"]) |
| assert result.returncode != 0 |
| assert "--body-file" in result.stderr |
| |
| def test_nonexistent_body_file(self, mock_server: str) -> None: |
| result = _run(mock_server, ["comment", "FOO-1", "--body-file", "/no/such/file"]) |
| assert result.returncode != 0 |
| assert "not found" in result.stderr |
| |
| def test_invalid_key(self, mock_server: str, tmp_path: Path) -> None: |
| body_file = tmp_path / "c.txt" |
| body_file.write_text("x") |
| result = _run( |
| mock_server, ["comment", "bad-key", "--body-file", str(body_file)] |
| ) |
| assert result.returncode != 0 |
| assert "not a valid" in result.stderr |
| |
| |
| # --------------------------------------------------------------------------- |
| # transition |
| # --------------------------------------------------------------------------- |
| |
| |
| class TestTransition: |
| def test_transition_by_name(self, mock_server: str) -> None: |
| _canned_responses["GET /rest/api/2/issue/FOO-2/transitions"] = ( |
| 200, |
| { |
| "transitions": [ |
| {"id": "11", "name": "Start Progress"}, |
| {"id": "21", "name": "Resolve Issue"}, |
| ] |
| }, |
| ) |
| _canned_responses["POST /rest/api/2/issue/FOO-2/transitions"] = (204, None) |
| result = _run(mock_server, ["transition", "FOO-2", "Resolve Issue"]) |
| assert result.returncode == 0 |
| out = json.loads(result.stdout) |
| assert out["ok"] is True |
| assert out["transition"] == "Resolve Issue" |
| assert out["transitionId"] == "21" |
| |
| def test_case_insensitive_match(self, mock_server: str) -> None: |
| _canned_responses["GET /rest/api/2/issue/FOO-2/transitions"] = ( |
| 200, |
| {"transitions": [{"id": "11", "name": "Start Progress"}]}, |
| ) |
| _canned_responses["POST /rest/api/2/issue/FOO-2/transitions"] = (204, None) |
| result = _run(mock_server, ["transition", "FOO-2", "start progress"]) |
| assert result.returncode == 0 |
| |
| def test_unknown_transition(self, mock_server: str) -> None: |
| _canned_responses["GET /rest/api/2/issue/FOO-2/transitions"] = ( |
| 200, |
| {"transitions": [{"id": "11", "name": "Start Progress"}]}, |
| ) |
| result = _run(mock_server, ["transition", "FOO-2", "NoSuchTransition"]) |
| assert result.returncode != 0 |
| assert "not found" in result.stderr |
| |
| def test_missing_transition_name(self, mock_server: str) -> None: |
| result = _run(mock_server, ["transition", "FOO-2"]) |
| assert result.returncode != 0 |
| assert "transition requires" in result.stderr |
| |
| |
| # --------------------------------------------------------------------------- |
| # label |
| # --------------------------------------------------------------------------- |
| |
| |
| class TestLabel: |
| def test_add_label(self, mock_server: str) -> None: |
| _canned_responses["PUT /rest/api/2/issue/FOO-3"] = (204, None) |
| result = _run(mock_server, ["label", "FOO-3", "--add", "new-label"]) |
| assert result.returncode == 0 |
| out = json.loads(result.stdout) |
| assert out["added"] == ["new-label"] |
| assert out["removed"] == [] |
| req = _log.requests[-1] |
| assert req["method"] == "PUT" |
| assert req["body"]["update"]["labels"] == [{"add": "new-label"}] |
| |
| def test_remove_label(self, mock_server: str) -> None: |
| _canned_responses["PUT /rest/api/2/issue/FOO-3"] = (204, None) |
| result = _run(mock_server, ["label", "FOO-3", "--remove", "drop"]) |
| assert result.returncode == 0 |
| out = json.loads(result.stdout) |
| assert out["removed"] == ["drop"] |
| req = _log.requests[-1] |
| assert req["body"]["update"]["labels"] == [{"remove": "drop"}] |
| |
| def test_add_and_remove(self, mock_server: str) -> None: |
| _canned_responses["PUT /rest/api/2/issue/FOO-3"] = (204, None) |
| result = _run(mock_server, ["label", "FOO-3", "--add", "c", "--remove", "a"]) |
| assert result.returncode == 0 |
| out = json.loads(result.stdout) |
| assert out["added"] == ["c"] |
| assert out["removed"] == ["a"] |
| req = _log.requests[-1] |
| assert req["body"]["update"]["labels"] == [{"add": "c"}, {"remove": "a"}] |
| |
| def test_no_flags(self, mock_server: str) -> None: |
| result = _run(mock_server, ["label", "FOO-3"]) |
| assert result.returncode != 0 |
| assert "--add or --remove" in result.stderr |
| |
| |
| # --------------------------------------------------------------------------- |
| # assign |
| # --------------------------------------------------------------------------- |
| |
| |
| class TestAssign: |
| def test_assign_user(self, mock_server: str) -> None: |
| _canned_responses["PUT /rest/api/2/issue/FOO-4/assignee"] = (204, None) |
| result = _run(mock_server, ["assign", "FOO-4", "jdoe"]) |
| assert result.returncode == 0 |
| out = json.loads(result.stdout) |
| assert out["ok"] is True |
| assert out["assignee"] == "jdoe" |
| req = _log.requests[-1] |
| assert req["body"]["name"] == "jdoe" |
| |
| def test_missing_username(self, mock_server: str) -> None: |
| result = _run(mock_server, ["assign", "FOO-4"]) |
| assert result.returncode != 0 |
| assert "username" in result.stderr |
| |
| |
| # --------------------------------------------------------------------------- |
| # field |
| # --------------------------------------------------------------------------- |
| |
| |
| class TestField: |
| def test_set_field(self, mock_server: str) -> None: |
| _canned_responses["PUT /rest/api/2/issue/FOO-5"] = (204, None) |
| result = _run( |
| mock_server, ["field", "FOO-5", "customfield_10100", "--value", "high"] |
| ) |
| assert result.returncode == 0 |
| out = json.loads(result.stdout) |
| assert out["field"] == "customfield_10100" |
| assert out["value"] == "high" |
| req = _log.requests[-1] |
| assert req["body"]["fields"]["customfield_10100"] == "high" |
| |
| def test_missing_field_name(self, mock_server: str) -> None: |
| result = _run(mock_server, ["field", "FOO-5"]) |
| assert result.returncode != 0 |
| assert "field requires" in result.stderr |
| |
| def test_missing_value(self, mock_server: str) -> None: |
| result = _run(mock_server, ["field", "FOO-5", "customfield_10100"]) |
| assert result.returncode != 0 |
| assert "--value" in result.stderr |
| |
| def test_value_json_object(self, mock_server: str) -> None: |
| _canned_responses["PUT /rest/api/2/issue/FOO-5"] = (204, None) |
| result = _run( |
| mock_server, |
| ["field", "FOO-5", "priority", "--value-json", '{"name":"High"}'], |
| ) |
| assert result.returncode == 0 |
| out = json.loads(result.stdout) |
| assert out["value"] == {"name": "High"} |
| req = _log.requests[-1] |
| assert req["body"]["fields"]["priority"] == {"name": "High"} |
| |
| def test_value_json_array(self, mock_server: str) -> None: |
| _canned_responses["PUT /rest/api/2/issue/FOO-5"] = (204, None) |
| result = _run( |
| mock_server, |
| ["field", "FOO-5", "fixVersions", "--value-json", '[{"name":"1.2.3"}]'], |
| ) |
| assert result.returncode == 0 |
| out = json.loads(result.stdout) |
| assert out["value"] == [{"name": "1.2.3"}] |
| req = _log.requests[-1] |
| assert req["body"]["fields"]["fixVersions"] == [{"name": "1.2.3"}] |
| |
| |
| # --------------------------------------------------------------------------- |
| # attach |
| # --------------------------------------------------------------------------- |
| |
| |
| class TestAttach: |
| def test_attach_file(self, mock_server: str, tmp_path: Path) -> None: |
| attachment = tmp_path / "report.txt" |
| attachment.write_text("file content here") |
| _canned_responses["POST /rest/api/2/issue/FOO-6/attachments"] = ( |
| 200, |
| [{"id": "99", "filename": "report.txt"}], |
| ) |
| result = _run(mock_server, ["attach", "FOO-6", str(attachment)]) |
| assert result.returncode == 0 |
| out = json.loads(result.stdout) |
| assert out["ok"] is True |
| assert out["attachments"][0]["filename"] == "report.txt" |
| req = _log.requests[-1] |
| assert req["method"] == "POST" |
| assert "X-Atlassian-Token" in req["headers"] |
| |
| def test_nonexistent_file(self, mock_server: str) -> None: |
| result = _run(mock_server, ["attach", "FOO-6", "/no/such/file.txt"]) |
| assert result.returncode != 0 |
| assert "not found" in result.stderr |
| |
| def test_missing_file_arg(self, mock_server: str) -> None: |
| result = _run(mock_server, ["attach", "FOO-6"]) |
| assert result.returncode != 0 |
| assert "file path" in result.stderr |
| |
| |
| # --------------------------------------------------------------------------- |
| # auth requirement for writes |
| # --------------------------------------------------------------------------- |
| |
| |
| class TestAuth: |
| def test_comment_requires_token(self, mock_server: str, tmp_path: Path) -> None: |
| body_file = tmp_path / "c.txt" |
| body_file.write_text("x") |
| result = _run( |
| mock_server, ["comment", "FOO-1", "--body-file", str(body_file)], token="" |
| ) |
| assert result.returncode != 0 |
| assert "JIRA_API_TOKEN" in result.stderr |
| |
| def test_assign_requires_token(self, mock_server: str) -> None: |
| result = _run(mock_server, ["assign", "FOO-1", "user"], token="") |
| assert result.returncode != 0 |
| assert "JIRA_API_TOKEN" in result.stderr |
| |
| def test_basic_auth_header(self, mock_server: str) -> None: |
| _canned_responses["PUT /rest/api/2/issue/FOO-1/assignee"] = (204, None) |
| result = _run( |
| mock_server, ["assign", "FOO-1", "jdoe"], token="dGVzdDp0b2tlbg==" |
| ) |
| assert result.returncode == 0 |
| req = _log.requests[-1] |
| assert req["headers"]["Authorization"] == "Basic dGVzdDp0b2tlbg==" |
| |
| def test_bearer_auth_scheme(self, mock_server: str) -> None: |
| _canned_responses["PUT /rest/api/2/issue/FOO-1/assignee"] = (204, None) |
| result = _run( |
| mock_server, |
| ["assign", "FOO-1", "jdoe"], |
| token="my-pat-token", |
| env_extra={"JIRA_AUTH_SCHEME": "Bearer"}, |
| ) |
| assert result.returncode == 0 |
| req = _log.requests[-1] |
| assert req["headers"]["Authorization"] == "Bearer my-pat-token" |
| |
| |
| # --------------------------------------------------------------------------- |
| # unknown subcommand |
| # --------------------------------------------------------------------------- |
| |
| |
| class TestUnknown: |
| def test_unknown_subcommand(self, mock_server: str) -> None: |
| result = _run(mock_server, ["bogus"]) |
| assert result.returncode != 0 |
| assert "unknown subcommand" in result.stderr |
| |
| def test_no_args(self, mock_server: str) -> None: |
| result = _run(mock_server, []) |
| assert result.returncode != 0 |