Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Tests

on:
push:
branches:
- 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
matrix:
python-version:
- "3.10"
- "3.11"
- "3.12"
- "3.13"
- "3.14"

steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
pip install -r requirements-dev.txt

- name: Run tests
run: |
pytest
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -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
"""


209 changes: 209 additions & 0 deletions tests/test_application_interactors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
from unittest.mock import patch

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 = "<p>body</p>"):
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=[],
)


@patch("notifier.application.interactors.sulguk.transform_html")
def test_send_issue_uses_default_template(mock_transform_html):
github = _GithubStub()
telegram = _TelegramStub()
render_service = _RenderServiceStub()

mock_transform_html.return_value = _make_render_result("rendered")

interactor = SendIssue(
template="",
github=github,
telegram=telegram,
render_service=render_service,
)

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,
title=github.get_issue().title,
labels=render_service._labels,
url=github.get_issue().url,
body=render_service._body,
repository=github.get_issue().repository,
promo="<a href='/reagento/relator'>sent via relator</a>",
) == captured_message
# ensure telegram was called with rendered result
assert len(telegram.sent) == 1
assert telegram.sent[0].text == "rendered"


@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)

# render_result.text should reflect the original message length
def _side_effect(message: str, base_url: str):
return _make_render_result(text=message)

mock_transform_html.side_effect = _side_effect

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 mock_transform_html.call_count == 2
calls = [call[0][0] for call in mock_transform_html.call_args_list]
assert "<p></p>" in calls[1]


@patch("notifier.application.interactors.sulguk.transform_html")
def test_send_pr_uses_default_template(mock_transform_html):
github = _GithubStub()
telegram = _TelegramStub()
render_service = _RenderServiceStub()

mock_transform_html.return_value = _make_render_result("rendered")

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="<a href='/reagento/relator'>sent via relator</a>",
)
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"


@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)

def _side_effect(message: str, base_url: str):
return _make_render_result(text=message)

mock_transform_html.side_effect = _side_effect

interactor = SendPR(
template=PR_TEMPLATE,
github=github,
telegram=telegram,
render_service=render_service,
)

interactor.handler()

assert telegram.sent == []
assert mock_transform_html.call_count == 2
calls = [call[0][0] for call in mock_transform_html.call_args_list]
assert "<p></p>" in calls[1]


82 changes: 82 additions & 0 deletions tests/test_application_services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from unittest.mock import patch

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 = """
<div>
<div class="blob-wrapper"><p>code block</p></div>
<p>real content</p>
</div>
"""

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 = """
<ul>
<li><input type="checkbox"/> item 1</li>
<li><input type="checkbox"/> item 2</li>
</ul>
"""

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)


@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
mock_transform_html.side_effect = RuntimeError("boom")

assert service.format_body("<p>broken</p>") == "<p></p>"


@pytest.mark.parametrize(
("labels", "custom", "expected"),
[
(["bug"], [], "#bug<br/>"),
(["Bug Report"], [], "#bug_report<br/>"),
(["high-priority"], [], "#high_priority<br/>"),
(["Feature Request!!!"], [], "#feature_request<br/>"),
(["Version 2.0"], [], "#version_20<br/>"),
(["already_normalized"], [], "#already_normalized<br/>"),
(["Test@#$%^&*()Label"], [], "#testlabel<br/>"),
(["bug"], ["custom"], "#bug #custom<br/>"),
],
)
def test_format_labels(labels, custom, expected):
service = RenderService(custom_labels=custom, join_input_with_list=False)

assert service.format_labels(labels) == expected


36 changes: 36 additions & 0 deletions tests/test_domain_entities.py
Original file line number Diff line number Diff line change
@@ -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


Loading