"""Integration tests for Mastodon RSS Bot"""
import unittest
from unittest.mock import Mock, patch
import tempfile
import os
import time
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 = """
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
"""
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 = """
Test Atom Feed
2024-01-01T00:00:00Z
Atom Article
https://example.com/atom1
2024-01-01T00:00:00Z
This is an atom article
"""
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 = """
Test Feed
-
Article 1
https://example.com/1
"""
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 = """
Test Feed
-
Article 1
https://example.com/1
-
Article 2
https://example.com/2
"""
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 = """
Test Feed
-
Article 3
https://example.com/3
-
Article 2
https://example.com/2
-
Article 1
https://example.com/1
"""
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()