"""Integration tests for Mastodon RSS Bot""" import unittest from unittest.mock import Mock, patch import tempfile import os import time from textwrap import dedent from bot import MastodonRSSBot import responses import feedparser class TestRSSFeedIntegration(unittest.TestCase): """Integration tests for RSS feed parsing""" def setUp(self): """Set up test fixtures""" self.test_config = { "client_id": "test_client_id", "client_secret": "test_client_secret", "access_token": "test_access_token", "instance_url": "https://mastodon.test", "feed_url": "https://example.com/feed.xml", "toot_visibility": "public", "check_interval": 1, "state_file": tempfile.mktemp(), } def tearDown(self): """Clean up test files""" if os.path.exists(self.test_config["state_file"]): os.remove(self.test_config["state_file"]) @responses.activate @patch("bot.Mastodon") def test_end_to_end_rss_to_mastodon(self, mock_mastodon): """Test complete flow from RSS feed to Mastodon post""" # Mock RSS feed response rss_feed = dedent( """ Test Feed https://example.com Test RSS Feed First Article https://example.com/article1 This is the first article Second Article https://example.com/article2 This is the second article """ ).strip() responses.add( responses.GET, "https://example.com/feed.xml", body=rss_feed, status=200, content_type="application/xml", ) # Mock Mastodon instance mock_instance = Mock() mock_mastodon.return_value = mock_instance # Create bot and process entries bot = MastodonRSSBot(**self.test_config) count = bot.process_new_entries() # Verify results self.assertEqual(count, 2) self.assertEqual(mock_instance.status_post.call_count, 2) # Verify the content of posts calls = mock_instance.status_post.call_args_list self.assertIn("First Article", calls[0][0][0]) self.assertIn("https://example.com/article1", calls[0][0][0]) self.assertIn("Second Article", calls[1][0][0]) self.assertIn("https://example.com/article2", calls[1][0][0]) @responses.activate @patch("bot.Mastodon") def test_atom_feed_parsing(self, mock_mastodon): """Test parsing Atom feeds""" atom_feed = dedent( """ Test Atom Feed 2024-01-01T00:00:00Z Atom Article https://example.com/atom1 2024-01-01T00:00:00Z This is an atom article """ ).strip() responses.add( responses.GET, "https://example.com/feed.xml", body=atom_feed, status=200, content_type="application/atom+xml", ) mock_instance = Mock() mock_mastodon.return_value = mock_instance bot = MastodonRSSBot(**self.test_config) count = bot.process_new_entries() self.assertEqual(count, 1) calls = mock_instance.status_post.call_args_list self.assertIn("Atom Article", calls[0][0][0]) @responses.activate @patch("bot.Mastodon") def test_persistence_across_runs(self, mock_mastodon): """Test that processed entries persist across multiple bot runs""" rss_feed = dedent( """ Test Feed Article 1 https://example.com/1 """ ).strip() responses.add( responses.GET, "https://example.com/feed.xml", body=rss_feed, status=200, content_type="application/xml", ) mock_instance = Mock() mock_mastodon.return_value = mock_instance # First run - should post the article bot1 = MastodonRSSBot(**self.test_config) count1 = bot1.process_new_entries() self.assertEqual(count1, 1) # Second run - should NOT post again (already processed) bot2 = MastodonRSSBot(**self.test_config) count2 = bot2.process_new_entries() self.assertEqual(count2, 0) # Total posts should be 1 self.assertEqual(mock_instance.status_post.call_count, 1) @responses.activate @patch("bot.Mastodon") def test_incremental_feed_updates(self, mock_mastodon): """Test handling of new entries added to feed over time""" # Initial feed with 2 articles initial_feed = dedent( """ Test Feed Article 1 https://example.com/1 Article 2 https://example.com/2 """ ).strip() responses.add( responses.GET, "https://example.com/feed.xml", body=initial_feed, status=200, content_type="application/xml", ) mock_instance = Mock() mock_mastodon.return_value = mock_instance # First run bot = MastodonRSSBot(**self.test_config) count1 = bot.process_new_entries() self.assertEqual(count1, 2) # Update feed with 1 new article responses.reset() updated_feed = dedent( """ Test Feed Article 3 https://example.com/3 Article 2 https://example.com/2 Article 1 https://example.com/1 """ ).strip() responses.add( responses.GET, "https://example.com/feed.xml", body=updated_feed, status=200, content_type="application/xml", ) # Second run - should only post the new article count2 = bot.process_new_entries() self.assertEqual(count2, 1) # Verify only 3 total posts self.assertEqual(mock_instance.status_post.call_count, 3) @responses.activate @patch("bot.Mastodon") def test_network_error_handling(self, mock_mastodon): """Test handling of network errors when fetching feed""" responses.add( responses.GET, "https://example.com/feed.xml", body="Network error", status=500, ) mock_instance = Mock() mock_mastodon.return_value = mock_instance bot = MastodonRSSBot(**self.test_config) count = bot.process_new_entries() # Should handle error gracefully self.assertEqual(count, 0) self.assertEqual(mock_instance.status_post.call_count, 0) @responses.activate @patch("bot.Mastodon") def test_malformed_xml_handling(self, mock_mastodon): """Test handling of malformed XML feeds""" malformed_feed = """ Broken Feed <item> <title>Article """ # Intentionally malformed responses.add( responses.GET, "https://example.com/feed.xml", body=malformed_feed, status=200, content_type="application/xml", ) mock_instance = Mock() mock_mastodon.return_value = mock_instance bot = MastodonRSSBot(**self.test_config) # Should handle malformed feed gracefully count = bot.process_new_entries() # feedparser is lenient and may parse some entries # The important thing is it doesn't crash self.assertIsInstance(count, int) class TestMastodonAPIIntegration(unittest.TestCase): """Integration tests for Mastodon API interaction""" def setUp(self): """Set up test fixtures""" self.test_config = { "client_id": "test_client_id", "client_secret": "test_client_secret", "access_token": "test_access_token", "instance_url": "https://mastodon.test", "feed_url": "https://example.com/feed.xml", "toot_visibility": "public", "check_interval": 1, "state_file": tempfile.mktemp(), } def tearDown(self): """Clean up test files""" if os.path.exists(self.test_config["state_file"]): os.remove(self.test_config["state_file"]) @patch("bot.Mastodon") def test_different_visibility_levels(self, mock_mastodon): """Test posting with different visibility levels""" mock_instance = Mock() mock_mastodon.return_value = mock_instance visibility_levels = ["public", "unlisted", "private", "direct"] for visibility in visibility_levels: self.test_config["toot_visibility"] = visibility bot = MastodonRSSBot(**self.test_config) bot.post_to_mastodon("Test status") # Verify all visibility levels were used calls = mock_instance.status_post.call_args_list for idx, visibility in enumerate(visibility_levels): self.assertEqual(calls[idx][1]["visibility"], visibility) @patch("bot.Mastodon") def test_retry_on_rate_limit(self, mock_mastodon): """Test that rate limit errors are handled appropriately""" from mastodon import MastodonRatelimitError mock_instance = Mock() # First call raises rate limit error, second succeeds mock_instance.status_post.side_effect = [ MastodonRatelimitError("Rate limited"), None, ] mock_mastodon.return_value = mock_instance bot = MastodonRSSBot(**self.test_config) # First attempt should fail result1 = bot.post_to_mastodon("Test status") self.assertFalse(result1) # Second attempt should succeed result2 = bot.post_to_mastodon("Test status") self.assertTrue(result2) if __name__ == "__main__": unittest.main()