from __future__ import annotations import contextlib import json import shutil import subprocess # noqa: S404 from pathlib import Path from typing import TYPE_CHECKING from typing import Any from unittest.mock import MagicMock from unittest.mock import patch import pytest from fastapi.testclient import TestClient from discord_rss_bot.git_backup import commit_state_change from discord_rss_bot.git_backup import export_state from discord_rss_bot.git_backup import get_backup_path from discord_rss_bot.git_backup import get_backup_remote from discord_rss_bot.git_backup import setup_backup_repo from discord_rss_bot.main import app if TYPE_CHECKING: from pathlib import Path SKIP_IF_NO_GIT: pytest.MarkDecorator = pytest.mark.skipif( shutil.which("git") is None, reason="git executable not found" ) def test_get_backup_path_unset(monkeypatch: pytest.MonkeyPatch) -> None: """get_backup_path returns None when GIT_BACKUP_PATH is not set.""" monkeypatch.delenv("GIT_BACKUP_PATH", raising=False) assert get_backup_path() is None def test_get_backup_path_set(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """get_backup_path returns a Path when GIT_BACKUP_PATH is set.""" monkeypatch.setenv("GIT_BACKUP_PATH", str(tmp_path)) result: Path | None = get_backup_path() assert result == tmp_path def test_get_backup_path_strips_whitespace(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """get_backup_path strips surrounding whitespace from the env var value.""" monkeypatch.setenv("GIT_BACKUP_PATH", f" {tmp_path} ") result: Path | None = get_backup_path() assert result == tmp_path def test_get_backup_remote_unset(monkeypatch: pytest.MonkeyPatch) -> None: """get_backup_remote returns empty string when GIT_BACKUP_REMOTE is not set.""" monkeypatch.delenv("GIT_BACKUP_REMOTE", raising=False) assert not get_backup_remote() def test_get_backup_remote_set(monkeypatch: pytest.MonkeyPatch) -> None: """get_backup_remote returns the configured remote URL.""" monkeypatch.setenv("GIT_BACKUP_REMOTE", "git@github.com:user/repo.git") assert get_backup_remote() == "git@github.com:user/repo.git" @SKIP_IF_NO_GIT 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) assert result is True assert (backup_path / ".git").exists() @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.""" backup_path: Path = tmp_path / "backup" 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: """setup_backup_repo adds remote 'origin' when GIT_BACKUP_REMOTE is set.""" backup_path: Path = tmp_path / "backup" monkeypatch.setenv("GIT_BACKUP_REMOTE", "git@github.com:user/private.git") with patch("discord_rss_bot.git_backup.subprocess.run") as mock_run: # git config --local queries fail initially so setup writes defaults. 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 MagicMock(returncode=1), # remote get-url origin (missing) MagicMock(returncode=0), # remote add origin ] assert setup_backup_repo(backup_path) is True called_commands: list[list[str]] = [call.args[0] for call in mock_run.call_args_list] assert ["remote", "add", "origin", "git@github.com:user/private.git"] in [ cmd[-4:] for cmd in called_commands if len(cmd) >= 4 ] def test_setup_backup_repo_updates_origin_remote(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """setup_backup_repo updates existing origin when URL differs.""" backup_path: Path = tmp_path / "backup" monkeypatch.setenv("GIT_BACKUP_REMOTE", "git@github.com:user/new-private.git") with patch("discord_rss_bot.git_backup.subprocess.run") as mock_run: # Existing repo path: no git init call. (backup_path / ".git").mkdir(parents=True) mock_run.side_effect = [ MagicMock(returncode=0), # config user.email read MagicMock(returncode=0), # config user.name read MagicMock(returncode=0, stdout=b"git@github.com:user/old-private.git\n"), # remote get-url origin MagicMock(returncode=0), # remote set-url origin ] assert setup_backup_repo(backup_path) is True called_commands: list[list[str]] = [call.args[0] for call in mock_run.call_args_list] assert ["remote", "set-url", "origin", "git@github.com:user/new-private.git"] in [ cmd[-4:] for cmd in called_commands if len(cmd) >= 4 ] def test_export_state_creates_state_json(tmp_path: Path) -> None: """export_state writes a valid state.json to the backup directory.""" mock_reader = MagicMock() # Feeds feed1 = MagicMock() feed1.url = "https://example.com/feed.rss" mock_reader.get_feeds.return_value = [feed1] # Tag values: webhook present, everything else absent (returns None) def get_tag_side_effect( feed_or_key: tuple | str, tag: str | None = None, default: str | None = None, ) -> list[Any] | str | None: if feed_or_key == () and tag is None: # Called for global webhooks list return [] if tag == "webhook": return "https://discord.com/api/webhooks/123/abc" return default mock_reader.get_tag.side_effect = get_tag_side_effect backup_path: Path = tmp_path / "backup" backup_path.mkdir() export_state(mock_reader, backup_path) state_file: Path = backup_path / "state.json" assert state_file.exists(), "state.json should be created by export_state" data: dict[str, Any] = json.loads(state_file.read_text(encoding="utf-8")) assert "feeds" in data assert "webhooks" in data assert data["feeds"][0]["url"] == "https://example.com/feed.rss" assert data["feeds"][0]["webhook"] == "https://discord.com/api/webhooks/123/abc" def test_export_state_omits_empty_tags(tmp_path: Path) -> None: """export_state does not include tags with empty-string or None values.""" mock_reader = MagicMock() feed1 = MagicMock() feed1.url = "https://example.com/feed.rss" mock_reader.get_feeds.return_value = [feed1] def get_tag_side_effect( feed_or_key: tuple | str, tag: str | None = None, default: str | None = None, ) -> list[Any] | str | None: if feed_or_key == (): return [] # Return empty string for all tags return default # default is None mock_reader.get_tag.side_effect = get_tag_side_effect backup_path: Path = tmp_path / "backup" backup_path.mkdir() export_state(mock_reader, backup_path) data: dict[str, Any] = json.loads((backup_path / "state.json").read_text()) # Only "url" key should be present (no empty-value tags) assert list(data["feeds"][0].keys()) == ["url"] def test_commit_state_change_noop_when_not_configured(monkeypatch: pytest.MonkeyPatch) -> None: """commit_state_change does nothing when GIT_BACKUP_PATH is not set.""" monkeypatch.delenv("GIT_BACKUP_PATH", raising=False) mock_reader = MagicMock() # Should not raise and should not call reader methods for export commit_state_change(mock_reader, "Add feed example.com/rss") mock_reader.get_feeds.assert_not_called() @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.""" backup_path: Path = tmp_path / "backup" monkeypatch.setenv("GIT_BACKUP_PATH", str(backup_path)) monkeypatch.delenv("GIT_BACKUP_REMOTE", raising=False) mock_reader = MagicMock() mock_reader.get_feeds.return_value = [] mock_reader.get_tag.return_value = [] commit_state_change(mock_reader, "Add feed https://example.com/rss") # Verify a commit was created in the backup repo git_executable: str | None = shutil.which("git") assert git_executable is not None, "git executable not found" result: subprocess.CompletedProcess[str] = subprocess.run( # noqa: S603 [git_executable, "-C", str(backup_path), "log", "--oneline"], capture_output=True, text=True, check=False, ) assert result.returncode == 0 assert "Add feed https://example.com/rss" in result.stdout @SKIP_IF_NO_GIT def test_commit_state_change_no_double_commit(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """commit_state_change does not create a commit when state has not changed.""" backup_path: Path = tmp_path / "backup" monkeypatch.setenv("GIT_BACKUP_PATH", str(backup_path)) monkeypatch.delenv("GIT_BACKUP_REMOTE", raising=False) mock_reader = MagicMock() mock_reader.get_feeds.return_value = [] mock_reader.get_tag.return_value = [] commit_state_change(mock_reader, "First commit") commit_state_change(mock_reader, "Should not appear") git_executable: str | None = shutil.which("git") assert git_executable is not None, "git executable not found" result: subprocess.CompletedProcess[str] = subprocess.run( # noqa: S603 [git_executable, "-C", str(backup_path), "log", "--oneline"], capture_output=True, text=True, check=False, ) assert result.returncode == 0 assert "First commit" in result.stdout assert "Should not appear" not in result.stdout def test_commit_state_change_push_when_remote_set(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """commit_state_change calls git push when GIT_BACKUP_REMOTE is configured.""" backup_path: Path = tmp_path / "backup" monkeypatch.setenv("GIT_BACKUP_PATH", str(backup_path)) monkeypatch.setenv("GIT_BACKUP_REMOTE", "git@github.com:user/private.git") mock_reader = MagicMock() mock_reader.get_feeds.return_value = [] mock_reader.get_tag.return_value = [] with patch("discord_rss_bot.git_backup.subprocess.run") as mock_run: # Make all subprocess calls succeed mock_run.return_value = MagicMock(returncode=1) # returncode=1 means staged changes exist commit_state_change(mock_reader, "Add feed https://example.com/rss") called_commands: list[list[str]] = [call.args[0] for call in mock_run.call_args_list] push_calls: list[list[str]] = [cmd for cmd in called_commands if "push" in cmd] assert push_calls, "git push should have been called when GIT_BACKUP_REMOTE is set" assert any(cmd[-3:] == ["push", "origin", "HEAD"] for cmd in called_commands), ( "git push should target configured remote name 'origin'" ) def test_commit_state_change_no_push_when_remote_unset(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """commit_state_change does not call git push when GIT_BACKUP_REMOTE is not set.""" backup_path: Path = tmp_path / "backup" monkeypatch.setenv("GIT_BACKUP_PATH", str(backup_path)) monkeypatch.delenv("GIT_BACKUP_REMOTE", raising=False) mock_reader = MagicMock() mock_reader.get_feeds.return_value = [] mock_reader.get_tag.return_value = [] with patch("discord_rss_bot.git_backup.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=1) commit_state_change(mock_reader, "Add feed https://example.com/rss") called_commands: list[list[str]] = [call.args[0] for call in mock_run.call_args_list] push_calls: list[list[str]] = [cmd for cmd in called_commands if "push" in cmd] assert not push_calls, "git push should NOT be called when GIT_BACKUP_REMOTE is not set" @SKIP_IF_NO_GIT def test_commit_state_change_e2e_push_to_bare_repo(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """End-to-end test: commit_state_change pushes to a real bare git repository.""" git_executable: str | None = shutil.which("git") assert git_executable is not None, "git executable not found" # Create a bare remote repository bare_repo_path: Path = tmp_path / "remote.git" subprocess.run([git_executable, "init", "--bare", str(bare_repo_path)], check=True, capture_output=True) # noqa: S603 # Configure backup with remote pointing to bare repo backup_path: Path = tmp_path / "backup" monkeypatch.setenv("GIT_BACKUP_PATH", str(backup_path)) monkeypatch.setenv("GIT_BACKUP_REMOTE", str(bare_repo_path)) # Create mock reader with some state mock_reader = MagicMock() feed1 = MagicMock() feed1.url = "https://example.com/feed.rss" mock_reader.get_feeds.return_value = [feed1] def get_tag_side_effect( feed_or_key: tuple | str, tag: str | None = None, default: str | None = None, ) -> list[Any] | str | None: if feed_or_key == (): return [] if tag == "webhook": return "https://discord.com/api/webhooks/123/abc" return default mock_reader.get_tag.side_effect = get_tag_side_effect # Perform backup with commit and push commit_state_change(mock_reader, "Initial backup") # Verify commit exists in local backup repo result: subprocess.CompletedProcess[str] = subprocess.run( # noqa: S603 [git_executable, "-C", str(backup_path), "log", "--oneline"], capture_output=True, text=True, check=True, ) assert "Initial backup" in result.stdout # Verify origin remote is configured correctly result = subprocess.run( # noqa: S603 [git_executable, "-C", str(backup_path), "remote", "get-url", "origin"], capture_output=True, text=True, check=True, ) assert result.stdout.strip() == str(bare_repo_path) # Verify commit was pushed to the bare remote result = subprocess.run( # noqa: S603 [git_executable, "-C", str(bare_repo_path), "log", "--oneline", "master"], capture_output=True, text=True, check=True, ) assert "Initial backup" in result.stdout # Verify state.json content in the remote result = subprocess.run( # noqa: S603 [git_executable, "-C", str(bare_repo_path), "show", "master:state.json"], capture_output=True, text=True, check=True, ) state_data: dict[str, Any] = json.loads(result.stdout) assert state_data["feeds"][0]["url"] == "https://example.com/feed.rss" assert state_data["feeds"][0]["webhook"] == "https://discord.com/api/webhooks/123/abc" # Perform a second backup to verify subsequent pushes work feed2 = MagicMock() feed2.url = "https://another.com/feed.xml" mock_reader.get_feeds.return_value = [feed1, feed2] commit_state_change(mock_reader, "Add second feed") # Verify both commits are in the remote result = subprocess.run( # noqa: S603 [git_executable, "-C", str(bare_repo_path), "log", "--oneline", "master"], capture_output=True, text=True, check=True, ) assert "Initial backup" in result.stdout assert "Add second feed" in result.stdout # Integration tests for embed-related endpoint backups client: TestClient = TestClient(app) test_webhook_name: str = "Test Backup Webhook" test_webhook_url: str = "https://discord.com/api/webhooks/999999999/testbackupwebhook" test_feed_url: str = "https://lovinator.space/rss_test.xml" def setup_test_feed() -> None: """Set up a test webhook and feed for endpoint tests.""" # Clean up existing test data with contextlib.suppress(Exception): client.post(url="/remove", data={"feed_url": test_feed_url}) with contextlib.suppress(Exception): client.post(url="/delete_webhook", data={"webhook_url": test_webhook_url}) # Create webhook and feed client.post( url="/add_webhook", data={"webhook_name": test_webhook_name, "webhook_url": test_webhook_url}, ) client.post(url="/add", data={"feed_url": test_feed_url, "webhook_dropdown": test_webhook_name}) def test_post_embed_triggers_backup(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """Posting to /embed should trigger a git backup with appropriate message.""" # Set up git backup backup_path: Path = tmp_path / "backup" monkeypatch.setenv("GIT_BACKUP_PATH", str(backup_path)) monkeypatch.delenv("GIT_BACKUP_REMOTE", raising=False) setup_test_feed() with patch("discord_rss_bot.main.commit_state_change") as mock_commit: response = client.post( url="/embed", data={ "feed_url": test_feed_url, "title": "Custom Title", "description": "Custom Description", "color": "#FF5733", }, ) assert response.status_code == 200, f"Failed to post embed: {response.text}" mock_commit.assert_called_once() # Verify the commit message contains the feed URL call_args = mock_commit.call_args assert call_args is not None commit_message: str = call_args[0][1] assert "Update embed settings" in commit_message assert test_feed_url in commit_message def test_post_use_embed_triggers_backup(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """Posting to /use_embed should trigger a git backup.""" backup_path: Path = tmp_path / "backup" monkeypatch.setenv("GIT_BACKUP_PATH", str(backup_path)) monkeypatch.delenv("GIT_BACKUP_REMOTE", raising=False) setup_test_feed() with patch("discord_rss_bot.main.commit_state_change") as mock_commit: response = client.post(url="/use_embed", data={"feed_url": test_feed_url}) assert response.status_code == 200, f"Failed to enable embed: {response.text}" mock_commit.assert_called_once() # Verify the commit message call_args = mock_commit.call_args assert call_args is not None commit_message: str = call_args[0][1] assert "Enable embed mode" in commit_message assert test_feed_url in commit_message def test_post_use_text_triggers_backup(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """Posting to /use_text should trigger a git backup.""" backup_path: Path = tmp_path / "backup" monkeypatch.setenv("GIT_BACKUP_PATH", str(backup_path)) monkeypatch.delenv("GIT_BACKUP_REMOTE", raising=False) setup_test_feed() with patch("discord_rss_bot.main.commit_state_change") as mock_commit: response = client.post(url="/use_text", data={"feed_url": test_feed_url}) assert response.status_code == 200, f"Failed to disable embed: {response.text}" mock_commit.assert_called_once() # Verify the commit message call_args = mock_commit.call_args assert call_args is not None commit_message: str = call_args[0][1] assert "Disable embed mode" in commit_message assert test_feed_url in commit_message def test_post_custom_message_triggers_backup(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """Posting to /custom should trigger a git backup.""" backup_path: Path = tmp_path / "backup" monkeypatch.setenv("GIT_BACKUP_PATH", str(backup_path)) monkeypatch.delenv("GIT_BACKUP_REMOTE", raising=False) setup_test_feed() with patch("discord_rss_bot.main.commit_state_change") as mock_commit: response = client.post( url="/custom", data={ "feed_url": test_feed_url, "custom_message": "Check out this entry: {entry.title}", }, ) assert response.status_code == 200, f"Failed to set custom message: {response.text}" mock_commit.assert_called_once() # Verify the commit message call_args = mock_commit.call_args assert call_args is not None commit_message: str = call_args[0][1] assert "Update custom message" in commit_message assert test_feed_url in commit_message @SKIP_IF_NO_GIT def test_embed_backup_end_to_end(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """End-to-end test: customizing embed creates a real commit in the backup repo.""" git_executable: str | None = shutil.which("git") assert git_executable is not None, "git executable not found" backup_path: Path = tmp_path / "backup" monkeypatch.setenv("GIT_BACKUP_PATH", str(backup_path)) monkeypatch.delenv("GIT_BACKUP_REMOTE", raising=False) setup_test_feed() # Post embed customization response = client.post( url="/embed", data={ "feed_url": test_feed_url, "title": "{entry.title}", "description": "{entry.summary}", "color": "#0099FF", "image_url": "{entry.image}", }, ) assert response.status_code == 200, f"Failed to customize embed: {response.text}" # Verify a commit was created result: subprocess.CompletedProcess[str] = subprocess.run( # noqa: S603 [git_executable, "-C", str(backup_path), "log", "--oneline"], capture_output=True, text=True, check=False, ) assert result.returncode == 0, f"Failed to read git log: {result.stderr}" assert "Update embed settings" in result.stdout, f"Commit not found in log: {result.stdout}" # Verify state.json contains embed data state_file: Path = backup_path / "state.json" assert state_file.exists(), "state.json should exist in backup repo" state_data: dict[str, Any] = json.loads(state_file.read_text(encoding="utf-8")) # Find our test feed in the state test_feed_data = next((feed for feed in state_data["feeds"] if feed["url"] == test_feed_url), None) assert test_feed_data is not None, f"Test feed not found in state.json: {state_data}" # The embed settings are stored as a nested dict under custom_embed tag # This verifies the embed customization was persisted assert "webhook" in test_feed_data, "Feed should have webhook set"