""" Tests for RSS feed route (/feed.xml) Tests cover: - Feed route returns valid XML - Correct Content-Type header - Caching behavior (server-side and client-side) - ETag generation and validation - Only published notes included - Feed item limit configuration - Cache expiration behavior """ import pytest import time from xml.etree import ElementTree as ET from starpunk import create_app from starpunk.notes import create_note @pytest.fixture def app(tmp_path): """Create test application""" test_data_dir = tmp_path / "data" test_data_dir.mkdir(parents=True, exist_ok=True) test_config = { "TESTING": True, "DATABASE_PATH": test_data_dir / "starpunk.db", "DATA_PATH": test_data_dir, "NOTES_PATH": test_data_dir / "notes", "SESSION_SECRET": "test-secret-key", "ADMIN_ME": "https://test.example.com", "SITE_URL": "https://example.com", "SITE_NAME": "Test Blog", "SITE_DESCRIPTION": "A test blog", "DEV_MODE": False, "FEED_MAX_ITEMS": 50, "FEED_CACHE_SECONDS": 2, # Short cache for testing } app = create_app(config=test_config) yield app @pytest.fixture def client(app): """Test client for making requests""" return app.test_client() @pytest.fixture def sample_notes(app): """Create sample notes (mix of published and drafts)""" with app.app_context(): notes = [] for i in range(10): note = create_note( content=f"# Test Note {i}\n\nContent for note {i}.", published=(i < 7), # First 7 published, last 3 drafts ) notes.append(note) return notes class TestFeedRoute: """Test /feed.xml route""" def test_feed_route_exists(self, client): """Test /feed.xml route exists and returns 200""" response = client.get("/feed.xml") assert response.status_code == 200 def test_feed_route_returns_xml(self, client): """Test /feed.xml returns valid XML""" response = client.get("/feed.xml") assert response.status_code == 200 # Should be valid XML root = ET.fromstring(response.data) assert root.tag == "rss" def test_feed_route_content_type(self, client): """Test /feed.xml has correct Content-Type header""" response = client.get("/feed.xml") assert response.status_code == 200 # Should have RSS content type assert "application/rss+xml" in response.content_type assert "charset=utf-8" in response.content_type.lower() def test_feed_route_cache_control_header(self, client, app): """Test /feed.xml has Cache-Control header""" response = client.get("/feed.xml") assert response.status_code == 200 # Should have Cache-Control header assert "Cache-Control" in response.headers assert "public" in response.headers["Cache-Control"] # Should include max-age matching config cache_seconds = app.config.get("FEED_CACHE_SECONDS", 300) assert f"max-age={cache_seconds}" in response.headers["Cache-Control"] def test_feed_route_etag_header(self, client): """Test /feed.xml has ETag header""" response = client.get("/feed.xml") assert response.status_code == 200 # Should have ETag header assert "ETag" in response.headers assert len(response.headers["ETag"]) > 0 class TestFeedContent: """Test feed content and structure""" def test_feed_only_published_notes(self, client, sample_notes): """Test feed only includes published notes""" response = client.get("/feed.xml") assert response.status_code == 200 root = ET.fromstring(response.data) channel = root.find("channel") items = channel.findall("item") # Should have 7 items (only published notes) assert len(items) == 7 # Check that draft notes don't appear in feed feed_text = response.data.decode("utf-8") assert "Test Note 0" in feed_text # Published assert "Test Note 6" in feed_text # Published assert "Test Note 7" not in feed_text # Draft assert "Test Note 8" not in feed_text # Draft assert "Test Note 9" not in feed_text # Draft def test_feed_respects_limit_config(self, client, app): """Test feed respects FEED_MAX_ITEMS configuration""" # Create more notes than limit with app.app_context(): for i in range(60): create_note(content=f"Note {i}", published=True) response = client.get("/feed.xml") assert response.status_code == 200 root = ET.fromstring(response.data) channel = root.find("channel") items = channel.findall("item") # Should respect configured limit (50) max_items = app.config.get("FEED_MAX_ITEMS", 50) assert len(items) <= max_items def test_feed_empty_when_no_notes(self, client): """Test feed with no published notes""" response = client.get("/feed.xml") assert response.status_code == 200 root = ET.fromstring(response.data) channel = root.find("channel") items = channel.findall("item") # Should have no items but still valid feed assert len(items) == 0 # Channel should still have required elements assert channel.find("title") is not None assert channel.find("link") is not None def test_feed_has_required_channel_elements(self, client, app): """Test feed has all required RSS channel elements""" response = client.get("/feed.xml") assert response.status_code == 200 root = ET.fromstring(response.data) channel = root.find("channel") # Check required elements assert channel.find("title").text == app.config["SITE_NAME"] assert channel.find("link").text == app.config["SITE_URL"] assert channel.find("description") is not None assert channel.find("language") is not None def test_feed_items_have_required_elements(self, client, sample_notes): """Test feed items have all required RSS item elements""" response = client.get("/feed.xml") assert response.status_code == 200 root = ET.fromstring(response.data) channel = root.find("channel") items = channel.findall("item") # Check first item has required elements if len(items) > 0: item = items[0] assert item.find("title") is not None assert item.find("link") is not None assert item.find("guid") is not None assert item.find("pubDate") is not None assert item.find("description") is not None def test_feed_item_links_are_absolute(self, client, sample_notes, app): """Test feed item links are absolute URLs""" response = client.get("/feed.xml") assert response.status_code == 200 root = ET.fromstring(response.data) channel = root.find("channel") items = channel.findall("item") if len(items) > 0: link = items[0].find("link").text # Should start with site URL assert link.startswith(app.config["SITE_URL"]) # Should be full URL, not relative path assert link.startswith("http") class TestFeedCaching: """Test feed caching behavior""" def test_feed_caches_response(self, client, sample_notes): """Test feed caches response on server side""" # First request response1 = client.get("/feed.xml") etag1 = response1.headers.get("ETag") # Second request (should be cached) response2 = client.get("/feed.xml") etag2 = response2.headers.get("ETag") # ETags should match (same cached content) assert etag1 == etag2 # Content should be identical assert response1.data == response2.data def test_feed_cache_expires(self, client, sample_notes, app): """Test feed cache expires after configured duration""" # First request response1 = client.get("/feed.xml") etag1 = response1.headers.get("ETag") # Wait for cache to expire (cache is 2 seconds in test config) time.sleep(3) # Create new note (changes feed content) with app.app_context(): create_note(content="New note after cache expiry", published=True) # Second request (cache should be expired and regenerated) response2 = client.get("/feed.xml") etag2 = response2.headers.get("ETag") # ETags should be different (content changed) assert etag1 != etag2 def test_feed_etag_changes_with_content(self, client, app): """Test ETag changes when content changes""" # First request response1 = client.get("/feed.xml") etag1 = response1.headers.get("ETag") # Wait for cache expiry time.sleep(3) # Add new note with app.app_context(): create_note(content="New note changes ETag", published=True) # Second request response2 = client.get("/feed.xml") etag2 = response2.headers.get("ETag") # ETags should be different assert etag1 != etag2 def test_feed_cache_consistent_within_window(self, client, sample_notes): """Test cache returns consistent content within cache window""" # Multiple requests within cache window responses = [] for _ in range(5): response = client.get("/feed.xml") responses.append(response) # All responses should be identical first_content = responses[0].data first_etag = responses[0].headers.get("ETag") for response in responses[1:]: assert response.data == first_content assert response.headers.get("ETag") == first_etag class TestFeedEdgeCases: """Test edge cases for feed route""" def test_feed_with_special_characters_in_content(self, client, app): """Test feed handles special characters correctly""" with app.app_context(): create_note( content="# Test & Special \n\n'Quotes' and \"doubles\".", published=True, ) response = client.get("/feed.xml") assert response.status_code == 200 # Should produce valid XML despite special characters root = ET.fromstring(response.data) assert root is not None def test_feed_with_unicode_content(self, client, app): """Test feed handles Unicode content""" with app.app_context(): create_note(content="# Test Unicode 你好 🚀\n\nEmojis and ümlauts.", published=True) response = client.get("/feed.xml") assert response.status_code == 200 # Should handle UTF-8 correctly root = ET.fromstring(response.data) assert root is not None def test_feed_with_very_long_note(self, client, app): """Test feed handles very long note content""" with app.app_context(): long_content = "# Long Note\n\n" + ("This is a very long paragraph. " * 100) create_note(content=long_content, published=True) response = client.get("/feed.xml") assert response.status_code == 200 # Should include full content (no truncation by default) root = ET.fromstring(response.data) assert root is not None class TestFeedConfiguration: """Test feed configuration options""" def test_feed_uses_site_name_from_config(self, client, app): """Test feed uses SITE_NAME from config""" response = client.get("/feed.xml") assert response.status_code == 200 root = ET.fromstring(response.data) channel = root.find("channel") title = channel.find("title").text assert title == app.config["SITE_NAME"] def test_feed_uses_site_url_from_config(self, client, app): """Test feed uses SITE_URL from config""" response = client.get("/feed.xml") assert response.status_code == 200 root = ET.fromstring(response.data) channel = root.find("channel") link = channel.find("link").text assert link == app.config["SITE_URL"] def test_feed_uses_site_description_from_config(self, client, app): """Test feed uses SITE_DESCRIPTION from config""" response = client.get("/feed.xml") assert response.status_code == 200 root = ET.fromstring(response.data) channel = root.find("channel") description = channel.find("description").text assert description == app.config["SITE_DESCRIPTION"]