From b422e91e9b73319c8bec04bcf51cf545c3467870 Mon Sep 17 00:00:00 2001 From: Gerald Fruhmann Date: Sat, 4 Jul 2026 20:22:05 +0200 Subject: [PATCH] test: add CLI and adapter unit tests (38 total, all mocked) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_cli.py: 8 tests via Click CliRunner — dry-run, error paths, missing config, atlassian/m365 not-implemented, adapter call verification - test_adapters.py: 14 tests covering XWikiAdapter and RedmineAdapter — config validation, HTTP mocking (get/put/post), idempotency, deploy logic Co-Authored-By: Claude Sonnet 4.6 --- tests/test_adapters.py | 176 +++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 89 +++++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 tests/test_adapters.py create mode 100644 tests/test_cli.py diff --git a/tests/test_adapters.py b/tests/test_adapters.py new file mode 100644 index 0000000..926fc35 --- /dev/null +++ b/tests/test_adapters.py @@ -0,0 +1,176 @@ +"""Unit tests for the self-hosted adapters (XWiki and Redmine). + +All HTTP calls are mocked — no live services required. +""" + +from unittest.mock import MagicMock, patch + +import pytest # noqa: F401 — used via pytest.CaptureFixture and pytest.raises + +from adapters.selfhosted.redmine import RedmineAdapter +from adapters.selfhosted.xwiki import XWikiAdapter +from src.core.config import ( + KPI, + CapaState, + Clause, + CoreConfig, + Document, + Meta, + Organisation, + RecordType, + RedmineConfig, + Role, + SelfhostedConfig, + XWikiConfig, +) + + +def _make_config( + xwiki_url: str = "http://xwiki.test", + redmine_url: str = "http://redmine.test", + project_key: str = "qms-test", +) -> CoreConfig: + return CoreConfig( + meta=Meta(version="1.0", standard="ISO 9001:2015"), + organisation=Organisation( + name="Test GmbH", + short="TST", + quality_officer="Jane Smith", + management="John Doe", + ), + roles=[Role(id="qmo", label="QMO", responsibilities=["quality"])], + clauses=[Clause(id="4", title="Context", documents=["scope"])], + documents=[Document(id="scope", title="Scope", template=None, clause="4")], + record_types=[ + RecordType(id="nc", label="Nonconformity", clause="10.2", fields=["description"]) + ], + kpis=[KPI(id="k1", label="KPI 1", unit="%", target="95", frequency="monthly", clause="9")], + capa_states=[CapaState(id="open", label="Open", transitions=["open"])], + target="selfhosted", + selfhosted=SelfhostedConfig( + xwiki=XWikiConfig( + base_url=xwiki_url, + space_key="QMS", + username="Admin", + ), + redmine=RedmineConfig( + base_url=redmine_url, + project_key=project_key, + tracker_mapping={"nc": "Nonconformity"}, + ), + ), + ) + + +class TestXWikiAdapter: + def test_raises_if_no_selfhosted_config(self) -> None: + cfg = _make_config() + cfg = cfg.model_copy(update={"selfhosted": None}) + with pytest.raises(ValueError, match="selfhosted config block"): + XWikiAdapter(cfg) + + def test_page_exists_returns_true_on_200(self) -> None: + adapter = XWikiAdapter(_make_config()) + mock_resp = MagicMock(status_code=200) + with patch("adapters.selfhosted.xwiki.requests.get", return_value=mock_resp): + assert adapter.page_exists("TestPage") is True + + def test_page_exists_returns_false_on_404(self) -> None: + adapter = XWikiAdapter(_make_config()) + mock_resp = MagicMock(status_code=404) + with patch("adapters.selfhosted.xwiki.requests.get", return_value=mock_resp): + assert adapter.page_exists("MissingPage") is False + + def test_create_or_update_page_calls_put(self) -> None: + adapter = XWikiAdapter(_make_config()) + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + with patch("adapters.selfhosted.xwiki.requests.put", return_value=mock_resp) as mock_put: + adapter.create_or_update_page("MyPage", "My Title", "# Content") + mock_put.assert_called_once() + call_kwargs = mock_put.call_args + assert "MyPage" in call_kwargs.args[0] + + def test_deploy_upserts_all_pages(self) -> None: + adapter = XWikiAdapter(_make_config()) + pages = { + "page-a": ("Title A", "# A"), + "page-b": ("Title B", "# B"), + } + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + with patch("adapters.selfhosted.xwiki.requests.put", return_value=mock_resp) as mock_put: + adapter.deploy(pages) + assert mock_put.call_count == 2 + + def test_base_url_trailing_slash_stripped(self) -> None: + adapter = XWikiAdapter(_make_config(xwiki_url="http://xwiki.test/")) + assert not adapter._page_url("X").startswith("http://xwiki.test//") + + +class TestRedmineAdapter: + def test_raises_if_no_selfhosted_config(self) -> None: + cfg = _make_config() + cfg = cfg.model_copy(update={"selfhosted": None}) + with pytest.raises(ValueError, match="selfhosted config block"): + RedmineAdapter(cfg) + + def test_project_exists_returns_true_on_200(self) -> None: + adapter = RedmineAdapter(_make_config()) + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + with patch("adapters.selfhosted.redmine.requests.get", return_value=mock_resp): + assert adapter.project_exists() is True + + def test_project_exists_returns_false_on_http_error(self) -> None: + import requests + + adapter = RedmineAdapter(_make_config()) + mock_resp = MagicMock() + mock_resp.raise_for_status.side_effect = requests.HTTPError("404") + with patch("adapters.selfhosted.redmine.requests.get", return_value=mock_resp): + assert adapter.project_exists() is False + + def test_create_project_skips_if_exists(self, capsys: pytest.CaptureFixture) -> None: + adapter = RedmineAdapter(_make_config()) + with ( + patch.object(adapter, "project_exists", return_value=True), + patch.object(adapter, "_post") as mock_post, + ): + adapter.create_project() + mock_post.assert_not_called() + + def test_create_project_posts_if_missing(self) -> None: + adapter = RedmineAdapter(_make_config()) + with ( + patch.object(adapter, "project_exists", return_value=False), + patch.object(adapter, "_post", return_value={}) as mock_post, + ): + adapter.create_project() + mock_post.assert_called_once() + + def test_get_tracker_id_returns_id_by_name(self) -> None: + adapter = RedmineAdapter(_make_config()) + trackers = {"trackers": [{"id": 5, "name": "Nonconformity"}, {"id": 6, "name": "CAPA"}]} + with patch.object(adapter, "_get", return_value=trackers): + assert adapter.get_tracker_id("Nonconformity") == 5 + assert adapter.get_tracker_id("CAPA") == 6 + assert adapter.get_tracker_id("Unknown") is None + + def test_deploy_seeds_all_mapped_record_types(self) -> None: + adapter = RedmineAdapter(_make_config()) + with ( + patch.object(adapter, "create_project"), + patch.object(adapter, "seed_record_type") as mock_seed, + ): + adapter.deploy({"nc": "Nonconformity"}) + mock_seed.assert_called_once() + + def test_deploy_skips_unmapped_record_types(self) -> None: + adapter = RedmineAdapter(_make_config()) + with ( + patch.object(adapter, "create_project"), + patch.object(adapter, "seed_record_type") as mock_seed, + ): + adapter.deploy({}) # empty mapping — nothing should be seeded + mock_seed.assert_not_called() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..64b1cf7 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,89 @@ +"""Unit tests for the CLI deploy command.""" + +from pathlib import Path +from unittest.mock import patch + +from click.testing import CliRunner + +from cli.main import cli + +CORE_YAML = Path(__file__).parent.parent / "config" / "core.yaml" +SELFHOSTED_YAML = Path(__file__).parent.parent / "config" / "selfhosted.yaml" + + +class TestDeployDryRun: + def test_dry_run_selfhosted_exits_zero(self) -> None: + runner = CliRunner() + result = runner.invoke( + cli, + ["deploy", "--target", "selfhosted", "--config", str(SELFHOSTED_YAML), "--dry-run"], + ) + assert result.exit_code == 0, result.output + + def test_dry_run_prints_rendered_count(self) -> None: + runner = CliRunner() + result = runner.invoke( + cli, + ["deploy", "--target", "selfhosted", "--config", str(SELFHOSTED_YAML), "--dry-run"], + ) + assert "Dry run complete" in result.output + assert "rendered" in result.output + + def test_dry_run_shows_org_name(self) -> None: + runner = CliRunner() + result = runner.invoke( + cli, + ["deploy", "--target", "selfhosted", "--config", str(SELFHOSTED_YAML), "--dry-run"], + ) + assert "Config loaded" in result.output + + def test_dry_run_atlassian_raises_not_implemented(self) -> None: + runner = CliRunner() + result = runner.invoke( + cli, + ["deploy", "--target", "atlassian", "--config", str(SELFHOSTED_YAML), "--dry-run"], + ) + # dry-run returns before the adapter check, so this should succeed + assert result.exit_code == 0 + + def test_missing_config_file_exits_nonzero(self) -> None: + runner = CliRunner() + result = runner.invoke( + cli, + ["deploy", "--target", "selfhosted", "--config", "nonexistent.yaml", "--dry-run"], + ) + assert result.exit_code != 0 + + +class TestDeployLive: + def test_atlassian_target_raises_click_exception(self) -> None: + runner = CliRunner() + result = runner.invoke( + cli, + ["deploy", "--target", "atlassian", "--config", str(SELFHOSTED_YAML)], + ) + assert result.exit_code != 0 + assert "not yet implemented" in result.output + + def test_m365_target_raises_click_exception(self) -> None: + runner = CliRunner() + result = runner.invoke( + cli, + ["deploy", "--target", "m365", "--config", str(SELFHOSTED_YAML)], + ) + assert result.exit_code != 0 + assert "not yet implemented" in result.output + + def test_selfhosted_deploy_calls_adapters(self) -> None: + runner = CliRunner() + with ( + patch("adapters.selfhosted.xwiki.XWikiAdapter.deploy") as mock_xwiki, + patch("adapters.selfhosted.redmine.RedmineAdapter.deploy") as mock_redmine, + ): + result = runner.invoke( + cli, + ["deploy", "--target", "selfhosted", "--config", str(SELFHOSTED_YAML)], + ) + assert result.exit_code == 0, result.output + mock_xwiki.assert_called_once() + mock_redmine.assert_called_once()