Implement Phase 4 of v1.4.0 Media release - Enhanced Feed Media support. RSS Feed Enhancements (starpunk/feeds/rss.py): - Wrap size variants in <media:group> elements - Add <media:content> for large/medium/small variants with attributes: url, type, medium, isDefault, width, height, fileSize - Add <media:thumbnail> for thumb variant with dimensions - Add <media:title type="plain"> for image captions - Implement isDefault logic: largest available variant (large→medium→small fallback) - Maintain backwards compatibility for media without variants (legacy fallback) JSON Feed Enhancements (starpunk/feeds/json_feed.py): - Add _starpunk.about URL (configurable via STARPUNK_ABOUT_URL config) - Add _starpunk.media_variants array with variant data when variants exist - Each variant entry includes: url, width, height, size_in_bytes, mime_type ATOM Feed Enhancements (starpunk/feeds/atom.py): - Add title attribute to enclosure links for captions - Keep simple (no variants in ATOM per design decision) Test Updates (tests/test_feeds_rss.py): - Update streaming media test to search descendants for media:content - Now inside media:group for images with variants (v1.4.0 behavior) Per design document: /docs/design/v1.4.0/media-implementation-design.md Following ADR-059: Full Feed Media Standardization Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
574 lines
20 KiB
Python
574 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 (v1.4.0 with variants)"""
|
|
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")
|
|
|
|
# v1.4.0: media:content is now inside media:group for images with variants
|
|
# Use // to search descendants, not direct children
|
|
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
|