Files
grm/tests/unit/test_post_merge.py
T
Emil Simeonov 790b5c3769
CI / lint (push) Has been cancelled
CI / unit-tests (push) Has been cancelled
CI / molecule-tests (push) Has been cancelled
Post-merge Vikunja update / vikunja (push) Has been cancelled
GRM-20: refactor: rework all scripts to use click and i18n
- Replace argparse/print/sys.exit with click commands and ClickException
- Translate all user-facing messages via _()
- Add friendly Oops! / Nice! prompts
- Wrap HTTP errors in all scripts with user-friendly translated messages
- Update all unit tests to use CliRunner and expect ClickException
- Add 100% branch coverage for new HTTP error handling branches
- Add missing translation keys to i18n.py
- Fix pre-commit hook to use venv Python for validate_commit_msg.py
2026-06-19 20:11:20 +02:00

215 lines
8.1 KiB
Python

"""Unit tests for scripts/post_merge.py."""
from unittest.mock import MagicMock, patch
import click
import pytest
import requests
from click.testing import CliRunner
from scripts.post_merge import (
PROJECT_ID,
VIKUNJA_API,
build_comment,
extract_conventional_msg,
extract_task_id,
main,
mark_task_done,
post_comment,
resolve_task_id,
)
class TestExtractTaskId:
def test_extracts_from_first_line(self) -> None:
assert extract_task_id("GRM-19: fix: resolve bug\n\nBody") == "GRM-19"
def test_returns_empty_when_missing(self) -> None:
assert extract_task_id("fix: resolve bug") == ""
class TestExtractConventionalMsg:
def test_strips_task_id_prefix(self) -> None:
assert extract_conventional_msg("GRM-19: fix: resolve bug") == "fix: resolve bug"
def test_returns_unchanged_without_prefix(self) -> None:
assert extract_conventional_msg("fix: resolve bug") == "fix: resolve bug"
class TestBuildComment:
def test_html_format(self) -> None:
html = build_comment("GRM-19", "fix: bug", "abc123")
assert "<strong>GRM-19</strong>" in html
assert "fix: bug" in html
assert "<code>abc123</code>" in html
class TestResolveTaskId:
@patch("scripts.post_merge.requests.get")
def test_found(self, mock_get: MagicMock) -> None:
mock_response = MagicMock()
mock_response.json.return_value = [
{"id": 42, "project_id": PROJECT_ID, "identifier": "GRM-19"},
]
mock_get.return_value = mock_response
assert resolve_task_id("tok", "GRM-19") == 42
mock_get.assert_called_once()
@patch("scripts.post_merge.requests.get")
def test_not_found_raises(self, mock_get: MagicMock) -> None:
mock_response = MagicMock()
mock_response.json.return_value = []
mock_get.return_value = mock_response
with pytest.raises(click.ClickException) as exc:
resolve_task_id("tok", "GRM-99")
assert "Could not find" in str(exc.value)
@patch("scripts.post_merge.requests.get")
def test_wrong_project_filtered(self, mock_get: MagicMock) -> None:
mock_response = MagicMock()
mock_response.json.return_value = [
{"id": 42, "project_id": 999, "identifier": "GRM-19"},
]
mock_get.return_value = mock_response
with pytest.raises(click.ClickException) as exc:
resolve_task_id("tok", "GRM-19")
assert "Could not find" in str(exc.value)
@patch("scripts.post_merge.requests.get")
def test_http_error_propagates(self, mock_get: MagicMock) -> None:
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("500")
mock_get.return_value = mock_response
with pytest.raises(requests.HTTPError):
resolve_task_id("tok", "GRM-19")
class TestPostComment:
@patch("scripts.post_merge.requests.post")
def test_success(self, mock_post: MagicMock) -> None:
mock_response = MagicMock()
mock_post.return_value = mock_response
post_comment("tok", 42, "<p>hi</p>")
args, kwargs = mock_post.call_args
assert VIKUNJA_API in args[0]
assert kwargs["json"]["comment"] == "<p>hi</p>"
@patch("scripts.post_merge.requests.post")
def test_http_error_propagates(self, mock_post: MagicMock) -> None:
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("500")
mock_post.return_value = mock_response
with pytest.raises(requests.HTTPError):
post_comment("tok", 42, "html")
class TestMarkTaskDone:
@patch("scripts.post_merge.requests.put")
def test_success(self, mock_put: MagicMock) -> None:
mock_response = MagicMock()
mock_put.return_value = mock_response
mark_task_done("tok", 42)
args, kwargs = mock_put.call_args
assert kwargs["json"]["done"] is True
@patch("scripts.post_merge.requests.put")
def test_http_error_propagates(self, mock_put: MagicMock) -> None:
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("500")
mock_put.return_value = mock_response
with pytest.raises(requests.HTTPError):
mark_task_done("tok", 42)
class TestHandleHttpError:
def test_json_parse_failure(self) -> None:
mock_response = MagicMock()
mock_response.status_code = 502
mock_response.json.side_effect = ValueError("not json")
err = requests.HTTPError("502", response=mock_response)
with pytest.raises(click.ClickException) as exc:
from scripts.post_merge import _handle_http_error
_handle_http_error(err)
assert "502" in str(exc.value)
class TestMain:
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.post_merge.resolve_task_id")
@patch("scripts.post_merge.post_comment")
@patch("scripts.post_merge.mark_task_done")
def test_full_flow(self, mock_mark: MagicMock, mock_post: MagicMock, mock_resolve: MagicMock) -> None:
mock_resolve.return_value = 267
runner = CliRunner()
result = runner.invoke(
main,
["GRM-20: fix: resolve bug\n\nBody", "--commit-sha", "abc123"],
)
assert result.exit_code == 0
assert "updated and marked done" in result.output
mock_resolve.assert_called_once_with("tok", "GRM-20")
mock_post.assert_called_once()
mock_mark.assert_called_once_with("tok", 267)
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.post_merge.resolve_task_id")
@patch("scripts.post_merge.post_comment")
@patch("scripts.post_merge.mark_task_done")
def test_no_commit_sha(self, mock_mark: MagicMock, mock_post: MagicMock, mock_resolve: MagicMock) -> None:
mock_resolve.return_value = 267
runner = CliRunner()
result = runner.invoke(main, ["GRM-20: fix: resolve bug"])
assert result.exit_code == 0
mock_post.assert_called_once()
args, _ = mock_post.call_args
assert "unknown" in args[2]
@patch.dict("os.environ", {"VIKUNJA_TOKEN": ""}, clear=True)
def test_missing_token_exits(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["GRM-20: fix: bug"])
assert result.exit_code == 1
assert "VIKUNJA_TOKEN" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_no_task_id_skips(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["fix: resolve bug"])
assert result.exit_code == 0
assert "skipping" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.post_merge.resolve_task_id")
def test_resolve_failure_propagates(self, mock_resolve: MagicMock) -> None:
mock_resolve.side_effect = click.ClickException("not found")
runner = CliRunner()
result = runner.invoke(main, ["GRM-20: fix: bug"])
assert result.exit_code == 1
assert "not found" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.post_merge.resolve_task_id")
@patch("scripts.post_merge.post_comment")
def test_post_comment_failure_raises_click(self, mock_post: MagicMock, mock_resolve: MagicMock) -> None:
mock_resolve.return_value = 267
mock_post.side_effect = requests.HTTPError("500")
runner = CliRunner()
result = runner.invoke(main, ["GRM-20: fix: bug"])
assert result.exit_code == 1
assert "HTTP" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.post_merge.resolve_task_id")
@patch("scripts.post_merge.post_comment")
@patch("scripts.post_merge.mark_task_done")
def test_mark_done_failure_raises_click(
self, mock_mark: MagicMock, mock_post: MagicMock, mock_resolve: MagicMock
) -> None:
mock_resolve.return_value = 267
mock_mark.side_effect = requests.HTTPError("500")
runner = CliRunner()
result = runner.invoke(main, ["GRM-20: fix: bug"])
assert result.exit_code == 1
assert "HTTP" in result.output