diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index c2d854d..ef20d03 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -61,7 +61,7 @@ jobs: run: uv sync --all-extras --all-groups - name: Run tests - run: uv run pytest + run: uv run pytest -o addopts='-n 5 --dist loadfile -m ""' - id: tags name: Compute image tags diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 16a9a4f..3789837 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,14 +38,16 @@ repos: # An extremely fast Python linter and formatter. - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.5 + rev: v0.15.10 hooks: - id: ruff-format - - id: ruff + types_or: [ python, pyi, jupyter, pyproject ] + - id: ruff-check + types_or: [ python, pyi, jupyter, pyproject ] args: ["--fix", "--exit-non-zero-on-fix"] # Static checker for GitHub Actions workflow files. - repo: https://github.com/rhysd/actionlint - rev: v1.7.11 + rev: v1.7.12 hooks: - id: actionlint diff --git a/discord_rss_bot/custom_message.py b/discord_rss_bot/custom_message.py index 1626e39..92d3e8b 100644 --- a/discord_rss_bot/custom_message.py +++ b/discord_rss_bot/custom_message.py @@ -256,6 +256,7 @@ def replace_tags_in_embed(feed: Feed, entry: Entry, reader: Reader) -> CustomEmb content = format_entry_html_for_discord(content) feed_added: str = feed.added.strftime("%Y-%m-%d %H:%M:%S") if feed.added else "Never" + feed_last_exception: str = feed.last_exception.value_str if feed.last_exception else "" feed_last_updated: str = feed.last_updated.strftime("%Y-%m-%d %H:%M:%S") if feed.last_updated else "Never" feed_updated: str = feed.updated.strftime("%Y-%m-%d %H:%M:%S") if feed.updated else "Never" entry_added: str = entry.added.strftime("%Y-%m-%d %H:%M:%S") if entry.added else "Never" @@ -269,14 +270,6 @@ def replace_tags_in_embed(feed: Feed, entry: Entry, reader: Reader) -> CustomEmb embed.author_name = embed.title embed.title = "" - feed_added: str = feed.added.strftime("%Y-%m-%d %H:%M:%S") if feed.added else "Never" - feed_last_exception: str = feed.last_exception.value_str if feed.last_exception else "" - feed_last_updated: str = feed.last_updated.strftime("%Y-%m-%d %H:%M:%S") if feed.last_updated else "Never" - feed_updated: str = feed.updated.strftime("%Y-%m-%d %H:%M:%S") if feed.updated else "Never" - entry_published: str = entry.published.strftime("%Y-%m-%d %H:%M:%S") if entry.published else "Never" - entry_read_modified: str = entry.read_modified.strftime("%Y-%m-%d %H:%M:%S") if entry.read_modified else "Never" - entry_updated: str = entry.updated.strftime("%Y-%m-%d %H:%M:%S") if entry.updated else "Never" - list_of_replacements: list[dict[str, str]] = [ {"{{feed_author}}": feed.author or ""}, {"{{feed_added}}": feed_added or ""}, diff --git a/pyproject.toml b/pyproject.toml index cd6b23b..e196f58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,14 +22,20 @@ dependencies = [ ] [dependency-groups] -dev = ["djlint", "pytest", "pytest-cov", "pytest-randomly", "pytest-xdist"] +dev = [ + "djlint", + "identify", + "pytest", + "pytest-cov", + "pytest-randomly", + "pytest-xdist", +] [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" [tool.ruff] - preview = true unsafe-fixes = true fix = true @@ -87,7 +93,11 @@ lint.ignore = [ "tests/*" = ["S101", "D103", "PLR2004"] [tool.pytest.ini_options] -addopts = "-n 5 --dist loadfile" +addopts = "-n 5 --dist loadfile -m \"not integration and not slow\"" +markers = [ + "integration: tests that exercise external integrations or end-to-end flows", + "slow: tests that are intentionally slower than the default fast test suite", +] filterwarnings = [ "ignore::bs4.GuessedAtParserWarning", "ignore:functools\\.partial will be a method descriptor in future Python versions; wrap it in staticmethod\\(\\) if you want to preserve the old behavior:FutureWarning", diff --git a/tests/test_conftest_hooks.py b/tests/test_conftest_hooks.py new file mode 100644 index 0000000..716edba --- /dev/null +++ b/tests/test_conftest_hooks.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import os +import sys +from types import SimpleNamespace +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +import tests.conftest as hooks + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + + +def test_pytest_addoption_registers_real_git_backup_flag() -> None: + """The hook should register the opt-in flag for real git-backup tests.""" + parser: MagicMock = MagicMock() + + hooks.pytest_addoption(parser) + + parser.addoption.assert_called_once_with( + "--run-real-git-backup-tests", + action="store_true", + default=False, + help="Run tests that push git backup state to a real repository.", + ) + + +def test_pytest_sessionstart_initializes_worker_data_dir( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """The hook should set worker-scoped state and silence bs4 locator warnings.""" + monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw3") + monkeypatch.setattr(hooks.tempfile, "gettempdir", lambda: str(tmp_path)) + + filterwarnings_mock: MagicMock = MagicMock() + monkeypatch.setattr(hooks.warnings, "filterwarnings", filterwarnings_mock) + + hooks.pytest_sessionstart(session=MagicMock()) + + expected_dir: Path = tmp_path / "discord-rss-bot-tests" / "gw3" + assert expected_dir.exists(), f"Expected worker dir to exist: {expected_dir}" + assert os.environ.get("DISCORD_RSS_BOT_DATA_DIR") == str(expected_dir) + filterwarnings_mock.assert_any_call("ignore", category=hooks.MarkupResemblesLocatorWarning) + + +def test_pytest_sessionstart_refreshes_preloaded_settings_and_main_modules( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Preloaded modules should be re-pointed to worker-local storage and refreshed.""" + monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw9") + monkeypatch.setattr(hooks.tempfile, "gettempdir", lambda: str(tmp_path)) + + get_reader: MagicMock = MagicMock() + get_reader.cache_clear = MagicMock() # type: ignore[attr-defined] + close: MagicMock = MagicMock() + + settings_module = SimpleNamespace(data_dir="stale", get_reader=get_reader) + main_module = SimpleNamespace(reader=SimpleNamespace(close=close)) + + monkeypatch.setitem(sys.modules, "discord_rss_bot.settings", settings_module) + monkeypatch.setitem(sys.modules, "discord_rss_bot.main", main_module) + + hooks.pytest_sessionstart(session=MagicMock()) + + expected_dir: Path = tmp_path / "discord-rss-bot-tests" / "gw9" + assert settings_module.data_dir == str(expected_dir) + get_reader.cache_clear.assert_called_once() # type: ignore[attr-defined] + close.assert_called_once() + get_reader.assert_called_once() + + +def test_pytest_sessionstart_suppresses_reader_close_exceptions( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Reader close failures should not prevent rebuilding the main reader.""" + monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw11") + monkeypatch.setattr(hooks.tempfile, "gettempdir", lambda: str(tmp_path)) + + get_reader: MagicMock = MagicMock() + settings_module = SimpleNamespace(data_dir="stale", get_reader=get_reader) + + failing_reader = SimpleNamespace(close=MagicMock(side_effect=RuntimeError("close failed"))) + main_module = SimpleNamespace(reader=failing_reader) + + monkeypatch.setitem(sys.modules, "discord_rss_bot.settings", settings_module) + monkeypatch.setitem(sys.modules, "discord_rss_bot.main", main_module) + + hooks.pytest_sessionstart(session=MagicMock()) + + get_reader.assert_called_once() + + +def test_pytest_collection_modifyitems_noops_when_real_git_backup_tests_enabled() -> None: + """When the flag is enabled, collection hook should return immediately.""" + config: MagicMock = MagicMock() + config.getoption.return_value = True + + items: list[MagicMock] = [MagicMock()] + hooks.pytest_collection_modifyitems(config=config, items=items) + + config.getoption.assert_called_once_with("--run-real-git-backup-tests") diff --git a/tests/test_custom_message.py b/tests/test_custom_message.py index 4b23f45..6bfb16c 100644 --- a/tests/test_custom_message.py +++ b/tests/test_custom_message.py @@ -9,11 +9,18 @@ import pytest from discord_rss_bot.custom_message import CustomEmbed from discord_rss_bot.custom_message import format_entry_html_for_discord +from discord_rss_bot.custom_message import get_custom_message +from discord_rss_bot.custom_message import get_embed +from discord_rss_bot.custom_message import get_embed_data +from discord_rss_bot.custom_message import get_first_image from discord_rss_bot.custom_message import replace_tags_in_embed from discord_rss_bot.custom_message import replace_tags_in_text_message +from discord_rss_bot.custom_message import save_embed +from discord_rss_bot.custom_message import try_to_replace if typing.TYPE_CHECKING: from reader import Entry + from reader.types import Feed # https://docs.discord.com/developers/reference#message-formatting TIMESTAMP_FORMATS: tuple[str, ...] = ( @@ -138,3 +145,217 @@ def test_replace_tags_in_embed_preserves_timestamp_tags( for timestamp_tag in TIMESTAMP_FORMATS: assert timestamp_tag in embed.description + + +def test_try_to_replace_returns_original_message_when_replace_fails() -> None: + rendered = try_to_replace(typing.cast("str", None), "{{tag}}", "value") + assert rendered is None + + +@patch("discord_rss_bot.custom_message.get_custom_message") +def test_replace_tags_in_text_message_uses_last_content_item_and_unescapes_newline( + mock_get_custom_message: MagicMock, +) -> None: + mock_reader = MagicMock() + mock_get_custom_message.return_value = "{{entry_content}}\\n{{entry_content_raw}}" + entry_ns: SimpleNamespace = make_entry("
Summary
") + entry_ns.content = [SimpleNamespace(value="First content
"), SimpleNamespace(value="Last content
")] + + entry: Entry = typing.cast("Entry", entry_ns) + rendered: str = replace_tags_in_text_message(entry, reader=mock_reader) + + assert "Last content" in rendered + assert "First content
" in rendered + assert "\\n" not in rendered + assert "\n" in rendered + + +@patch("discord_rss_bot.custom_message.get_custom_message") +def test_replace_tags_in_text_message_skips_non_string_replacement_values( + mock_get_custom_message: MagicMock, +) -> None: + mock_reader = MagicMock() + mock_get_custom_message.return_value = "{{entry_id}}" + entry_ns: SimpleNamespace = make_entry("Summary
") + entry_ns.id = 123 + + entry: Entry = typing.cast("Entry", entry_ns) + rendered: str = replace_tags_in_text_message(entry, reader=mock_reader) + + assert rendered == "{{entry_id}}" + + +def test_get_first_image_prefers_content_image_over_summary_image() -> None: + summary = '


