"""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
-
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()