## 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>
572 lines
20 KiB
Python
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
|