Files
StarPunk/tests/test_feeds_rss.py
Phil Skentelbery 27501f6381 feat: v1.2.0-rc.2 - Media display fixes and feed enhancements
## Added
- Feed Media Enhancement with Media RSS namespace support
  - RSS enclosure, media:content, media:thumbnail elements
  - JSON Feed image field for first image
- ADR-059: Full feed media standardization roadmap

## Fixed
- Media display on homepage (was only showing on note pages)
- Responsive image sizing with CSS constraints
- Caption display (now alt text only, not visible)
- Logging correlation ID crash in non-request contexts

## Documentation
- Feed media design documents and implementation reports
- Media display fixes design and validation reports
- Updated ROADMAP with v1.3.0/v1.4.0 media plans

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 14:58:37 -07:00

572 lines
20 KiB
Python

"""
Tests for RSS 2.0 with Media RSS extension
Tests cover:
- Media RSS namespace declaration
- RSS enclosure element (first image only)
- Media RSS content elements (all images)
- Media RSS thumbnail (first image)
- Items without media have no media elements
- JSON Feed image field
- JSON Feed omits image when no media
"""
import pytest
from datetime import datetime, timezone
from xml.etree import ElementTree as ET
import json
import time
from pathlib import Path
from PIL import Image
import io
from starpunk import create_app
from starpunk.feeds.rss import generate_rss, generate_rss_streaming
from starpunk.feeds.json_feed import generate_json_feed, generate_json_feed_streaming
from starpunk.notes import create_note, list_notes
from starpunk.media import save_media, attach_media_to_note
def create_test_image(width=800, height=600, format='PNG'):
"""
Generate test image using PIL
Args:
width: Image width in pixels
height: Image height in pixels
format: Image format (PNG, JPEG, GIF, WEBP)
Returns:
Bytes of image data
"""
img = Image.new('RGB', (width, height), color='blue')
buffer = io.BytesIO()
img.save(buffer, format=format)
buffer.seek(0)
return buffer.getvalue()
@pytest.fixture
def app(tmp_path):
"""Create test application"""
test_data_dir = tmp_path / "data"
test_data_dir.mkdir(parents=True, exist_ok=True)
# Create media directory
test_media_dir = test_data_dir / "media"
test_media_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",
"MEDIA_PATH": test_media_dir,
"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 note_with_single_media(app):
"""Create note with single image attachment"""
from starpunk.media import get_note_media
with app.app_context():
# Create note
note = create_note(
content="# Test Note\n\nNote with one image.",
published=True
)
# Create and attach media
image_data = create_test_image(800, 600, 'JPEG')
media_info = save_media(image_data, 'test-image.jpg')
attach_media_to_note(note.id, [media_info['id']], ['Test caption'])
# Reload note and attach media
notes = list_notes(published_only=True, limit=1)
note = notes[0]
media = get_note_media(note.id)
object.__setattr__(note, 'media', media)
return note
@pytest.fixture
def note_with_multiple_media(app):
"""Create note with multiple image attachments"""
from starpunk.media import get_note_media
with app.app_context():
# Create note
note = create_note(
content="# Gallery Note\n\nNote with three images.",
published=True
)
# Create and attach multiple media
media_ids = []
captions = []
for i in range(3):
image_data = create_test_image(800, 600, 'JPEG')
media_info = save_media(image_data, f'image-{i}.jpg')
media_ids.append(media_info['id'])
captions.append(f'Caption {i}')
attach_media_to_note(note.id, media_ids, captions)
# Reload note and attach media
notes = list_notes(published_only=True, limit=1)
note = notes[0]
media = get_note_media(note.id)
object.__setattr__(note, 'media', media)
return note
@pytest.fixture
def note_without_media(app):
"""Create note without media"""
with app.app_context():
note = create_note(
content="# Plain Note\n\nNote without images.",
published=True
)
notes = list_notes(published_only=True, limit=1)
return notes[0]
class TestRSSMediaNamespace:
"""Test RSS feed has Media RSS namespace"""
def test_rss_has_media_namespace(self, app, note_with_single_media):
"""RSS feed should declare Media RSS namespace"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
# Check for Media RSS namespace declaration
assert 'xmlns:media="http://search.yahoo.com/mrss/"' in feed_xml
def test_rss_streaming_has_media_namespace(self, app, note_with_single_media):
"""Streaming RSS feed should declare Media RSS namespace"""
with app.app_context():
generator = generate_rss_streaming(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
feed_xml = ''.join(generator)
# Check for Media RSS namespace declaration
assert 'xmlns:media="http://search.yahoo.com/mrss/"' in feed_xml
class TestRSSEnclosure:
"""Test RSS enclosure element for first image"""
def test_rss_enclosure_for_single_media(self, app, note_with_single_media):
"""RSS item should include enclosure element for first image"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
# Parse XML
root = ET.fromstring(feed_xml)
channel = root.find("channel")
item = channel.find("item")
enclosure = item.find("enclosure")
# Should have enclosure
assert enclosure is not None
# Check attributes
assert enclosure.get("url") is not None
assert "https://example.com/media/" in enclosure.get("url")
assert enclosure.get("type") == "image/jpeg"
assert enclosure.get("length") is not None
def test_rss_enclosure_first_image_only(self, app, note_with_multiple_media):
"""RSS item should include only one enclosure (first image)"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_multiple_media]
)
# Parse XML
root = ET.fromstring(feed_xml)
channel = root.find("channel")
item = channel.find("item")
enclosures = item.findall("enclosure")
# Should have exactly one enclosure (RSS 2.0 spec)
assert len(enclosures) == 1
def test_rss_no_enclosure_without_media(self, app, note_without_media):
"""RSS item without media should have no enclosure"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_without_media]
)
# Parse XML
root = ET.fromstring(feed_xml)
channel = root.find("channel")
item = channel.find("item")
enclosure = item.find("enclosure")
# Should have no enclosure
assert enclosure is None
class TestRSSMediaContent:
"""Test Media RSS content elements"""
def test_rss_media_content_for_single_image(self, app, note_with_single_media):
"""RSS item should include media:content for image"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
# Parse XML with namespace
root = ET.fromstring(feed_xml)
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
channel = root.find("channel")
item = channel.find("item")
media_contents = item.findall("media:content", namespaces)
# Should have one media:content element
assert len(media_contents) == 1
# Check attributes
media_content = media_contents[0]
assert media_content.get("url") is not None
assert "https://example.com/media/" in media_content.get("url")
assert media_content.get("type") == "image/jpeg"
assert media_content.get("medium") == "image"
def test_rss_media_content_for_multiple_images(self, app, note_with_multiple_media):
"""RSS item should include media:content for each image"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_multiple_media]
)
# Parse XML with namespace
root = ET.fromstring(feed_xml)
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
channel = root.find("channel")
item = channel.find("item")
media_contents = item.findall("media:content", namespaces)
# Should have three media:content elements
assert len(media_contents) == 3
def test_rss_no_media_content_without_media(self, app, note_without_media):
"""RSS item without media should have no media:content elements"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_without_media]
)
# Parse XML with namespace
root = ET.fromstring(feed_xml)
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
channel = root.find("channel")
item = channel.find("item")
media_contents = item.findall("media:content", namespaces)
# Should have no media:content elements
assert len(media_contents) == 0
class TestRSSMediaThumbnail:
"""Test Media RSS thumbnail element"""
def test_rss_media_thumbnail_for_first_image(self, app, note_with_single_media):
"""RSS item should include media:thumbnail for first image"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
# Parse XML with namespace
root = ET.fromstring(feed_xml)
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
channel = root.find("channel")
item = channel.find("item")
media_thumbnail = item.find("media:thumbnail", namespaces)
# Should have media:thumbnail
assert media_thumbnail is not None
assert media_thumbnail.get("url") is not None
assert "https://example.com/media/" in media_thumbnail.get("url")
def test_rss_media_thumbnail_only_one(self, app, note_with_multiple_media):
"""RSS item should include only one media:thumbnail (first image)"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_multiple_media]
)
# Parse XML with namespace
root = ET.fromstring(feed_xml)
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
channel = root.find("channel")
item = channel.find("item")
media_thumbnails = item.findall("media:thumbnail", namespaces)
# Should have exactly one media:thumbnail
assert len(media_thumbnails) == 1
def test_rss_no_media_thumbnail_without_media(self, app, note_without_media):
"""RSS item without media should have no media:thumbnail"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_without_media]
)
# Parse XML with namespace
root = ET.fromstring(feed_xml)
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
channel = root.find("channel")
item = channel.find("item")
media_thumbnail = item.find("media:thumbnail", namespaces)
# Should have no media:thumbnail
assert media_thumbnail is None
class TestRSSStreamingMedia:
"""Test streaming RSS generation includes media elements"""
def test_rss_streaming_includes_enclosure(self, app, note_with_single_media):
"""Streaming RSS should include enclosure element"""
with app.app_context():
generator = generate_rss_streaming(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
feed_xml = ''.join(generator)
# Parse and check for enclosure
root = ET.fromstring(feed_xml)
channel = root.find("channel")
item = channel.find("item")
enclosure = item.find("enclosure")
assert enclosure is not None
def test_rss_streaming_includes_media_elements(self, app, note_with_single_media):
"""Streaming RSS should include media:content and media:thumbnail"""
with app.app_context():
generator = generate_rss_streaming(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
feed_xml = ''.join(generator)
# Parse and check for media elements
root = ET.fromstring(feed_xml)
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
channel = root.find("channel")
item = channel.find("item")
media_content = item.find("media:content", namespaces)
media_thumbnail = item.find("media:thumbnail", namespaces)
assert media_content is not None
assert media_thumbnail is not None
class TestJSONFeedImage:
"""Test JSON Feed image field"""
def test_json_feed_has_image_field(self, app, note_with_single_media):
"""JSON Feed item should include image field for first image"""
with app.app_context():
feed_json = generate_json_feed(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
feed = json.loads(feed_json)
item = feed["items"][0]
# Should have image field
assert "image" in item
assert item["image"] is not None
assert "https://example.com/media/" in item["image"]
def test_json_feed_image_uses_first_media(self, app, note_with_multiple_media):
"""JSON Feed image field should use first media item URL"""
from starpunk.media import get_note_media
with app.app_context():
feed_json = generate_json_feed(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_multiple_media]
)
feed = json.loads(feed_json)
item = feed["items"][0]
# Should have image field with first image
assert "image" in item
# Verify it's the first media item from the note
# (Media is saved with UUID filenames, so we can't check for "image-0.jpg")
media = note_with_multiple_media.media
first_media_path = media[0]['path']
assert first_media_path in item["image"]
def test_json_feed_no_image_field_without_media(self, app, note_without_media):
"""JSON Feed item without media should not have image field"""
with app.app_context():
feed_json = generate_json_feed(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_without_media]
)
feed = json.loads(feed_json)
item = feed["items"][0]
# Should NOT have image field (per Q7: absent, not null)
assert "image" not in item
def test_json_feed_streaming_has_image_field(self, app, note_with_single_media):
"""Streaming JSON Feed should include image field"""
with app.app_context():
generator = generate_json_feed_streaming(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
feed_json = ''.join(generator)
feed = json.loads(feed_json)
item = feed["items"][0]
# Should have image field
assert "image" in item
assert "https://example.com/media/" in item["image"]
def test_json_feed_streaming_no_image_without_media(self, app, note_without_media):
"""Streaming JSON Feed without media should omit image field"""
with app.app_context():
generator = generate_json_feed_streaming(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_without_media]
)
feed_json = ''.join(generator)
feed = json.loads(feed_json)
item = feed["items"][0]
# Should NOT have image field
assert "image" not in item
class TestFeedMediaIntegration:
"""Integration tests for media in feeds"""
def test_rss_media_and_html_both_present(self, app, note_with_single_media):
"""RSS should include both media elements AND HTML img tags"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
# Parse XML
root = ET.fromstring(feed_xml)
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
channel = root.find("channel")
item = channel.find("item")
# Should have media:content
media_content = item.find("media:content", namespaces)
assert media_content is not None
# Should also have HTML img in description
description = item.find("description").text
assert '<img' in description
def test_json_feed_image_and_attachments_both_present(self, app, note_with_single_media):
"""JSON Feed should include both image field AND attachments array"""
with app.app_context():
feed_json = generate_json_feed(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
feed = json.loads(feed_json)
item = feed["items"][0]
# Should have image field
assert "image" in item
# Should also have attachments array
assert "attachments" in item
assert len(item["attachments"]) > 0