Files
StarPunk/tests/test_routes_feed.py
Phil Skentelbery 9a31632e05 test: add comprehensive RSS feed tests
Adds unit tests for feed module and integration tests for feed route.

test_feed.py:
- Feed generation with various note counts
- RFC-822 date formatting
- Note title extraction
- HTML cleaning for CDATA safety
- Feed structure validation
- Special characters and Unicode handling

test_routes_feed.py:
- Feed route accessibility and response
- Content-Type and cache headers
- ETag generation and validation
- Server-side caching behavior
- Published notes filtering
- Feed item limit configuration
- Configuration integration

All tests follow existing test patterns and use proper fixtures.
2025-11-19 08:48:35 -07:00

376 lines
12 KiB
Python

"""
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 <Characters>\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"]