GRM-20: refactor(scripts): centralize constants, API clients, and HTTP status codes
- Add shared config.py with API URLs, regexes, timeouts, pagination - Add GiteaClient and VikunjaClient in api_clients.py with pooled sessions - Add APIError exception for unified HTTP error handling - Refactor all scripts to use shared modules and http.HTTPStatus - Rewrite unit tests to mock clients and use HTTPStatus constants - Add tests for api_clients and config modules - Achieve 100% test coverage
This commit is contained in:
@@ -5,202 +5,35 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from gitea_runner_manager.config import BRANCH_PROTECTION_CONFIG
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.configure_repo import (
|
||||
BRANCH_PROTECTION_CONFIG,
|
||||
LABEL_CONFIG,
|
||||
GiteaRepoConfig,
|
||||
_handle_http_error,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
class TestGiteaRepoConfig:
|
||||
def test_init_sets_headers(self) -> None:
|
||||
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
|
||||
assert cfg._base_url == "https://git.example.com"
|
||||
assert cfg._owner == "owner"
|
||||
assert cfg._repo == "repo"
|
||||
assert cfg._session.headers["Authorization"] == "token tok"
|
||||
assert cfg._session.headers["Content-Type"] == "application/json"
|
||||
class TestHandleHttpError:
|
||||
def test_handle_http_error_403(self) -> None:
|
||||
err = APIError(http.HTTPStatus.FORBIDDEN, "Forbidden")
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
_handle_http_error(err)
|
||||
msg = str(exc.value)
|
||||
assert "admin rights" in msg
|
||||
assert "Settings → Branches" in msg
|
||||
|
||||
def test_url_constructs_path(self) -> None:
|
||||
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
|
||||
assert cfg._url("/branch_protections") == ("https://git.example.com/repos/owner/repo/branch_protections")
|
||||
def test_handle_http_error_other(self) -> None:
|
||||
err = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "Internal Server Error")
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
_handle_http_error(err)
|
||||
assert str(http.HTTPStatus.INTERNAL_SERVER_ERROR) in str(exc.value)
|
||||
|
||||
def test_url_strips_trailing_slash(self) -> None:
|
||||
cfg = GiteaRepoConfig("https://git.example.com/", "tok", "owner", "repo")
|
||||
assert cfg._url("/labels") == ("https://git.example.com/repos/owner/repo/labels")
|
||||
|
||||
def test_list_branch_protections(self) -> None:
|
||||
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = [
|
||||
{"id": 1, "branch_name": "master"},
|
||||
{"id": 2, "branch_name": "develop"},
|
||||
]
|
||||
cfg._session.get = MagicMock(return_value=mock_response)
|
||||
|
||||
result = cfg.list_branch_protections()
|
||||
assert len(result) == 2
|
||||
assert result[0]["branch_name"] == "master"
|
||||
cfg._session.get.assert_called_once_with("https://git.example.com/repos/owner/repo/branch_protections")
|
||||
|
||||
def test_list_branch_protections_raises_on_error(self) -> None:
|
||||
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = requests.HTTPError("500")
|
||||
cfg._session.get = MagicMock(return_value=mock_response)
|
||||
|
||||
with pytest.raises(requests.HTTPError):
|
||||
cfg.list_branch_protections()
|
||||
|
||||
def test_create_branch_protection(self) -> None:
|
||||
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"id": 3, "branch_name": "master"}
|
||||
cfg._session.post = MagicMock(return_value=mock_response)
|
||||
|
||||
result = cfg.create_branch_protection(BRANCH_PROTECTION_CONFIG)
|
||||
assert result["id"] == 3
|
||||
cfg._session.post.assert_called_once_with(
|
||||
"https://git.example.com/repos/owner/repo/branch_protections",
|
||||
json=BRANCH_PROTECTION_CONFIG,
|
||||
)
|
||||
|
||||
def test_create_branch_protection_raises_on_error(self) -> None:
|
||||
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = requests.HTTPError("403")
|
||||
cfg._session.post = MagicMock(return_value=mock_response)
|
||||
|
||||
with pytest.raises(requests.HTTPError):
|
||||
cfg.create_branch_protection(BRANCH_PROTECTION_CONFIG)
|
||||
|
||||
def test_update_branch_protection(self) -> None:
|
||||
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"id": 1, "required_approvals": 2}
|
||||
cfg._session.patch = MagicMock(return_value=mock_response)
|
||||
|
||||
update = {"required_approvals": 2}
|
||||
result = cfg.update_branch_protection(1, update)
|
||||
assert result["required_approvals"] == 2
|
||||
cfg._session.patch.assert_called_once_with(
|
||||
"https://git.example.com/repos/owner/repo/branch_protections/1",
|
||||
json=update,
|
||||
)
|
||||
|
||||
def test_update_branch_protection_raises_on_error(self) -> None:
|
||||
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = requests.HTTPError("404")
|
||||
cfg._session.patch = MagicMock(return_value=mock_response)
|
||||
|
||||
with pytest.raises(requests.HTTPError):
|
||||
cfg.update_branch_protection(999, {})
|
||||
|
||||
def test_ensure_branch_protection_creates_when_none_exist(self) -> None:
|
||||
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
|
||||
cfg.list_branch_protections = MagicMock(return_value=[])
|
||||
cfg.create_branch_protection = MagicMock(return_value={"id": 1, "branch_name": "master"})
|
||||
|
||||
result = cfg.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
|
||||
assert result["id"] == 1
|
||||
cfg.create_branch_protection.assert_called_once_with(BRANCH_PROTECTION_CONFIG)
|
||||
|
||||
def test_ensure_branch_protection_updates_when_exists(self) -> None:
|
||||
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
|
||||
cfg.list_branch_protections = MagicMock(return_value=[{"id": 5, "branch_name": "master"}])
|
||||
cfg.update_branch_protection = MagicMock(return_value={"id": 5, "required_approvals": 1})
|
||||
|
||||
result = cfg.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
|
||||
assert result["id"] == 5
|
||||
# update config should exclude branch_name
|
||||
expected_update = {k: v for k, v in BRANCH_PROTECTION_CONFIG.items() if k != "branch_name"}
|
||||
cfg.update_branch_protection.assert_called_once_with(5, expected_update)
|
||||
|
||||
def test_ensure_branch_protection_creates_when_other_branches_exist(self) -> None:
|
||||
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
|
||||
cfg.list_branch_protections = MagicMock(return_value=[{"id": 1, "branch_name": "develop"}])
|
||||
cfg.create_branch_protection = MagicMock(return_value={"id": 2, "branch_name": "master"})
|
||||
|
||||
result = cfg.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
|
||||
assert result["id"] == 2
|
||||
cfg.create_branch_protection.assert_called_once_with(BRANCH_PROTECTION_CONFIG)
|
||||
|
||||
def test_list_labels(self) -> None:
|
||||
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = [
|
||||
{"name": "bug", "color": "ff0000"},
|
||||
{"name": "enhancement", "color": "00ff00"},
|
||||
]
|
||||
cfg._session.get = MagicMock(return_value=mock_response)
|
||||
|
||||
result = cfg.list_labels()
|
||||
assert len(result) == 2
|
||||
cfg._session.get.assert_called_once_with("https://git.example.com/repos/owner/repo/labels")
|
||||
|
||||
def test_list_labels_raises_on_error(self) -> None:
|
||||
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = requests.HTTPError("500")
|
||||
cfg._session.get = MagicMock(return_value=mock_response)
|
||||
|
||||
with pytest.raises(requests.HTTPError):
|
||||
cfg.list_labels()
|
||||
|
||||
def test_create_label(self) -> None:
|
||||
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"name": "ready-to-merge", "color": "2ecc71"}
|
||||
cfg._session.post = MagicMock(return_value=mock_response)
|
||||
|
||||
result = cfg.create_label("ready-to-merge", "2ecc71", "Auto-merge label")
|
||||
assert result["name"] == "ready-to-merge"
|
||||
cfg._session.post.assert_called_once_with(
|
||||
"https://git.example.com/repos/owner/repo/labels",
|
||||
json={"name": "ready-to-merge", "color": "2ecc71", "description": "Auto-merge label"},
|
||||
)
|
||||
|
||||
def test_create_label_raises_on_error(self) -> None:
|
||||
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = requests.HTTPError("422")
|
||||
cfg._session.post = MagicMock(return_value=mock_response)
|
||||
|
||||
with pytest.raises(requests.HTTPError):
|
||||
cfg.create_label("dup", "ffffff")
|
||||
|
||||
def test_ensure_label_creates_when_not_exists(self) -> None:
|
||||
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
|
||||
cfg.list_labels = MagicMock(return_value=[])
|
||||
cfg.create_label = MagicMock(return_value={"name": "ready-to-merge", "color": "2ecc71"})
|
||||
|
||||
result = cfg.ensure_label("ready-to-merge", "2ecc71", "desc")
|
||||
assert result is not None
|
||||
assert result["name"] == "ready-to-merge"
|
||||
cfg.create_label.assert_called_once_with("ready-to-merge", "2ecc71", "desc")
|
||||
|
||||
def test_ensure_label_returns_none_when_exists(self) -> None:
|
||||
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
|
||||
cfg.list_labels = MagicMock(return_value=[{"name": "ready-to-merge", "color": "2ecc71"}])
|
||||
cfg.create_label = MagicMock()
|
||||
|
||||
result = cfg.ensure_label("ready-to-merge", "2ecc71", "desc")
|
||||
assert result is None
|
||||
cfg.create_label.assert_not_called()
|
||||
|
||||
def test_ensure_label_creates_when_other_labels_exist(self) -> None:
|
||||
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
|
||||
cfg.list_labels = MagicMock(return_value=[{"name": "bug", "color": "ff0000"}])
|
||||
cfg.create_label = MagicMock(return_value={"name": "ready-to-merge", "color": "2ecc71"})
|
||||
|
||||
result = cfg.ensure_label("ready-to-merge", "2ecc71", "desc")
|
||||
assert result is not None
|
||||
cfg.create_label.assert_called_once()
|
||||
def test_handle_http_error_json_parse_fails(self) -> None:
|
||||
err = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway")
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
_handle_http_error(err)
|
||||
assert str(http.HTTPStatus.BAD_GATEWAY) in str(exc.value)
|
||||
|
||||
|
||||
class TestMain:
|
||||
@@ -212,73 +45,44 @@ class TestMain:
|
||||
|
||||
def test_main_success(self) -> None:
|
||||
with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True):
|
||||
with patch("scripts.configure_repo.GiteaRepoConfig") as mock_cfg_class:
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg_class.return_value = mock_cfg
|
||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
main()
|
||||
|
||||
mock_cfg.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_cfg.ensure_label.assert_called_once_with(**LABEL_CONFIG)
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_client.ensure_label.assert_called_once()
|
||||
|
||||
def test_main_label_already_exists(self) -> None:
|
||||
with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True):
|
||||
with patch("scripts.configure_repo.GiteaRepoConfig") as mock_cfg_class:
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.ensure_label.return_value = None
|
||||
mock_cfg_class.return_value = mock_cfg
|
||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ensure_label.return_value = None
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
main()
|
||||
|
||||
mock_cfg.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_cfg.ensure_label.assert_called_once_with(**LABEL_CONFIG)
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_client.ensure_label.assert_called_once()
|
||||
|
||||
def test_main_api_error(self) -> None:
|
||||
with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True):
|
||||
with patch("scripts.configure_repo.GiteaRepoConfig") as mock_cfg_class:
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.ensure_branch_protection.side_effect = requests.HTTPError("403")
|
||||
mock_cfg_class.return_value = mock_cfg
|
||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ensure_branch_protection.side_effect = APIError(http.HTTPStatus.FORBIDDEN, "Forbidden")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
main()
|
||||
assert "HTTP" in str(exc.value)
|
||||
|
||||
def test_handle_http_error_403(self) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = http.HTTPStatus.FORBIDDEN
|
||||
mock_response.json.return_value = {"message": "Forbidden"}
|
||||
err = requests.HTTPError("403", response=mock_response)
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
_handle_http_error(err)
|
||||
msg = str(exc.value)
|
||||
assert "admin rights" in msg
|
||||
assert "Settings → Branches" in msg
|
||||
|
||||
def test_handle_http_error_other(self) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = http.HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
mock_response.json.return_value = {"message": "Internal Server Error"}
|
||||
err = requests.HTTPError("500", response=mock_response)
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
_handle_http_error(err)
|
||||
assert "500" in str(exc.value)
|
||||
|
||||
def test_handle_http_error_json_parse_fails(self) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = http.HTTPStatus.BAD_GATEWAY
|
||||
mock_response.json.side_effect = ValueError("not json")
|
||||
err = requests.HTTPError("502", response=mock_response)
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
_handle_http_error(err)
|
||||
assert "502" in str(exc.value)
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True):
|
||||
with patch("scripts.configure_repo.GiteaRepoConfig") as mock_cfg_class:
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg_class.return_value = mock_cfg
|
||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
import scripts.configure_repo as cr
|
||||
|
||||
with open(cr.__file__) as f:
|
||||
@@ -287,6 +91,6 @@ def test_main_module_block() -> None:
|
||||
source = source.replace('if __name__ == "__main__":\n main()\n', "")
|
||||
namespace = dict(cr.__dict__)
|
||||
exec(compile(source, cr.__file__, "exec"), namespace)
|
||||
namespace["GiteaRepoConfig"] = mock_cfg_class
|
||||
namespace["GiteaClient"] = mock_client_cls
|
||||
namespace["main"]()
|
||||
mock_cfg.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
|
||||
Reference in New Issue
Block a user