Files
StarPunk/tests/test_feeds_json.py
Phil Skentelbery 59e9d402c6 feat: Implement Phase 2 Feed Formats - ATOM, JSON Feed, RSS fix (Phases 2.0-2.3)
This commit implements the first three phases of v1.1.2 Phase 2 Feed Formats,
adding ATOM 1.0 and JSON Feed 1.1 support alongside the existing RSS feed.

CRITICAL BUG FIX:
- Fixed RSS streaming feed ordering (was showing oldest-first instead of newest-first)
- Streaming RSS removed incorrect reversed() call at line 198
- Feedgen RSS kept correct reversed() to compensate for library behavior

NEW FEATURES:
- ATOM 1.0 feed generation (RFC 4287 compliant)
  - Proper XML namespacing and RFC 3339 dates
  - Streaming and non-streaming methods
  - 11 comprehensive tests

- JSON Feed 1.1 generation (JSON Feed spec compliant)
  - RFC 3339 dates and UTF-8 JSON output
  - Custom _starpunk extension with permalink_path and word_count
  - 13 comprehensive tests

REFACTORING:
- Restructured feed code into starpunk/feeds/ module
  - feeds/rss.py - RSS 2.0 (moved from feed.py)
  - feeds/atom.py - ATOM 1.0 (new)
  - feeds/json_feed.py - JSON Feed 1.1 (new)
- Backward compatible feed.py shim for existing imports
- Business metrics integrated into all feed generators

TESTING:
- Created shared test helper tests/helpers/feed_ordering.py
- Helper validates newest-first ordering across all formats
- 48 total feed tests, all passing
  - RSS: 24 tests
  - ATOM: 11 tests
  - JSON Feed: 13 tests

FILES CHANGED:
- Modified: starpunk/feed.py (now compatibility shim)
- New: starpunk/feeds/ module with rss.py, atom.py, json_feed.py
- New: tests/helpers/feed_ordering.py (shared test helper)
- New: tests/test_feeds_atom.py, tests/test_feeds_json.py
- Modified: CHANGELOG.md (Phase 2 entries)
- New: docs/reports/2025-11-26-v1.1.2-phase2-feed-formats-partial.md

NEXT STEPS:
Phase 2.4 (Content Negotiation) pending - will add /feed endpoint with
Accept header negotiation and explicit format endpoints.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 14:54:52 -07:00

315 lines
11 KiB
Python

