Files
StarPunk/tests/test_routes_feeds.py
Phil Skentelbery 8fbdcb6e6f feat: Complete Phase 2.4 - HTTP Content Negotiation
Implements HTTP content negotiation for feed format selection.

Phase 2.4 Deliverables:
- Content negotiation via Accept header parsing
- Quality factor support (q= parameter)
- 5 feed endpoints with format routing
- 406 Not Acceptable responses with helpful errors
- Comprehensive test coverage (63 tests)

Endpoints:
- /feed - Content negotiation based on Accept header
- /feed.rss - Explicit RSS 2.0
- /feed.atom - Explicit ATOM 1.0
- /feed.json - Explicit JSON Feed 1.1
- /feed.xml - Backward compatibility (→ RSS)

MIME Type Mapping:
- application/rss+xml → RSS 2.0
- application/atom+xml → ATOM 1.0
- application/feed+json or application/json → JSON Feed 1.1
- */* → RSS 2.0 (default)

Implementation:
- Simple quality factor parsing (StarPunk philosophy)
- Not full RFC 7231 compliance (minimal approach)
- Reuses existing feed generators
- No breaking changes

Quality Metrics:
- 132/132 tests passing (100%)
- Zero breaking changes
- Full backward compatibility
- Standards compliant negotiation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 20:46:49 -07:00

256 lines
10 KiB
Python

"""
Integration tests for feed route endpoints
Tests the /feed, /feed.rss, /feed.atom, /feed.json, and /feed.xml endpoints
including content negotiation.
"""
import pytest
from starpunk import create_app
from starpunk.notes import create_note
@pytest.fixture
def app(tmp_path):
"""Create and configure a test app instance"""
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 Site",
"SITE_DESCRIPTION": "Test Description",
"AUTHOR_NAME": "Test Author",
"DEV_MODE": False,
"FEED_CACHE_SECONDS": 0, # Disable caching for tests
"FEED_MAX_ITEMS": 50,
}
app = create_app(config=test_config)
# Create test notes
with app.app_context():
create_note(content='Test content 1', published=True, custom_slug='test-note-1')
create_note(content='Test content 2', published=True, custom_slug='test-note-2')
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
class TestExplicitEndpoints:
"""Tests for explicit format endpoints"""
def test_feed_rss_endpoint(self, client):
"""GET /feed.rss returns RSS feed"""
response = client.get('/feed.rss')
assert response.status_code == 200
assert response.headers['Content-Type'] == 'application/rss+xml; charset=utf-8'
assert b'<?xml version="1.0" encoding="UTF-8"?>' in response.data
assert b'<rss version="2.0"' in response.data
def test_feed_atom_endpoint(self, client):
"""GET /feed.atom returns ATOM feed"""
response = client.get('/feed.atom')
assert response.status_code == 200
assert response.headers['Content-Type'] == 'application/atom+xml; charset=utf-8'
# Check for XML declaration (encoding may be utf-8 or UTF-8)
assert b'<?xml version="1.0"' in response.data
assert b'<feed xmlns="http://www.w3.org/2005/Atom"' in response.data
def test_feed_json_endpoint(self, client):
"""GET /feed.json returns JSON Feed"""
response = client.get('/feed.json')
assert response.status_code == 200
assert response.headers['Content-Type'] == 'application/feed+json; charset=utf-8'
# JSON Feed is streamed, so we need to collect all chunks
data = b''.join(response.response)
assert b'"version": "https://jsonfeed.org/version/1.1"' in data
assert b'"title":' in data
def test_feed_xml_legacy_endpoint(self, client):
"""GET /feed.xml returns RSS feed (backward compatibility)"""
response = client.get('/feed.xml')
assert response.status_code == 200
assert response.headers['Content-Type'] == 'application/rss+xml; charset=utf-8'
assert b'<?xml version="1.0" encoding="UTF-8"?>' in response.data
assert b'<rss version="2.0"' in response.data
class TestContentNegotiation:
"""Tests for /feed content negotiation endpoint"""
def test_accept_rss(self, client):
"""Accept: application/rss+xml returns RSS"""
response = client.get('/feed', headers={'Accept': 'application/rss+xml'})
assert response.status_code == 200
assert response.headers['Content-Type'] == 'application/rss+xml; charset=utf-8'
assert b'<rss version="2.0"' in response.data
def test_accept_atom(self, client):
"""Accept: application/atom+xml returns ATOM"""
response = client.get('/feed', headers={'Accept': 'application/atom+xml'})
assert response.status_code == 200
assert response.headers['Content-Type'] == 'application/atom+xml; charset=utf-8'
assert b'<feed xmlns="http://www.w3.org/2005/Atom"' in response.data
def test_accept_json_feed(self, client):
"""Accept: application/feed+json returns JSON Feed"""
response = client.get('/feed', headers={'Accept': 'application/feed+json'})
assert response.status_code == 200
assert response.headers['Content-Type'] == 'application/feed+json; charset=utf-8'
data = b''.join(response.response)
assert b'"version": "https://jsonfeed.org/version/1.1"' in data
def test_accept_json_generic(self, client):
"""Accept: application/json returns JSON Feed"""
response = client.get('/feed', headers={'Accept': 'application/json'})
assert response.status_code == 200
assert response.headers['Content-Type'] == 'application/feed+json; charset=utf-8'
data = b''.join(response.response)
assert b'"version": "https://jsonfeed.org/version/1.1"' in data
def test_accept_wildcard(self, client):
"""Accept: */* returns RSS (default)"""
response = client.get('/feed', headers={'Accept': '*/*'})
assert response.status_code == 200
assert response.headers['Content-Type'] == 'application/rss+xml; charset=utf-8'
assert b'<rss version="2.0"' in response.data
def test_no_accept_header(self, client):
"""No Accept header defaults to RSS"""
response = client.get('/feed')
assert response.status_code == 200
assert response.headers['Content-Type'] == 'application/rss+xml; charset=utf-8'
assert b'<rss version="2.0"' in response.data
def test_quality_factor_atom_wins(self, client):
"""Higher quality factor wins"""
response = client.get('/feed', headers={
'Accept': 'application/atom+xml;q=0.9, application/rss+xml;q=0.5'
})
assert response.status_code == 200
assert response.headers['Content-Type'] == 'application/atom+xml; charset=utf-8'
def test_quality_factor_json_wins(self, client):
"""JSON with highest quality wins"""
response = client.get('/feed', headers={
'Accept': 'application/json;q=1.0, application/atom+xml;q=0.8'
})
assert response.status_code == 200
assert response.headers['Content-Type'] == 'application/feed+json; charset=utf-8'
def test_browser_accept_header(self, client):
"""Browser-like Accept header returns RSS"""
response = client.get('/feed', headers={
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
})
assert response.status_code == 200
assert response.headers['Content-Type'] == 'application/rss+xml; charset=utf-8'
def test_no_acceptable_format(self, client):
"""No acceptable format returns 406"""
response = client.get('/feed', headers={'Accept': 'text/html'})
assert response.status_code == 406
assert response.headers['Content-Type'] == 'text/plain; charset=utf-8'
assert 'X-Available-Formats' in response.headers
assert 'application/rss+xml' in response.headers['X-Available-Formats']
assert 'application/atom+xml' in response.headers['X-Available-Formats']
assert 'application/feed+json' in response.headers['X-Available-Formats']
assert b'Not Acceptable' in response.data
class TestCacheHeaders:
"""Tests for cache control headers"""
def test_rss_cache_header(self, client):
"""RSS feed includes Cache-Control header"""
response = client.get('/feed.rss')
assert 'Cache-Control' in response.headers
# FEED_CACHE_SECONDS is 0 in test config
assert 'max-age=0' in response.headers['Cache-Control']
def test_atom_cache_header(self, client):
"""ATOM feed includes Cache-Control header"""
response = client.get('/feed.atom')
assert 'Cache-Control' in response.headers
assert 'max-age=0' in response.headers['Cache-Control']
def test_json_cache_header(self, client):
"""JSON Feed includes Cache-Control header"""
response = client.get('/feed.json')
assert 'Cache-Control' in response.headers
assert 'max-age=0' in response.headers['Cache-Control']
class TestFeedContent:
"""Tests for feed content correctness"""
def test_rss_contains_notes(self, client):
"""RSS feed contains test notes"""
response = client.get('/feed.rss')
assert b'test-note-1' in response.data
assert b'test-note-2' in response.data
assert b'Test content 1' in response.data
assert b'Test content 2' in response.data
def test_atom_contains_notes(self, client):
"""ATOM feed contains test notes"""
response = client.get('/feed.atom')
assert b'test-note-1' in response.data
assert b'test-note-2' in response.data
assert b'Test content 1' in response.data
assert b'Test content 2' in response.data
def test_json_contains_notes(self, client):
"""JSON Feed contains test notes"""
response = client.get('/feed.json')
data = b''.join(response.response)
assert b'test-note-1' in data
assert b'test-note-2' in data
assert b'Test content 1' in data
assert b'Test content 2' in data
class TestBackwardCompatibility:
"""Tests for backward compatibility"""
def test_feed_xml_same_as_feed_rss(self, client):
"""GET /feed.xml returns same content as /feed.rss"""
rss_response = client.get('/feed.rss')
xml_response = client.get('/feed.xml')
assert rss_response.status_code == xml_response.status_code
assert rss_response.headers['Content-Type'] == xml_response.headers['Content-Type']
# Content should be identical
assert rss_response.data == xml_response.data
def test_feed_xml_contains_rss(self, client):
"""GET /feed.xml contains RSS XML"""
response = client.get('/feed.xml')
assert b'<?xml version="1.0" encoding="UTF-8"?>' in response.data
assert b'<rss version="2.0"' in response.data
assert b'</rss>' in response.data