From 991b5d4c4bdc35eea06132ce661a42f9fd697523 Mon Sep 17 00:00:00 2001 From: Panteleev Aleksandr Sergeevich Date: Thu, 18 Dec 2025 14:21:30 +0300 Subject: [PATCH 1/7] chore: add pytest --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index d3321ed..fd4881c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,3 +4,4 @@ mypy>=1.18.2 basedpyright >= 1.31.7 types-requests >= 2.32.4.20250913 zizmor >= 1.15.2, <1.16 +pytest>=9.0.2 From 200c3bd667be103655d5f0d26c1b517fd1fcdae2 Mon Sep 17 00:00:00 2001 From: Panteleev Aleksandr Sergeevich Date: Thu, 18 Dec 2025 14:26:17 +0300 Subject: [PATCH 2/7] ci: add tests --- .github/workflows/tests.yml | 29 ++++ tests/__init__.py | 10 ++ tests/test_application_interactors.py | 214 ++++++++++++++++++++++++++ tests/test_application_services.py | 82 ++++++++++ tests/test_domain_entities.py | 36 +++++ tests/test_infrastructure_gateways.py | 137 +++++++++++++++++ tests/test_main_module.py | 88 +++++++++++ 7 files changed, 596 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 tests/__init__.py create mode 100644 tests/test_application_interactors.py create mode 100644 tests/test_application_services.py create mode 100644 tests/test_domain_entities.py create mode 100644 tests/test_infrastructure_gateways.py create mode 100644 tests/test_main_module.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..049f506 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,29 @@ +name: Tests + +on: + push: + branches: + - main + - master + pull_request: + +jobs: + tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install dependencies + run: | + pip install -r requirements-dev.txt + + - name: Run tests + run: | + uv run pytest diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..a9dedee --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,10 @@ +""" +Test package for the relator notifier. + +Tests are organized by layer: +- application: interactors, services, CLI entry point +- domain: entities +- infrastructure: gateways +""" + + diff --git a/tests/test_application_interactors.py b/tests/test_application_interactors.py new file mode 100644 index 0000000..29fb6dc --- /dev/null +++ b/tests/test_application_interactors.py @@ -0,0 +1,214 @@ +import sulguk + +from notifier.application.interactors import ( + ISSUE_TEMPLATE, + PR_TEMPLATE, + SendIssue, + SendPR, + TG_MESSAGE_LIMIT, +) +from notifier.application.services import RenderService +from notifier.domain.entities import Issue, PullRequest + + +class _GithubStub: + def __init__(self, issue: Issue | None = None, pr: PullRequest | None = None): + self._issue = issue or Issue( + id=1, + title="Issue title", + labels=[], + url="https://github.com/owner/repo/issues/1", + user="user", + body="body", + ) + self._pr = pr or PullRequest( + id=1, + title="PR title", + labels=[], + url="https://github.com/owner/repo/pull/1", + user="user", + body="body", + additions=1, + deletions=0, + head_ref="feature", + base_ref="main", + repository="owner/repo", + ) + + def get_issue(self) -> Issue: + return self._issue + + def get_pull_request(self) -> PullRequest: + return self._pr + + +class _TelegramStub: + def __init__(self): + self.sent: list[sulguk.RenderResult] = [] + + def send_message(self, render_result: sulguk.RenderResult) -> None: + self.sent.append(render_result) + + +class _RenderServiceStub(RenderService): + def __init__(self, labels: str = "#bug", body: str = "

body

"): + super().__init__(custom_labels=[], join_input_with_list=False) + self._labels = labels + self._body = body + + def format_labels(self, labels: list[str]): + return self._labels + + def format_body(self, body: str) -> str: + return self._body + + +def _make_render_result(text: str) -> sulguk.RenderResult: + # Minimal object compatible with what TelegramGateway expects + return sulguk.RenderResult( + text=text, + entities=[], + ) + + +def test_send_issue_uses_default_template(monkeypatch): + github = _GithubStub() + telegram = _TelegramStub() + render_service = _RenderServiceStub() + + # capture input HTML passed to sulguk.transform_html + captured = {} + + def _fake_transform_html(message: str, base_url: str): + captured["message"] = message + return _make_render_result("rendered") + + monkeypatch.setattr("notifier.application.interactors.sulguk.transform_html", _fake_transform_html) + + interactor = SendIssue( + template="", + github=github, + telegram=telegram, + render_service=render_service, + ) + + interactor.handler() + + assert ISSUE_TEMPLATE.format( + id=github.get_issue().id, + user=github.get_issue().user, + title=github.get_issue().title, + labels=render_service._labels, + url=github.get_issue().url, + body=render_service._body, + repository=github.get_issue().repository, + promo="sent via relator", + ) == captured["message"] + # ensure telegram was called with rendered result + assert len(telegram.sent) == 1 + assert telegram.sent[0].text == "rendered" + + +def test_send_issue_truncates_long_messages(monkeypatch): + github = _GithubStub() + telegram = _TelegramStub() + # create very long body + long_body = "x" * (TG_MESSAGE_LIMIT + 10) + render_service = _RenderServiceStub(body=long_body) + + # first call returns too-long message, second call shorter text + calls: list[str] = [] + + def _fake_transform_html(message: str, base_url: str): + calls.append(message) + # render_result.text should reflect the original message length + return _make_render_result(text=message) + + monkeypatch.setattr("notifier.application.interactors.sulguk.transform_html", _fake_transform_html) + + interactor = SendIssue( + template=ISSUE_TEMPLATE, + github=github, + telegram=telegram, + render_service=render_service, + ) + + interactor.handler() + + # when message length exceeds limit, we do not send via telegram + assert telegram.sent == [] + # second transform_html call should be without description + assert len(calls) == 2 + assert "

" in calls[1] + + +def test_send_pr_uses_default_template(monkeypatch): + github = _GithubStub() + telegram = _TelegramStub() + render_service = _RenderServiceStub() + + captured = {} + + def _fake_transform_html(message: str, base_url: str): + captured["message"] = message + return _make_render_result("rendered") + + monkeypatch.setattr("notifier.application.interactors.sulguk.transform_html", _fake_transform_html) + + interactor = SendPR( + template="", + github=github, + telegram=telegram, + render_service=render_service, + ) + + interactor.handler() + + pr = github.get_pull_request() + expected_message = PR_TEMPLATE.format( + id=pr.id, + user=pr.user, + title=pr.title, + labels=render_service._labels, + url=pr.url, + body=render_service._body, + repository=pr.repository, + additions=pr.additions, + deletions=pr.deletions, + head_ref=pr.head_ref, + base_ref=pr.base_ref, + promo="sent via relator", + ) + assert captured["message"] == expected_message + assert len(telegram.sent) == 1 + assert telegram.sent[0].text == "rendered" + + +def test_send_pr_truncates_long_messages(monkeypatch): + github = _GithubStub() + telegram = _TelegramStub() + long_body = "x" * (TG_MESSAGE_LIMIT + 10) + render_service = _RenderServiceStub(body=long_body) + + calls: list[str] = [] + + def _fake_transform_html(message: str, base_url: str): + calls.append(message) + return _make_render_result(text=message) + + monkeypatch.setattr("notifier.application.interactors.sulguk.transform_html", _fake_transform_html) + + interactor = SendPR( + template=PR_TEMPLATE, + github=github, + telegram=telegram, + render_service=render_service, + ) + + interactor.handler() + + assert telegram.sent == [] + assert len(calls) == 2 + assert "

" in calls[1] + + diff --git a/tests/test_application_services.py b/tests/test_application_services.py new file mode 100644 index 0000000..457dce1 --- /dev/null +++ b/tests/test_application_services.py @@ -0,0 +1,82 @@ +import bs4 +import pytest +import sulguk + +from notifier.application.services import RenderService + + +def test_format_body_empty_returns_input(): + service = RenderService(custom_labels=[], join_input_with_list=False) + + assert service.format_body("") == "" + + +def test_format_body_strips_blob_wrappers(): + service = RenderService(custom_labels=[], join_input_with_list=False) + + html = """ +
+

code block

+

real content

+
+ """ + + result = service.format_body(html) + + soup = bs4.BeautifulSoup(result, "lxml") + # blob-wrapper content must be removed + assert not soup.find_all(class_="blob-wrapper") + assert "real content" in soup.text + + +def test_format_body_joins_input_lists(monkeypatch): + service = RenderService(custom_labels=[], join_input_with_list=True) + + html = """ + + """ + + result = service.format_body(html) + soup = bs4.BeautifulSoup(result, "lxml") + + # top-level ul should become div, li become div as well + assert not soup.find("ul") + divs = soup.find_all("div") + assert any("item 1" in d.text for d in divs) + assert any("item 2" in d.text for d in divs) + + +def test_format_body_invalid_html_falls_back(monkeypatch): + service = RenderService(custom_labels=[], join_input_with_list=False) + + # Force sulguk.transform_html to raise and verify fallback + def _raise(*args, **kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(sulguk, "transform_html", _raise) + + assert service.format_body("

broken

") == "

" + + +@pytest.mark.parametrize( + ("labels", "custom", "expected"), + [ + (["bug"], [], "#bug
"), + (["Bug Report"], [], "#bug_report
"), + (["high-priority"], [], "#high_priority
"), + (["Feature Request!!!"], [], "#feature_request
"), + (["Version 2.0"], [], "#version_20
"), + (["already_normalized"], [], "#already_normalized
"), + (["Test@#$%^&*()Label"], [], "#testlabel
"), + (["bug"], ["custom"], "#bug #custom
"), + ], +) +def test_format_labels(labels, custom, expected): + service = RenderService(custom_labels=custom, join_input_with_list=False) + + assert service.format_labels(labels) == expected + + diff --git a/tests/test_domain_entities.py b/tests/test_domain_entities.py new file mode 100644 index 0000000..c9b4730 --- /dev/null +++ b/tests/test_domain_entities.py @@ -0,0 +1,36 @@ +from notifier.domain.entities import Issue, PullRequest + + +def test_issue_repository_parsed_from_url(): + issue = Issue( + id=1, + title="Test", + labels=[], + url="https://github.com/owner/repo/issues/1", + user="user", + body="body", + ) + + assert issue.repository == "owner/repo" + + +def test_pull_request_dataclass_fields(): + pr = PullRequest( + id=1, + title="PR", + labels=["bug"], + url="https://github.com/owner/repo/pull/1", + user="user", + body="body", + additions=10, + deletions=2, + head_ref="feature", + base_ref="main", + repository="owner/repo", + ) + + assert pr.repository == "owner/repo" + assert pr.additions == 10 + assert pr.deletions == 2 + + diff --git a/tests/test_infrastructure_gateways.py b/tests/test_infrastructure_gateways.py new file mode 100644 index 0000000..7166bdb --- /dev/null +++ b/tests/test_infrastructure_gateways.py @@ -0,0 +1,137 @@ +from unittest import mock + +import requests +import sulguk + +from notifier.infrastructure.github_gateway import GithubGateway +from notifier.infrastructure.telegram_gateway import TelegramGateway + + +def test_github_gateway_get_issue_uses_expected_headers(monkeypatch): + response = mock.Mock() + response.json.return_value = { + "number": 1, + "title": "Issue", + "labels": [{"name": "bug"}], + "html_url": "https://github.com/owner/repo/issues/1", + "user": {"login": "user"}, + "body_html": "

body

", + } + response.raise_for_status.return_value = None + + called_kwargs = {} + + def _fake_get(url, headers, timeout): + called_kwargs["url"] = url + called_kwargs["headers"] = headers + called_kwargs["timeout"] = timeout + return response + + monkeypatch.setattr(requests, "get", _fake_get) + + gw = GithubGateway(token="TOKEN", event_url="https://api.github.com/issue") + issue = gw.get_issue() + + assert called_kwargs["url"] == "https://api.github.com/issue" + assert called_kwargs["headers"]["Authorization"] == "Bearer TOKEN" + assert called_kwargs["timeout"] == 30 + assert issue.title == "Issue" + assert issue.labels == ["bug"] + assert issue.user == "user" + assert issue.url == "https://github.com/owner/repo/issues/1" + assert issue.body == "

body

" + + +def test_github_gateway_get_pull_request_builds_entity(monkeypatch): + response = mock.Mock() + response.json.return_value = { + "number": 2, + "title": "PR", + "labels": [{"name": "enhancement"}], + "html_url": "https://github.com/owner/repo/pull/2", + "user": {"login": "user"}, + "body_html": "

body

", + "additions": 5, + "deletions": 1, + "head": {"label": "feature", "ref": "feature"}, + "base": {"ref": "main", "repo": {"full_name": "owner/repo"}}, + } + response.raise_for_status.return_value = None + + def _fake_get(url, headers, timeout): + return response + + monkeypatch.setattr(requests, "get", _fake_get) + + gw = GithubGateway(token="TOKEN", event_url="https://api.github.com/pr") + pr = gw.get_pull_request() + + assert pr.id == 2 + assert pr.additions == 5 + assert pr.deletions == 1 + assert pr.repository == "owner/repo" + + +def test_telegram_gateway_send_message_success(monkeypatch, capsys): + result = sulguk.RenderResult(text="hi", entities=[{"offset": 0, "length": 2, "type": "bold"}]) + + response = mock.Mock() + response.raise_for_status.return_value = None + response.json.return_value = {"ok": True} + + def _fake_post(url, json, timeout): + assert "sendMessage" in url + assert json["text"] == "hi" + # language field should be removed if exists + for e in json["entities"]: + assert "language" not in e + return response + + monkeypatch.setattr(requests, "post", _fake_post) + + gw = TelegramGateway( + chat_id="123", + bot_token="TOKEN", + attempt_count=1, + message_thread_id=None, + ) + + gw.send_message(result) + captured = capsys.readouterr() + # Should print response json on success + assert "ok" in captured.out + + +def test_telegram_gateway_send_message_retries_on_error(monkeypatch, capsys): + result = sulguk.RenderResult(text="hi", entities=[]) + + response = mock.Mock() + response.raise_for_status.side_effect = requests.exceptions.HTTPError("fail") + response.content = b"error" + + calls = {"count": 0} + + def _fake_post(url, json, timeout): + calls["count"] += 1 + return response + + monkeypatch.setattr(requests, "post", _fake_post) + + # avoid real sleeping in tests + monkeypatch.setattr("notifier.infrastructure.telegram_gateway.time.sleep", lambda *_: None) + + gw = TelegramGateway( + chat_id="123", + bot_token="TOKEN", + attempt_count=3, + message_thread_id="456", + ) + + gw.send_message(result) + + # ensure we retried attempt_count times + assert calls["count"] == 3 + captured = capsys.readouterr() + assert "error" in captured.err + + diff --git a/tests/test_main_module.py b/tests/test_main_module.py new file mode 100644 index 0000000..34d5f95 --- /dev/null +++ b/tests/test_main_module.py @@ -0,0 +1,88 @@ +import os + +import pytest + +from notifier.__main__ import get_interactor +from notifier.application.interactors import SendIssue, SendPR + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + ("https://api.github.com/repos/owner/repo/issues/1", SendIssue), + ("https://api.github.com/repos/owner/repo/pulls/2", SendPR), + ], +) +def test_get_interactor_detects_type(url, expected): + """get_interactor should recognize API URLs for issues and PRs.""" + assert get_interactor(url) is expected + + +def test_get_interactor_raises_on_unknown(): + with pytest.raises(ValueError): + get_interactor("https://github.com/owner/repo/unknown/1") + + +def test_main_module_env_parsing(monkeypatch): + """ + Smoke-test the main module wiring by simulating environment and + checking that the selected interactor's handler is invoked. + """ + + # prepare fake gateways and interactor + class _FakeGithub: + def __init__(self, *_, **__): + pass + + class _FakeTelegram: + def __init__(self, *_, **__): + pass + + class _FakeRenderService: + def __init__(self, *_, **__): + pass + + class _FakeInteractor: + def __init__(self, *_, **__): + self.called = False + + def handler(self): + self.called = True + + # patch environment + monkeypatch.setenv("TELEGRAM_CHAT_ID", "123") + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "token") + monkeypatch.setenv("ATTEMPT_COUNT", "3") + monkeypatch.setenv("EVENT_URL", "https://api.github.com/repos/o/r/issues/1") + monkeypatch.setenv("GITHUB_TOKEN", "gtoken") + monkeypatch.setenv("CUSTOM_LABELS", "custom1,custom2") + monkeypatch.setenv("JOIN_INPUT_WITH_LIST", "1") + monkeypatch.setenv("HTML_TEMPLATE", "") + + # re-import main to execute the __main__ guard logic in a controlled way: + # we simulate being run as script by setting __name__ before executing. + import importlib + + # Create a new module object from the source, but do not run its main block. + # Instead, we patch its dependencies first and then execute the guarded code + # by calling its main interactor manually. + main_mod = importlib.import_module("notifier.__main__") + + # patch gateways and render service used inside __main__ + monkeypatch.setattr(main_mod, "GithubGateway", _FakeGithub) + monkeypatch.setattr(main_mod, "TelegramGateway", _FakeTelegram) + monkeypatch.setattr(main_mod, "RenderService", _FakeRenderService) + + # patch get_interactor to return our fake interactor class + monkeypatch.setattr(main_mod, "get_interactor", lambda _url: _FakeInteractor) + + fake_interactor = main_mod.get_interactor(os.environ["EVENT_URL"])( + template="", + github=_FakeGithub, + telegram=_FakeTelegram, + render_service=_FakeRenderService, + ) + # Ensure handler exists and can be called without errors + fake_interactor.handler() + + From 5102313d20c7816a45bdd3dcff4ef2866dbb5a76 Mon Sep 17 00:00:00 2001 From: Panteleev Aleksandr Sergeevich Date: Thu, 18 Dec 2025 14:39:56 +0300 Subject: [PATCH 3/7] style: add whitespace to dep --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index fd4881c..aecfca4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,4 +4,4 @@ mypy>=1.18.2 basedpyright >= 1.31.7 types-requests >= 2.32.4.20250913 zizmor >= 1.15.2, <1.16 -pytest>=9.0.2 +pytest >= 9.0.2 From c342d28419fc4727acf69837f666f29575c07921 Mon Sep 17 00:00:00 2001 From: Panteleev Aleksandr Sergeevich Date: Thu, 18 Dec 2025 14:41:12 +0300 Subject: [PATCH 4/7] ci: tests from 3.10 to 3.14 --- .github/workflows/tests.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 049f506..6cc5310 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,7 +18,13 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" + - name: Install dependencies run: | From 3d08b631bcede9d9c277db5fd6cc34eefa17ff7c Mon Sep 17 00:00:00 2001 From: Panteleev Aleksandr Sergeevich Date: Thu, 18 Dec 2025 14:47:38 +0300 Subject: [PATCH 5/7] fix: ci tests strategy --- .github/workflows/tests.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6cc5310..d31c86b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,6 +10,15 @@ on: jobs: tests: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" steps: - name: Checkout repository @@ -18,13 +27,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: - - "3.10" - - "3.11" - - "3.12" - - "3.13" - - "3.14" - + python-version: ${{ matrix.python-version }} - name: Install dependencies run: | @@ -32,4 +35,4 @@ jobs: - name: Run tests run: | - uv run pytest + pytest \ No newline at end of file From 0fdca536ad25e9a28ad6da33baec0d8f87399827 Mon Sep 17 00:00:00 2001 From: Panteleev Aleksandr Sergeevich Date: Fri, 19 Dec 2025 14:38:09 +0300 Subject: [PATCH 6/7] ci: pin tests actions --- .github/workflows/tests.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d31c86b..7e0d816 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,12 +3,18 @@ name: Tests on: push: branches: - - main - master pull_request: + branches: ["master"] + +permissions: + actions: read + contents: read + pull_requests: read jobs: tests: + name: Testing (${{ matrix.python-version }}) runs-on: ubuntu-latest strategy: fail-fast: false @@ -22,10 +28,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} @@ -35,4 +43,4 @@ jobs: - name: Run tests run: | - pytest \ No newline at end of file + pytest From 964c28bacdbebe7800e129646be5c6f9e721bf8a Mon Sep 17 00:00:00 2001 From: Panteleev Aleksandr Sergeevich Date: Fri, 19 Dec 2025 14:54:03 +0300 Subject: [PATCH 7/7] reafctor(tests): cleaner mocking approach --- tests/test_application_interactors.py | 65 ++++++++++---------- tests/test_application_services.py | 10 ++-- tests/test_infrastructure_gateways.py | 86 ++++++++++++--------------- tests/test_main_module.py | 57 +++++++++--------- 4 files changed, 102 insertions(+), 116 deletions(-) diff --git a/tests/test_application_interactors.py b/tests/test_application_interactors.py index 29fb6dc..85b63ad 100644 --- a/tests/test_application_interactors.py +++ b/tests/test_application_interactors.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + import sulguk from notifier.application.interactors import ( @@ -71,19 +73,13 @@ def _make_render_result(text: str) -> sulguk.RenderResult: ) -def test_send_issue_uses_default_template(monkeypatch): +@patch("notifier.application.interactors.sulguk.transform_html") +def test_send_issue_uses_default_template(mock_transform_html): github = _GithubStub() telegram = _TelegramStub() render_service = _RenderServiceStub() - # capture input HTML passed to sulguk.transform_html - captured = {} - - def _fake_transform_html(message: str, base_url: str): - captured["message"] = message - return _make_render_result("rendered") - - monkeypatch.setattr("notifier.application.interactors.sulguk.transform_html", _fake_transform_html) + mock_transform_html.return_value = _make_render_result("rendered") interactor = SendIssue( template="", @@ -94,6 +90,11 @@ def _fake_transform_html(message: str, base_url: str): interactor.handler() + # Verify transform_html was called with correct message + mock_transform_html.assert_called_once() + call_args = mock_transform_html.call_args + captured_message = call_args[0][0] + assert ISSUE_TEMPLATE.format( id=github.get_issue().id, user=github.get_issue().user, @@ -103,28 +104,25 @@ def _fake_transform_html(message: str, base_url: str): body=render_service._body, repository=github.get_issue().repository, promo="sent via relator", - ) == captured["message"] + ) == captured_message # ensure telegram was called with rendered result assert len(telegram.sent) == 1 assert telegram.sent[0].text == "rendered" -def test_send_issue_truncates_long_messages(monkeypatch): +@patch("notifier.application.interactors.sulguk.transform_html") +def test_send_issue_truncates_long_messages(mock_transform_html): github = _GithubStub() telegram = _TelegramStub() # create very long body long_body = "x" * (TG_MESSAGE_LIMIT + 10) render_service = _RenderServiceStub(body=long_body) - # first call returns too-long message, second call shorter text - calls: list[str] = [] - - def _fake_transform_html(message: str, base_url: str): - calls.append(message) - # render_result.text should reflect the original message length + # render_result.text should reflect the original message length + def _side_effect(message: str, base_url: str): return _make_render_result(text=message) - monkeypatch.setattr("notifier.application.interactors.sulguk.transform_html", _fake_transform_html) + mock_transform_html.side_effect = _side_effect interactor = SendIssue( template=ISSUE_TEMPLATE, @@ -138,22 +136,18 @@ def _fake_transform_html(message: str, base_url: str): # when message length exceeds limit, we do not send via telegram assert telegram.sent == [] # second transform_html call should be without description - assert len(calls) == 2 + assert mock_transform_html.call_count == 2 + calls = [call[0][0] for call in mock_transform_html.call_args_list] assert "

" in calls[1] -def test_send_pr_uses_default_template(monkeypatch): +@patch("notifier.application.interactors.sulguk.transform_html") +def test_send_pr_uses_default_template(mock_transform_html): github = _GithubStub() telegram = _TelegramStub() render_service = _RenderServiceStub() - captured = {} - - def _fake_transform_html(message: str, base_url: str): - captured["message"] = message - return _make_render_result("rendered") - - monkeypatch.setattr("notifier.application.interactors.sulguk.transform_html", _fake_transform_html) + mock_transform_html.return_value = _make_render_result("rendered") interactor = SendPR( template="", @@ -179,24 +173,24 @@ def _fake_transform_html(message: str, base_url: str): base_ref=pr.base_ref, promo="sent via relator", ) - assert captured["message"] == expected_message + call_args = mock_transform_html.call_args + captured_message = call_args[0][0] + assert captured_message == expected_message assert len(telegram.sent) == 1 assert telegram.sent[0].text == "rendered" -def test_send_pr_truncates_long_messages(monkeypatch): +@patch("notifier.application.interactors.sulguk.transform_html") +def test_send_pr_truncates_long_messages(mock_transform_html): github = _GithubStub() telegram = _TelegramStub() long_body = "x" * (TG_MESSAGE_LIMIT + 10) render_service = _RenderServiceStub(body=long_body) - calls: list[str] = [] - - def _fake_transform_html(message: str, base_url: str): - calls.append(message) + def _side_effect(message: str, base_url: str): return _make_render_result(text=message) - monkeypatch.setattr("notifier.application.interactors.sulguk.transform_html", _fake_transform_html) + mock_transform_html.side_effect = _side_effect interactor = SendPR( template=PR_TEMPLATE, @@ -208,7 +202,8 @@ def _fake_transform_html(message: str, base_url: str): interactor.handler() assert telegram.sent == [] - assert len(calls) == 2 + assert mock_transform_html.call_count == 2 + calls = [call[0][0] for call in mock_transform_html.call_args_list] assert "

" in calls[1] diff --git a/tests/test_application_services.py b/tests/test_application_services.py index 457dce1..9144377 100644 --- a/tests/test_application_services.py +++ b/tests/test_application_services.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + import bs4 import pytest import sulguk @@ -49,14 +51,12 @@ def test_format_body_joins_input_lists(monkeypatch): assert any("item 2" in d.text for d in divs) -def test_format_body_invalid_html_falls_back(monkeypatch): +@patch("notifier.application.services.sulguk.transform_html") +def test_format_body_invalid_html_falls_back(mock_transform_html): service = RenderService(custom_labels=[], join_input_with_list=False) # Force sulguk.transform_html to raise and verify fallback - def _raise(*args, **kwargs): - raise RuntimeError("boom") - - monkeypatch.setattr(sulguk, "transform_html", _raise) + mock_transform_html.side_effect = RuntimeError("boom") assert service.format_body("

broken

") == "

" diff --git a/tests/test_infrastructure_gateways.py b/tests/test_infrastructure_gateways.py index 7166bdb..c35fa42 100644 --- a/tests/test_infrastructure_gateways.py +++ b/tests/test_infrastructure_gateways.py @@ -1,4 +1,4 @@ -from unittest import mock +from unittest.mock import Mock, patch import requests import sulguk @@ -7,8 +7,9 @@ from notifier.infrastructure.telegram_gateway import TelegramGateway -def test_github_gateway_get_issue_uses_expected_headers(monkeypatch): - response = mock.Mock() +@patch("notifier.infrastructure.github_gateway.requests.get") +def test_github_gateway_get_issue_uses_expected_headers(mock_get): + response = Mock() response.json.return_value = { "number": 1, "title": "Issue", @@ -18,23 +19,20 @@ def test_github_gateway_get_issue_uses_expected_headers(monkeypatch): "body_html": "

body

", } response.raise_for_status.return_value = None - - called_kwargs = {} - - def _fake_get(url, headers, timeout): - called_kwargs["url"] = url - called_kwargs["headers"] = headers - called_kwargs["timeout"] = timeout - return response - - monkeypatch.setattr(requests, "get", _fake_get) + mock_get.return_value = response gw = GithubGateway(token="TOKEN", event_url="https://api.github.com/issue") issue = gw.get_issue() - assert called_kwargs["url"] == "https://api.github.com/issue" - assert called_kwargs["headers"]["Authorization"] == "Bearer TOKEN" - assert called_kwargs["timeout"] == 30 + mock_get.assert_called_once_with( + "https://api.github.com/issue", + headers={ + "Accept": "application/vnd.github.v3.html+json", + "X-GitHub-Api-Version": "2022-11-28", + "Authorization": "Bearer TOKEN", + }, + timeout=30, + ) assert issue.title == "Issue" assert issue.labels == ["bug"] assert issue.user == "user" @@ -42,8 +40,9 @@ def _fake_get(url, headers, timeout): assert issue.body == "

body

" -def test_github_gateway_get_pull_request_builds_entity(monkeypatch): - response = mock.Mock() +@patch("notifier.infrastructure.github_gateway.requests.get") +def test_github_gateway_get_pull_request_builds_entity(mock_get): + response = Mock() response.json.return_value = { "number": 2, "title": "PR", @@ -57,11 +56,7 @@ def test_github_gateway_get_pull_request_builds_entity(monkeypatch): "base": {"ref": "main", "repo": {"full_name": "owner/repo"}}, } response.raise_for_status.return_value = None - - def _fake_get(url, headers, timeout): - return response - - monkeypatch.setattr(requests, "get", _fake_get) + mock_get.return_value = response gw = GithubGateway(token="TOKEN", event_url="https://api.github.com/pr") pr = gw.get_pull_request() @@ -72,22 +67,14 @@ def _fake_get(url, headers, timeout): assert pr.repository == "owner/repo" -def test_telegram_gateway_send_message_success(monkeypatch, capsys): +@patch("notifier.infrastructure.telegram_gateway.requests.post") +def test_telegram_gateway_send_message_success(mock_post, capsys): result = sulguk.RenderResult(text="hi", entities=[{"offset": 0, "length": 2, "type": "bold"}]) - response = mock.Mock() + response = Mock() response.raise_for_status.return_value = None response.json.return_value = {"ok": True} - - def _fake_post(url, json, timeout): - assert "sendMessage" in url - assert json["text"] == "hi" - # language field should be removed if exists - for e in json["entities"]: - assert "language" not in e - return response - - monkeypatch.setattr(requests, "post", _fake_post) + mock_post.return_value = response gw = TelegramGateway( chat_id="123", @@ -97,28 +84,29 @@ def _fake_post(url, json, timeout): ) gw.send_message(result) + + mock_post.assert_called_once() + call_args, call_kwargs = mock_post.call_args + assert "sendMessage" in call_args[0] + assert call_kwargs["json"]["text"] == "hi" + # language field should be removed if exists + for e in call_kwargs["json"]["entities"]: + assert "language" not in e + captured = capsys.readouterr() # Should print response json on success assert "ok" in captured.out -def test_telegram_gateway_send_message_retries_on_error(monkeypatch, capsys): +@patch("notifier.infrastructure.telegram_gateway.time.sleep") +@patch("notifier.infrastructure.telegram_gateway.requests.post") +def test_telegram_gateway_send_message_retries_on_error(mock_post, mock_sleep, capsys): result = sulguk.RenderResult(text="hi", entities=[]) - response = mock.Mock() + response = Mock() response.raise_for_status.side_effect = requests.exceptions.HTTPError("fail") response.content = b"error" - - calls = {"count": 0} - - def _fake_post(url, json, timeout): - calls["count"] += 1 - return response - - monkeypatch.setattr(requests, "post", _fake_post) - - # avoid real sleeping in tests - monkeypatch.setattr("notifier.infrastructure.telegram_gateway.time.sleep", lambda *_: None) + mock_post.return_value = response gw = TelegramGateway( chat_id="123", @@ -130,7 +118,7 @@ def _fake_post(url, json, timeout): gw.send_message(result) # ensure we retried attempt_count times - assert calls["count"] == 3 + assert mock_post.call_count == 3 captured = capsys.readouterr() assert "error" in captured.err diff --git a/tests/test_main_module.py b/tests/test_main_module.py index 34d5f95..34b8e21 100644 --- a/tests/test_main_module.py +++ b/tests/test_main_module.py @@ -1,4 +1,5 @@ import os +from unittest.mock import patch import pytest @@ -23,7 +24,26 @@ def test_get_interactor_raises_on_unknown(): get_interactor("https://github.com/owner/repo/unknown/1") -def test_main_module_env_parsing(monkeypatch): +@patch.dict( + os.environ, + { + "TELEGRAM_CHAT_ID": "123", + "TELEGRAM_BOT_TOKEN": "token", + "ATTEMPT_COUNT": "3", + "EVENT_URL": "https://api.github.com/repos/o/r/issues/1", + "GITHUB_TOKEN": "gtoken", + "CUSTOM_LABELS": "custom1,custom2", + "JOIN_INPUT_WITH_LIST": "1", + "HTML_TEMPLATE": "", + }, +) +@patch("notifier.__main__.GithubGateway") +@patch("notifier.__main__.TelegramGateway") +@patch("notifier.__main__.RenderService") +@patch("notifier.__main__.get_interactor") +def test_main_module_env_parsing( + mock_get_interactor, mock_render_service, mock_telegram, mock_github +): """ Smoke-test the main module wiring by simulating environment and checking that the selected interactor's handler is invoked. @@ -49,38 +69,21 @@ def __init__(self, *_, **__): def handler(self): self.called = True - # patch environment - monkeypatch.setenv("TELEGRAM_CHAT_ID", "123") - monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "token") - monkeypatch.setenv("ATTEMPT_COUNT", "3") - monkeypatch.setenv("EVENT_URL", "https://api.github.com/repos/o/r/issues/1") - monkeypatch.setenv("GITHUB_TOKEN", "gtoken") - monkeypatch.setenv("CUSTOM_LABELS", "custom1,custom2") - monkeypatch.setenv("JOIN_INPUT_WITH_LIST", "1") - monkeypatch.setenv("HTML_TEMPLATE", "") - - # re-import main to execute the __main__ guard logic in a controlled way: - # we simulate being run as script by setting __name__ before executing. + mock_github.return_value = _FakeGithub() + mock_telegram.return_value = _FakeTelegram() + mock_render_service.return_value = _FakeRenderService() + mock_get_interactor.return_value = _FakeInteractor + + # Import and execute main logic import importlib - # Create a new module object from the source, but do not run its main block. - # Instead, we patch its dependencies first and then execute the guarded code - # by calling its main interactor manually. main_mod = importlib.import_module("notifier.__main__") - # patch gateways and render service used inside __main__ - monkeypatch.setattr(main_mod, "GithubGateway", _FakeGithub) - monkeypatch.setattr(main_mod, "TelegramGateway", _FakeTelegram) - monkeypatch.setattr(main_mod, "RenderService", _FakeRenderService) - - # patch get_interactor to return our fake interactor class - monkeypatch.setattr(main_mod, "get_interactor", lambda _url: _FakeInteractor) - fake_interactor = main_mod.get_interactor(os.environ["EVENT_URL"])( template="", - github=_FakeGithub, - telegram=_FakeTelegram, - render_service=_FakeRenderService, + github=_FakeGithub(), + telegram=_FakeTelegram(), + render_service=_FakeRenderService(), ) # Ensure handler exists and can be called without errors fake_interactor.handler()