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>
281 lines
9.7 KiB
Python
281 lines
9.7 KiB
Python
"""
|
|
Tests for feed content negotiation
|
|
|
|
This module tests the content negotiation functionality for determining
|
|
which feed format to serve based on HTTP Accept headers.
|
|
"""
|
|
|
|
import pytest
|
|
from starpunk.feeds.negotiation import (
|
|
negotiate_feed_format,
|
|
get_mime_type,
|
|
_parse_accept_header,
|
|
_score_format,
|
|
MIME_TYPES,
|
|
)
|
|
|
|
|
|
class TestParseAcceptHeader:
|
|
"""Tests for Accept header parsing"""
|
|
|
|
def test_single_type(self):
|
|
"""Parse single media type without quality"""
|
|
result = _parse_accept_header('application/json')
|
|
assert result == [('application/json', 1.0)]
|
|
|
|
def test_multiple_types(self):
|
|
"""Parse multiple media types"""
|
|
result = _parse_accept_header('application/json, text/html')
|
|
assert len(result) == 2
|
|
assert ('application/json', 1.0) in result
|
|
assert ('text/html', 1.0) in result
|
|
|
|
def test_quality_factors(self):
|
|
"""Parse quality factors correctly"""
|
|
result = _parse_accept_header('application/json;q=0.9, text/html;q=0.8')
|
|
assert result == [('application/json', 0.9), ('text/html', 0.8)]
|
|
|
|
def test_quality_sorting(self):
|
|
"""Media types sorted by quality (highest first)"""
|
|
result = _parse_accept_header('text/html;q=0.5, application/json;q=0.9')
|
|
assert result[0] == ('application/json', 0.9)
|
|
assert result[1] == ('text/html', 0.5)
|
|
|
|
def test_default_quality_1_0(self):
|
|
"""Media type without quality defaults to 1.0"""
|
|
result = _parse_accept_header('application/json;q=0.8, text/html')
|
|
assert result[0] == ('text/html', 1.0)
|
|
assert result[1] == ('application/json', 0.8)
|
|
|
|
def test_wildcard(self):
|
|
"""Parse wildcard */* correctly"""
|
|
result = _parse_accept_header('*/*')
|
|
assert result == [('*/*', 1.0)]
|
|
|
|
def test_wildcard_with_quality(self):
|
|
"""Parse wildcard with quality factor"""
|
|
result = _parse_accept_header('application/json, */*;q=0.1')
|
|
assert result == [('application/json', 1.0), ('*/*', 0.1)]
|
|
|
|
def test_whitespace_handling(self):
|
|
"""Handle whitespace around commas and semicolons"""
|
|
result = _parse_accept_header('application/json ; q=0.9 , text/html')
|
|
assert len(result) == 2
|
|
assert ('application/json', 0.9) in result
|
|
assert ('text/html', 1.0) in result
|
|
|
|
def test_empty_string(self):
|
|
"""Handle empty Accept header"""
|
|
result = _parse_accept_header('')
|
|
assert result == []
|
|
|
|
def test_invalid_quality(self):
|
|
"""Invalid quality factor defaults to 1.0"""
|
|
result = _parse_accept_header('application/json;q=invalid')
|
|
assert result == [('application/json', 1.0)]
|
|
|
|
def test_quality_clamping(self):
|
|
"""Quality factors clamped to 0-1 range"""
|
|
result = _parse_accept_header('application/json;q=1.5')
|
|
assert result == [('application/json', 1.0)]
|
|
|
|
def test_type_wildcard(self):
|
|
"""Parse type wildcard application/* correctly"""
|
|
result = _parse_accept_header('application/*')
|
|
assert result == [('application/*', 1.0)]
|
|
|
|
|
|
class TestScoreFormat:
|
|
"""Tests for format scoring"""
|
|
|
|
def test_exact_match(self):
|
|
"""Exact MIME type match gets full quality"""
|
|
media_types = [('application/atom+xml', 1.0)]
|
|
score = _score_format('atom', media_types)
|
|
assert score == 1.0
|
|
|
|
def test_wildcard_match(self):
|
|
"""Wildcard */* matches any format"""
|
|
media_types = [('*/*', 0.8)]
|
|
score = _score_format('rss', media_types)
|
|
assert score == 0.8
|
|
|
|
def test_type_wildcard_match(self):
|
|
"""Type wildcard application/* matches application types"""
|
|
media_types = [('application/*', 0.9)]
|
|
score = _score_format('atom', media_types)
|
|
assert score == 0.9
|
|
|
|
def test_no_match(self):
|
|
"""No matching media type returns 0"""
|
|
media_types = [('text/html', 1.0)]
|
|
score = _score_format('rss', media_types)
|
|
assert score == 0.0
|
|
|
|
def test_best_quality_wins(self):
|
|
"""Return highest quality among matches"""
|
|
media_types = [
|
|
('*/*', 0.5),
|
|
('application/*', 0.8),
|
|
('application/rss+xml', 1.0),
|
|
]
|
|
score = _score_format('rss', media_types)
|
|
assert score == 1.0
|
|
|
|
def test_invalid_format(self):
|
|
"""Invalid format name returns 0"""
|
|
media_types = [('*/*', 1.0)]
|
|
score = _score_format('invalid', media_types)
|
|
assert score == 0.0
|
|
|
|
|
|
class TestNegotiateFeedFormat:
|
|
"""Tests for feed format negotiation"""
|
|
|
|
def test_rss_exact_match(self):
|
|
"""Exact match for RSS"""
|
|
result = negotiate_feed_format('application/rss+xml', ['rss', 'atom', 'json'])
|
|
assert result == 'rss'
|
|
|
|
def test_atom_exact_match(self):
|
|
"""Exact match for ATOM"""
|
|
result = negotiate_feed_format('application/atom+xml', ['rss', 'atom', 'json'])
|
|
assert result == 'atom'
|
|
|
|
def test_json_feed_exact_match(self):
|
|
"""Exact match for JSON Feed"""
|
|
result = negotiate_feed_format('application/feed+json', ['rss', 'atom', 'json'])
|
|
assert result == 'json'
|
|
|
|
def test_json_generic_match(self):
|
|
"""Generic application/json matches JSON Feed"""
|
|
result = negotiate_feed_format('application/json', ['rss', 'atom', 'json'])
|
|
assert result == 'json'
|
|
|
|
def test_wildcard_defaults_to_rss(self):
|
|
"""Wildcard */* defaults to RSS"""
|
|
result = negotiate_feed_format('*/*', ['rss', 'atom', 'json'])
|
|
assert result == 'rss'
|
|
|
|
def test_quality_factor_selection(self):
|
|
"""Higher quality factor wins"""
|
|
result = negotiate_feed_format(
|
|
'application/atom+xml;q=0.9, application/rss+xml;q=0.5',
|
|
['rss', 'atom', 'json']
|
|
)
|
|
assert result == 'atom'
|
|
|
|
def test_tie_prefers_rss(self):
|
|
"""On quality tie, prefer RSS"""
|
|
result = negotiate_feed_format(
|
|
'application/atom+xml;q=0.9, application/rss+xml;q=0.9',
|
|
['rss', 'atom', 'json']
|
|
)
|
|
assert result == 'rss'
|
|
|
|
def test_tie_prefers_atom_over_json(self):
|
|
"""On quality tie, prefer ATOM over JSON"""
|
|
result = negotiate_feed_format(
|
|
'application/atom+xml;q=0.9, application/feed+json;q=0.9',
|
|
['atom', 'json']
|
|
)
|
|
assert result == 'atom'
|
|
|
|
def test_no_acceptable_format_raises(self):
|
|
"""No acceptable format raises ValueError"""
|
|
with pytest.raises(ValueError, match="No acceptable format found"):
|
|
negotiate_feed_format('text/html', ['rss', 'atom', 'json'])
|
|
|
|
def test_only_rss_available(self):
|
|
"""Negotiate when only RSS is available"""
|
|
result = negotiate_feed_format('application/rss+xml', ['rss'])
|
|
assert result == 'rss'
|
|
|
|
def test_wildcard_with_limited_formats(self):
|
|
"""Wildcard picks RSS even if not first in list"""
|
|
result = negotiate_feed_format('*/*', ['atom', 'json', 'rss'])
|
|
assert result == 'rss'
|
|
|
|
def test_complex_accept_header(self):
|
|
"""Complex Accept header with multiple types and qualities"""
|
|
result = negotiate_feed_format(
|
|
'text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8',
|
|
['rss', 'atom', 'json']
|
|
)
|
|
# application/xml doesn't match, so falls back to */* which gives RSS
|
|
assert result == 'rss'
|
|
|
|
def test_browser_like_accept(self):
|
|
"""Browser-like Accept header defaults to RSS"""
|
|
result = negotiate_feed_format(
|
|
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
['rss', 'atom', 'json']
|
|
)
|
|
assert result == 'rss'
|
|
|
|
def test_feed_reader_accept(self):
|
|
"""Feed reader requesting ATOM"""
|
|
result = negotiate_feed_format(
|
|
'application/atom+xml, application/rss+xml;q=0.9',
|
|
['rss', 'atom', 'json']
|
|
)
|
|
assert result == 'atom'
|
|
|
|
def test_json_api_client(self):
|
|
"""JSON API client requesting JSON"""
|
|
result = negotiate_feed_format(
|
|
'application/json, */*;q=0.1',
|
|
['rss', 'atom', 'json']
|
|
)
|
|
assert result == 'json'
|
|
|
|
def test_type_wildcard_application(self):
|
|
"""application/* matches all feed formats, prefers RSS"""
|
|
result = negotiate_feed_format(
|
|
'application/*',
|
|
['rss', 'atom', 'json']
|
|
)
|
|
assert result == 'rss'
|
|
|
|
def test_empty_accept_header(self):
|
|
"""Empty Accept header raises ValueError"""
|
|
with pytest.raises(ValueError, match="No acceptable format found"):
|
|
negotiate_feed_format('', ['rss', 'atom', 'json'])
|
|
|
|
|
|
class TestGetMimeType:
|
|
"""Tests for get_mime_type helper"""
|
|
|
|
def test_rss_mime_type(self):
|
|
"""Get MIME type for RSS"""
|
|
assert get_mime_type('rss') == 'application/rss+xml'
|
|
|
|
def test_atom_mime_type(self):
|
|
"""Get MIME type for ATOM"""
|
|
assert get_mime_type('atom') == 'application/atom+xml'
|
|
|
|
def test_json_mime_type(self):
|
|
"""Get MIME type for JSON Feed"""
|
|
assert get_mime_type('json') == 'application/feed+json'
|
|
|
|
def test_invalid_format(self):
|
|
"""Invalid format raises ValueError"""
|
|
with pytest.raises(ValueError, match="Unknown format"):
|
|
get_mime_type('invalid')
|
|
|
|
|
|
class TestMimeTypeConstants:
|
|
"""Tests for MIME type constant mappings"""
|
|
|
|
def test_mime_types_defined(self):
|
|
"""All expected MIME types are defined"""
|
|
assert 'rss' in MIME_TYPES
|
|
assert 'atom' in MIME_TYPES
|
|
assert 'json' in MIME_TYPES
|
|
|
|
def test_mime_type_values(self):
|
|
"""MIME type values are correct"""
|
|
assert MIME_TYPES['rss'] == 'application/rss+xml'
|
|
assert MIME_TYPES['atom'] == 'application/atom+xml'
|
|
assert MIME_TYPES['json'] == 'application/feed+json'
|