""" 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 ' 0