Files
StarPunk/tests/test_feeds_negotiation.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

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'