""" 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(autouse=True) def clear_feed_cache(): """Clear feed cache before each test""" from starpunk.routes import public public._feed_cache["notes"] = None public._feed_cache["timestamp"] = None yield # Clear again after test public._feed_cache["notes"] = None public._feed_cache["timestamp"] = None @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_streaming(self, client): """Test /feed.xml uses streaming response (no ETag)""" response = client.get("/feed.xml") assert response.status_code == 200 # Streaming responses don't have ETags (can't calculate hash before streaming) # This is intentional - memory optimization for large feeds assert "ETag" not in response.headers # But should still have cache control assert "Cache-Control" in response.headers 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"] # Channel may have multiple links (alternate and self), just check links exist assert len(channel.findall("link")) > 0 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_note_list(self, client, sample_notes): """Test feed caches note list on server side (not full XML)""" # First request - generates and caches note list response1 = client.get("/feed.xml") # Second request - should use cached note list (but still stream XML) response2 = client.get("/feed.xml") # Content should be identical (same notes) assert response1.data == response2.data # Note: We don't use ETags anymore due to streaming optimization # The note list is cached to avoid repeated DB queries, # but XML is still streamed for memory efficiency def test_feed_cache_expires(self, client, sample_notes, app): """Test feed note list cache expires after configured duration""" # First request response1 = client.get("/feed.xml") content1 = response1.data # 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 with new note) response2 = client.get("/feed.xml") content2 = response2.data # Content should be different (new note added) assert content1 != content2 assert b"New note after cache expiry" in content2 def test_feed_content_changes_with_new_notes(self, client, app): """Test feed content changes when notes are added""" # First request response1 = client.get("/feed.xml") content1 = response1.data # Wait for cache expiry time.sleep(3) # Add new note with app.app_context(): create_note(content="New note changes content", published=True) # Second request response2 = client.get("/feed.xml") content2 = response2.data # Content should be different (new note added) assert content1 != content2 assert b"New note changes content" in content2 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 (same cached note list) first_content = responses[0].data for response in responses[1:]: assert response.data == first_content 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 # Site URL should appear somewhere in the feed feed_text = response.data.decode("utf-8") assert app.config["SITE_URL"] in feed_text 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"]