"""
Tests for JSON Feed generation module
Tests cover:
- JSON Feed generation with various note counts
- RFC 3339 date formatting
- Feed structure and required fields
- Entry ordering (newest first)
- JSON validity
"""
import pytest
from datetime import datetime, timezone
import json
import time
from starpunk import create_app
from starpunk.feeds.json_feed import generate_json_feed, generate_json_feed_streaming
from starpunk.notes import create_note, list_notes
from tests.helpers.feed_ordering import assert_feed_newest_first
@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,
}
app = create_app(config=test_config)
yield app
@pytest.fixture
def sample_notes(app):
"""Create sample published notes"""
with app.app_context():
notes = []
for i in range(5):
note = create_note(
content=f"# Test Note {i}\n\nThis is test content for note {i}.",
published=True,
)
notes.append(note)
time.sleep(0.01) # Ensure distinct timestamps
return list_notes(published_only=True, limit=10)
class TestGenerateJsonFeed:
"""Test generate_json_feed() function"""
def test_generate_json_feed_basic(self, app, sample_notes):
"""Test basic JSON Feed generation with notes"""
with app.app_context():
feed_json = generate_json_feed(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=sample_notes,
)
# Should return JSON string
assert isinstance(feed_json, str)
# Parse JSON to verify structure
feed = json.loads(feed_json)
# Check required fields
assert feed["version"] == "https://jsonfeed.org/version/1.1"
assert feed["title"] == "Test Blog"
assert "items" in feed
assert isinstance(feed["items"], list)
# Check items (should have 5 items)
assert len(feed["items"]) == 5
def test_generate_json_feed_empty(self, app):
"""Test JSON Feed generation with no notes"""
with app.app_context():
feed_json = generate_json_feed(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[],
)
# Should still generate valid JSON
feed = json.loads(feed_json)
assert feed["items"] == []
def test_generate_json_feed_respects_limit(self, app, sample_notes):
"""Test JSON Feed respects item limit"""
with app.app_context():
feed_json = generate_json_feed(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=sample_notes,
limit=3,
)
feed = json.loads(feed_json)
# Should only have 3 items (respecting limit)
assert len(feed["items"]) == 3
def test_generate_json_feed_newest_first(self, app):
"""Test JSON Feed displays notes in newest-first order"""
with app.app_context():
# Create notes with distinct timestamps
for i in range(3):
create_note(
content=f"# Note {i}\n\nContent {i}.",
published=True,
)
time.sleep(0.01)
# Get notes from database (should be DESC = newest first)
notes = list_notes(published_only=True, limit=10)
# Generate feed
feed_json = generate_json_feed(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=notes,
)
# Use shared helper to verify ordering
assert_feed_newest_first(feed_json, format_type='json', expected_count=3)
# Also verify manually with JSON parsing
feed = json.loads(feed_json)
items = feed["items"]
# First item should be newest (Note 2)
# Last item should be oldest (Note 0)
assert "Note 2" in items[0]["title"]
assert "Note 0" in items[-1]["title"]
def test_generate_json_feed_requires_site_url(self):
"""Test JSON Feed generation requires site_url"""
with pytest.raises(ValueError, match="site_url is required"):
generate_json_feed(
site_url="",
site_name="Test Blog",
site_description="A test blog",
notes=[],
)
def test_generate_json_feed_requires_site_name(self):
"""Test JSON Feed generation requires site_name"""
with pytest.raises(ValueError, match="site_name is required"):
generate_json_feed(
site_url="https://example.com",
site_name="",
site_description="A test blog",
notes=[],
)
def test_generate_json_feed_item_structure(self, app, sample_notes):
"""Test individual JSON Feed item has all required fields"""
with app.app_context():
feed_json = generate_json_feed(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=sample_notes[:1],
)
feed = json.loads(feed_json)
item = feed["items"][0]
# Check required item fields
assert "id" in item
assert "url" in item
assert "title" in item
assert "date_published" in item
# Check either content_html or content_text is present
assert "content_html" in item or "content_text" in item
def test_generate_json_feed_html_content(self, app):
"""Test JSON Feed includes HTML content"""
with app.app_context():
note = create_note(
content="# Test\n\nThis is **bold** and *italic*.",
published=True,
)
feed_json = generate_json_feed(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note],
)
feed = json.loads(feed_json)
item = feed["items"][0]
# Should have content_html
assert "content_html" in item
content = item["content_html"]
# Should contain HTML tags
assert "<strong>" in content or "<em>" in content
def test_generate_json_feed_starpunk_extension(self, app, sample_notes):
"""Test JSON Feed includes StarPunk custom extension"""
with app.app_context():
feed_json = generate_json_feed(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=sample_notes[:1],
)
feed = json.loads(feed_json)
item = feed["items"][0]
# Should have _starpunk extension
assert "_starpunk" in item
assert "permalink_path" in item["_starpunk"]
assert "word_count" in item["_starpunk"]
def test_generate_json_feed_date_format(self, app, sample_notes):
"""Test JSON Feed uses RFC 3339 date format"""
with app.app_context():
feed_json = generate_json_feed(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=sample_notes[:1],
)
feed = json.loads(feed_json)
item = feed["items"][0]
# date_published should be in RFC 3339 format
date_str = item["date_published"]
# Should end with 'Z' for UTC or have timezone offset
assert date_str.endswith("Z") or "+" in date_str or "-" in date_str[-6:]
# Should be parseable as ISO 8601
parsed = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
assert parsed.tzinfo is not None
class TestGenerateJsonFeedStreaming:
"""Test generate_json_feed_streaming() function"""
def test_generate_json_feed_streaming_basic(self, app, sample_notes):
"""Test streaming JSON Feed generation"""
with app.app_context():
generator = generate_json_feed_streaming(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=sample_notes,
)
# Collect all chunks
chunks = list(generator)
assert len(chunks) > 0
# Join and verify valid JSON
feed_json = ''.join(chunks)
feed = json.loads(feed_json)
assert len(feed["items"]) == 5
def test_generate_json_feed_streaming_yields_chunks(self, app, sample_notes):
"""Test streaming yields multiple chunks"""
with app.app_context():
generator = generate_json_feed_streaming(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=sample_notes,
limit=3,
)
chunks = list(generator)
# Should have multiple chunks (at least opening + items + closing)
assert len(chunks) >= 3
def test_generate_json_feed_streaming_valid_json(self, app, sample_notes):
"""Test streaming produces valid JSON"""
with app.app_context():
generator = generate_json_feed_streaming(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=sample_notes,
)
feed_json = ''.join(generator)
# Should be valid JSON
feed = json.loads(feed_json)
assert feed["version"] == "https://jsonfeed.org/version/1.1"