Summary
") + + entry: Entry = typing.cast("Entry", entry_ns) + embed: CustomEmbed = replace_tags_in_embed(entry_ns.feed, entry, reader=mock_reader) + + assert not embed.title + assert embed.author_name == "Entry Title" + + +@patch("discord_rss_bot.custom_message.get_embed") +def test_replace_tags_in_embed_uses_last_content_item( + mock_get_embed: MagicMock, +) -> None: + mock_reader = MagicMock() + mock_get_embed.return_value = CustomEmbed(description="{{entry_content}}") + entry_ns: SimpleNamespace = make_entry("Summary
") + entry_ns.content = [SimpleNamespace(value="Old content
"), SimpleNamespace(value="New content
")] + + entry: Entry = typing.cast("Entry", entry_ns) + embed: CustomEmbed = replace_tags_in_embed(entry_ns.feed, entry, reader=mock_reader) + + assert "New content" in embed.description + + +def test_get_custom_message_returns_empty_string_on_value_error() -> None: + reader = MagicMock() + feed = make_feed() + reader.get_tag.side_effect = ValueError + + feed = typing.cast("Feed", feed) + + custom_message = get_custom_message(reader=reader, feed=feed) + + assert not custom_message + + +def test_save_embed_serializes_embed_and_writes_feed_tag() -> None: + reader = MagicMock() + feed = make_feed() + feed = typing.cast("Feed", feed) + embed = CustomEmbed( + title="Title", + description="Description", + color="#123456", + author_name="Author", + author_url="https://example.com/author", + author_icon_url="https://example.com/author.png", + image_url="https://example.com/image.png", + thumbnail_url="https://example.com/thumb.png", + footer_text="Footer", + footer_icon_url="https://example.com/footer.png", + ) + + save_embed(reader=reader, feed=feed, embed=embed) + + reader.set_tag.assert_called_once() + call_args = reader.set_tag.call_args.args + assert call_args[1] == "embed" + parsed = typing.cast("dict[str, str]", __import__("json").loads(call_args[2])) + assert parsed["title"] == "Title" + assert parsed["footer_icon_url"] == "https://example.com/footer.png" + + +def test_get_embed_returns_default_embed_when_tag_is_empty() -> None: + reader = MagicMock() + feed = make_feed() + feed = typing.cast("Feed", feed) + reader.get_tag.return_value = "" + + embed = get_embed(reader=reader, feed=feed) + + assert embed.color == "#469ad9" + + +def test_get_embed_reads_embed_from_dict_tag() -> None: + reader = MagicMock() + feed = make_feed() + feed = typing.cast("Feed", feed) + reader.get_tag.return_value = { + "title": "Dict title", + "description": "Dict description", + "color": 123, + } + + embed = get_embed(reader=reader, feed=feed) + + assert embed.title == "Dict title" + assert embed.description == "Dict description" + assert embed.color == "123" + + +def test_get_embed_reads_embed_from_json_string() -> None: + reader = MagicMock() + feed = make_feed() + feed = typing.cast("Feed", feed) + reader.get_tag.return_value = '{"title": "Json title", "footer_text": "Json footer"}' + + embed = get_embed(reader=reader, feed=feed) + + assert embed.title == "Json title" + assert embed.footer_text == "Json footer" + + +def test_get_embed_data_coerces_values_to_strings() -> None: + embed = get_embed_data( + { + "title": 1, + "description": 2, + "color": 3, + "author_name": 4, + "author_url": 5, + "author_icon_url": 6, + "image_url": 7, + "thumbnail_url": 8, + "footer_text": 9, + "footer_icon_url": 10, + }, + ) + + assert embed.title == "1" + assert embed.footer_icon_url == "10" diff --git a/tests/test_feeds.py b/tests/test_feeds.py index 84e836c..41ac72f 100644 --- a/tests/test_feeds.py +++ b/tests/test_feeds.py @@ -8,20 +8,33 @@ from unittest.mock import MagicMock from unittest.mock import patch import pytest +from reader import EntryNotFoundError from reader import Feed +from reader import FeedNotFoundError from reader import Reader +from reader import StorageError from reader import make_reader +from discord_rss_bot import feeds +from discord_rss_bot.feeds import execute_webhook from discord_rss_bot.feeds import extract_domain +from discord_rss_bot.feeds import get_webhook_url from discord_rss_bot.feeds import is_youtube_feed +from discord_rss_bot.feeds import send_discord_quest_notification from discord_rss_bot.feeds import send_entry_to_discord from discord_rss_bot.feeds import send_to_discord +from discord_rss_bot.feeds import set_entry_as_read from discord_rss_bot.feeds import should_send_embed_check from discord_rss_bot.feeds import truncate_webhook_message def test_send_to_discord() -> None: """Test sending to Discord.""" + # Skip early if no webhook URL is configured to avoid a real network request. + webhook_url: str | None = os.environ.get("TEST_WEBHOOK_URL") + if not webhook_url: + pytest.skip("No webhook URL provided.") + with tempfile.TemporaryDirectory() as temp_dir: # Create the temp directory. Path.mkdir(Path(temp_dir), exist_ok=True) @@ -41,13 +54,6 @@ def test_send_to_discord() -> None: feed: Feed = reader.get_feed("https://www.reddit.com/r/Python/.rss") assert feed is not None, f"The feed should not be None. Got: {feed}" - # Get the webhook. - webhook_url: str | None = os.environ.get("TEST_WEBHOOK_URL") - - if not webhook_url: - reader.close() - pytest.skip("No webhook URL provided.") - assert webhook_url is not None, f"The webhook URL should not be None. Got: {webhook_url}" # Add tag to the feed and check if it is there. @@ -274,3 +280,154 @@ def test_extract_domain_special_characters() -> None: ) def test_extract_domain(url: str, expected: str) -> None: assert extract_domain(url) == expected + + +@patch("discord_rss_bot.feeds.execute_webhook") +def test_send_discord_quest_notification_text_match(mock_execute_webhook: MagicMock) -> None: + """Send a quest link as a separate notification when plain text content contains one.""" + entry = MagicMock() + entry.id = "entry-1" + entry.content = [MagicMock(type="text", value="Check this https://discord.com/quests/12345 now")] + reader = MagicMock() + + send_discord_quest_notification(entry, "https://discord.com/api/webhooks/123/abc", reader) + + mock_execute_webhook.assert_called_once() + webhook_sent = mock_execute_webhook.call_args[0][0] + assert webhook_sent.content == "https://discord.com/quests/12345" + + +@patch("discord_rss_bot.feeds.execute_webhook") +def test_send_discord_quest_notification_html_match(mock_execute_webhook: MagicMock) -> None: + """Send a quest link when it is found inside HTML content.""" + entry = MagicMock() + entry.id = "entry-2" + entry.content = [ + MagicMock( + type="text/html", + value='Click here
', + ), + ] + reader = MagicMock() + + send_discord_quest_notification(entry, "https://discord.com/api/webhooks/123/abc", reader) + + mock_execute_webhook.assert_called_once() + webhook_sent = mock_execute_webhook.call_args[0][0] + assert webhook_sent.content == "https://discord.com/quests/777" + + +@patch("discord_rss_bot.feeds.execute_webhook") +def test_send_discord_quest_notification_no_match(mock_execute_webhook: MagicMock) -> None: + """Do nothing when no quest URL exists in entry content.""" + entry = MagicMock() + entry.id = "entry-3" + entry.content = [MagicMock(type="text", value="No quest link here")] + reader = MagicMock() + + send_discord_quest_notification(entry, "https://discord.com/api/webhooks/123/abc", reader) + + mock_execute_webhook.assert_not_called() + + +def test_get_webhook_url_returns_value() -> None: + reader = MagicMock() + entry = MagicMock() + entry.feed_url = "https://example.com/feed.xml" + entry.feed.url = "https://example.com/feed.xml" + reader.get_tag.return_value = "https://discord.com/api/webhooks/123/abc" + + result = get_webhook_url(reader, entry) + + assert result == "https://discord.com/api/webhooks/123/abc" + + +def test_get_webhook_url_returns_empty_on_storage_error() -> None: + reader = MagicMock() + entry = MagicMock() + entry.feed_url = "https://example.com/feed.xml" + entry.feed.url = "https://example.com/feed.xml" + reader.get_tag.side_effect = StorageError("db error") + + result = get_webhook_url(reader, entry) + + assert not result + + +def test_set_entry_as_read_handles_entry_not_found_error() -> None: + reader = MagicMock() + entry = MagicMock(id="entry-4") + reader.set_entry_read.side_effect = EntryNotFoundError("https://example.com/feed.xml", "entry-4") + + set_entry_as_read(reader, entry) + + reader.set_entry_read.assert_called_once_with(entry, True) + + +def test_set_entry_as_read_handles_storage_error() -> None: + reader = MagicMock() + entry = MagicMock(id="entry-5") + reader.set_entry_read.side_effect = StorageError("db error") + + set_entry_as_read(reader, entry) + + reader.set_entry_read.assert_called_once_with(entry, True) + + +def test_execute_webhook_skips_when_feed_paused() -> None: + webhook = MagicMock() + reader = MagicMock() + entry = MagicMock() + entry.id = "entry-6" + entry.feed.url = "https://example.com/feed.xml" + entry.feed.updates_enabled = False + + execute_webhook(webhook, entry, reader) + + reader.get_feed.assert_not_called() + webhook.execute.assert_not_called() + + +def test_execute_webhook_skips_when_feed_missing() -> None: + webhook = MagicMock() + reader = MagicMock() + reader.get_feed.side_effect = FeedNotFoundError("missing") + entry = MagicMock() + entry.id = "entry-7" + entry.feed.url = "https://example.com/feed.xml" + entry.feed.updates_enabled = True + + execute_webhook(webhook, entry, reader) + + webhook.execute.assert_not_called() + + +@patch.object(feeds, "logger") +def test_execute_webhook_logs_error_on_bad_status(mock_logger: MagicMock) -> None: + webhook = MagicMock() + webhook.json = {"content": "test"} + webhook.execute.return_value = MagicMock(status_code=500, text="fail") + reader = MagicMock() + entry = MagicMock() + entry.id = "entry-8" + entry.feed.url = "https://example.com/feed.xml" + entry.feed.updates_enabled = True + + execute_webhook(webhook, entry, reader) + + mock_logger.error.assert_called_once() + + +@patch.object(feeds, "logger") +def test_execute_webhook_logs_info_on_success(mock_logger: MagicMock) -> None: + webhook = MagicMock() + webhook.execute.return_value = MagicMock(status_code=204, text="") + reader = MagicMock() + entry = MagicMock() + entry.id = "entry-9" + entry.feed.url = "https://example.com/feed.xml" + entry.feed.updates_enabled = True + + execute_webhook(webhook, entry, reader) + + mock_logger.info.assert_called_once_with("Sent entry to Discord: %s", "entry-9") diff --git a/tests/test_git_backup.py b/tests/test_git_backup.py index 183d178..fd19ae7 100644 --- a/tests/test_git_backup.py +++ b/tests/test_git_backup.py @@ -21,6 +21,7 @@ from discord_rss_bot.git_backup import setup_backup_repo from discord_rss_bot.main import app if TYPE_CHECKING: + from collections.abc import Generator from pathlib import Path @@ -66,17 +67,46 @@ def test_get_backup_remote_set(monkeypatch: pytest.MonkeyPatch) -> None: def test_setup_backup_repo_creates_git_repo(tmp_path: Path) -> None: """setup_backup_repo initialises a git repo in a fresh directory.""" backup_path: Path = tmp_path / "backup" - result: bool = setup_backup_repo(backup_path) + + with patch("discord_rss_bot.git_backup.subprocess.run") as mock_run: + mock_run.side_effect = [ + MagicMock(returncode=0), # git init + MagicMock(returncode=1), # config user.email read + MagicMock(returncode=0), # config user.email write + MagicMock(returncode=1), # config user.name read + MagicMock(returncode=0), # config user.name write + ] + + result: bool = setup_backup_repo(backup_path) + assert result is True - assert (backup_path / ".git").exists() + assert backup_path.exists() + called_commands: list[list[str]] = [call.args[0] for call in mock_run.call_args_list] + assert any(cmd[:2] == [shutil.which("git") or "git", "init"] for cmd in called_commands) @SKIP_IF_NO_GIT def test_setup_backup_repo_idempotent(tmp_path: Path) -> None: - """setup_backup_repo does not fail when called on an existing repo.""" + """setup_backup_repo does not re-run init when called on an existing repo.""" backup_path: Path = tmp_path / "backup" - assert setup_backup_repo(backup_path) is True - assert setup_backup_repo(backup_path) is True + (backup_path / ".git").mkdir(parents=True) + + with patch("discord_rss_bot.git_backup.subprocess.run") as mock_run: + mock_run.side_effect = [ + MagicMock(returncode=1), # first call: config user.email read + MagicMock(returncode=0), # first call: config user.email write + MagicMock(returncode=1), # first call: config user.name read + MagicMock(returncode=0), # first call: config user.name write + MagicMock(returncode=0), # second call: config user.email read + MagicMock(returncode=0), # second call: config user.name read + ] + + assert setup_backup_repo(backup_path) is True + assert setup_backup_repo(backup_path) is True + + called_commands: list[list[str]] = [call.args[0] for call in mock_run.call_args_list] + init_calls: list[list[str]] = [cmd for cmd in called_commands if "init" in cmd] + assert not init_calls def test_setup_backup_repo_adds_origin_remote(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: @@ -210,7 +240,7 @@ def test_commit_state_change_noop_when_not_configured(monkeypatch: pytest.Monkey @SKIP_IF_NO_GIT def test_commit_state_change_commits(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """commit_state_change creates a commit in the backup repo.""" + """commit_state_change stages and commits exported state when it changes.""" backup_path: Path = tmp_path / "backup" monkeypatch.setenv("GIT_BACKUP_PATH", str(backup_path)) monkeypatch.delenv("GIT_BACKUP_REMOTE", raising=False) @@ -219,20 +249,25 @@ def test_commit_state_change_commits(monkeypatch: pytest.MonkeyPatch, tmp_path: mock_reader.get_feeds.return_value = [] mock_reader.get_tag.return_value = [] - commit_state_change(mock_reader, "Add feed https://example.com/rss") + with ( + patch("discord_rss_bot.git_backup.setup_backup_repo", return_value=True) as mock_setup, + patch("discord_rss_bot.git_backup.export_state") as mock_export, + patch("discord_rss_bot.git_backup.subprocess.run") as mock_run, + ): + mock_run.side_effect = [ + MagicMock(returncode=0), # git add -A + MagicMock(returncode=1), # diff --cached --exit-code => staged changes present + MagicMock(returncode=0), # git commit -m