diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index ef20d03..c2d854d 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 -o addopts='-n 5 --dist loadfile -m ""' + run: uv run pytest - id: tags name: Compute image tags diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3789837..16a9a4f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,16 +38,14 @@ repos: # An extremely fast Python linter and formatter. - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.10 + rev: v0.15.5 hooks: - id: ruff-format - types_or: [ python, pyi, jupyter, pyproject ] - - id: ruff-check - types_or: [ python, pyi, jupyter, pyproject ] + - id: ruff args: ["--fix", "--exit-non-zero-on-fix"] # Static checker for GitHub Actions workflow files. - repo: https://github.com/rhysd/actionlint - rev: v1.7.12 + rev: v1.7.11 hooks: - id: actionlint diff --git a/discord_rss_bot/custom_message.py b/discord_rss_bot/custom_message.py index 92d3e8b..1626e39 100644 --- a/discord_rss_bot/custom_message.py +++ b/discord_rss_bot/custom_message.py @@ -256,7 +256,6 @@ 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" @@ -270,6 +269,14 @@ 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/discord_rss_bot/healthcheck.py b/discord_rss_bot/healthcheck.py index 7fa0231..5c35a1e 100644 --- a/discord_rss_bot/healthcheck.py +++ b/discord_rss_bot/healthcheck.py @@ -16,7 +16,6 @@ def healthcheck() -> None: r: requests.Response = requests.get(url="http://localhost:5000", timeout=5) if r.ok: sys.exit(0) - sys.exit(1) except requests.exceptions.RequestException as e: print(f"Healthcheck failed: {e}", file=sys.stderr) # noqa: T201 sys.exit(1) diff --git a/pyproject.toml b/pyproject.toml index e196f58..970faeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,20 +22,14 @@ dependencies = [ ] [dependency-groups] -dev = [ - "djlint", - "identify", - "pytest", - "pytest-cov", - "pytest-randomly", - "pytest-xdist", -] +dev = ["djlint", "pytest", "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 @@ -93,11 +87,7 @@ lint.ignore = [ "tests/*" = ["S101", "D103", "PLR2004"] [tool.pytest.ini_options] -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", -] +addopts = "-n 5 --dist loadfile" 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 deleted file mode 100644 index 716edba..0000000 --- a/tests/test_conftest_hooks.py +++ /dev/null @@ -1,107 +0,0 @@ -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 6bfb16c..4b23f45 100644 --- a/tests/test_custom_message.py +++ b/tests/test_custom_message.py @@ -9,18 +9,11 @@ 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, ...] = ( @@ -145,217 +138,3 @@ 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 41ac72f..84e836c 100644 --- a/tests/test_feeds.py +++ b/tests/test_feeds.py @@ -8,33 +8,20 @@ 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) @@ -54,6 +41,13 @@ 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. @@ -280,154 +274,3 @@ 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 fd19ae7..183d178 100644 --- a/tests/test_git_backup.py +++ b/tests/test_git_backup.py @@ -21,7 +21,6 @@ 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 @@ -67,46 +66,17 @@ 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" - - 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) - + result: bool = setup_backup_repo(backup_path) assert result is True - 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) + assert (backup_path / ".git").exists() @SKIP_IF_NO_GIT def test_setup_backup_repo_idempotent(tmp_path: Path) -> None: - """setup_backup_repo does not re-run init when called on an existing repo.""" + """setup_backup_repo does not fail when called on an existing repo.""" backup_path: Path = tmp_path / "backup" - (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 + assert setup_backup_repo(backup_path) is True + assert setup_backup_repo(backup_path) is True def test_setup_backup_repo_adds_origin_remote(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: @@ -240,7 +210,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 stages and commits exported state when it changes.""" + """commit_state_change creates a commit in the backup repo.""" backup_path: Path = tmp_path / "backup" monkeypatch.setenv("GIT_BACKUP_PATH", str(backup_path)) monkeypatch.delenv("GIT_BACKUP_REMOTE", raising=False) @@ -249,25 +219,20 @@ def test_commit_state_change_commits(monkeypatch: pytest.MonkeyPatch, tmp_path: mock_reader.get_feeds.return_value = [] mock_reader.get_tag.return_value = [] - 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