Files
StarPunk/tests/test_microformats.py
Phil Skentelbery dd822a35b5 feat: v1.2.0-rc.1 - IndieWeb Features Release Candidate
Complete implementation of v1.2.0 "IndieWeb Features" release.

## Phase 1: Custom Slugs
- Optional custom slug field in note creation form
- Auto-sanitization (lowercase, hyphens only)
- Uniqueness validation with auto-numbering
- Read-only after creation to preserve permalinks
- Matches Micropub mp-slug behavior

## Phase 2: Author Discovery + Microformats2
- Automatic h-card discovery from IndieAuth identity URL
- 24-hour caching with graceful fallback
- Never blocks login (per ADR-061)
- Complete h-entry, h-card, h-feed markup
- All required Microformats2 properties
- rel-me links for identity verification
- Passes IndieWeb validation

## Phase 3: Media Upload
- Upload up to 4 images per note (JPEG, PNG, GIF, WebP)
- Automatic optimization with Pillow
  - Auto-resize to 2048px
  - EXIF orientation correction
  - 95% quality compression
- Social media-style layout (media top, text below)
- Optional captions for accessibility
- Integration with all feed formats (RSS, ATOM, JSON Feed)
- Date-organized storage with UUID filenames
- Immutable caching (1 year)

## Database Changes
- migrations/006_add_author_profile.sql - Author discovery cache
- migrations/007_add_media_support.sql - Media storage

## New Modules
- starpunk/author_discovery.py - h-card discovery and caching
- starpunk/media.py - Image upload, validation, optimization

## Documentation
- 4 new ADRs (056, 057, 058, 061)
- Complete design specifications
- Developer Q&A with 40+ questions answered
- 3 implementation reports
- 3 architect reviews (all approved)

## Testing
- 56 new tests for v1.2.0 features
- 842 total tests in suite
- All v1.2.0 feature tests passing

## Dependencies
- Added: mf2py (Microformats2 parser)
- Added: Pillow (image processing)

Version: 1.2.0-rc.1

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 15:02:20 -07:00

302 lines
12 KiB
Python

"""
Tests for Microformats2 markup in templates
Per v1.2.0 Phase 2 and developer Q&A Q31-Q33:
- Use mf2py to validate generated HTML
- Test h-entry, h-card, h-feed markup
- Ensure all required properties present
- Validate p-name only appears with explicit titles (per Q22)
"""
import mf2py
import pytest
from unittest.mock import patch
class TestNoteHEntry:
"""Test h-entry markup on individual note pages"""
def test_note_has_hentry_markup(self, client, app, sample_note):
"""Note page has h-entry container"""
# Sample note is already published, just get its slug
response = client.get(f'/note/{sample_note.slug}')
assert response.status_code == 200, f"Failed to load note at /note/{sample_note.slug}"
# Parse microformats
parsed = mf2py.parse(doc=response.data.decode(), url=f'http://localhost/note/{sample_note.slug}')
# Should have at least one h-entry
entries = [item for item in parsed['items'] if 'h-entry' in item.get('type', [])]
assert len(entries) >= 1
def test_hentry_has_required_properties(self, client, app, sample_note):
"""h-entry has all required Microformats2 properties"""
response = client.get(f'/note/{sample_note.slug}')
parsed = mf2py.parse(doc=response.data.decode(), url=f'http://localhost/note/{sample_note.slug}')
entry = [item for item in parsed['items'] if 'h-entry' in item.get('type', [])][0]
props = entry.get('properties', {})
# Required properties per spec
assert 'url' in props, "h-entry missing u-url"
assert 'published' in props, "h-entry missing dt-published"
assert 'content' in props, "h-entry missing e-content"
assert 'author' in props, "h-entry missing p-author"
def test_hentry_url_and_uid_match(self, client, app, sample_note):
"""u-url and u-uid are the same for notes (per Q23)"""
response = client.get(f'/note/{sample_note.slug}')
parsed = mf2py.parse(doc=response.data.decode(), url=f'http://localhost/note/{sample_note.slug}')
entry = [item for item in parsed['items'] if 'h-entry' in item.get('type', [])][0]
props = entry.get('properties', {})
# Both should exist and match
assert 'url' in props
assert 'uid' in props
assert props['url'][0] == props['uid'][0], "u-url and u-uid should match"
def test_hentry_pname_only_with_explicit_title(self, client, app, data_dir):
"""p-name only present when note has explicit title (per Q22)"""
from starpunk.notes import create_note
# Create note WITH heading (explicit title)
with app.app_context():
note_with_title = create_note(
content="# Explicit Title\n\nThis note has a heading.",
custom_slug="note-with-title",
published=True
)
response = client.get('/note/note-with-title')
assert response.status_code == 200, f"Failed to get note: {response.status_code}"
parsed = mf2py.parse(doc=response.data.decode(), url='http://localhost/note/note-with-title')
entries = [item for item in parsed['items'] if 'h-entry' in item.get('type', [])]
assert len(entries) > 0, "No h-entry found in parsed HTML"
entry = entries[0]
props = entry.get('properties', {})
# Should have p-name
assert 'name' in props, "Note with explicit title should have p-name"
assert props['name'][0] == 'Explicit Title'
# Create note WITHOUT heading (no explicit title)
with app.app_context():
note_without_title = create_note(
content="Just a simple note without a heading.",
custom_slug="note-without-title",
published=True
)
response = client.get('/note/note-without-title')
assert response.status_code == 200, f"Failed to get note: {response.status_code}"
parsed = mf2py.parse(doc=response.data.decode(), url='http://localhost/note/note-without-title')
entries = [item for item in parsed['items'] if 'h-entry' in item.get('type', [])]
assert len(entries) > 0, "No h-entry found in parsed HTML"
entry = entries[0]
props = entry.get('properties', {})
# Should NOT have explicit p-name (or it should be implicit from content)
# Per Q22: p-name only if has_explicit_title
# If p-name exists, it shouldn't be set explicitly in our markup
# (mf2py may infer it from content, but we shouldn't add class="p-name")
def test_hentry_has_updated_if_modified(self, client, app, sample_note, data_dir):
"""dt-updated present if note was modified"""
from starpunk.notes import update_note
from pathlib import Path
import time
# Update the note
time.sleep(0.1) # Ensure different timestamp
with app.app_context():
update_note(sample_note.slug, content="Updated content")
response = client.get(f'/note/{sample_note.slug}')
parsed = mf2py.parse(doc=response.data.decode(), url=f'http://localhost/note/{sample_note.slug}')
entry = [item for item in parsed['items'] if 'h-entry' in item.get('type', [])][0]
props = entry.get('properties', {})
# Should have dt-updated
assert 'updated' in props, "Modified note should have dt-updated"
class TestAuthorHCard:
"""Test h-card markup for author"""
def test_hentry_has_nested_hcard(self, client, app, sample_note):
"""h-entry has nested p-author h-card (per Q20)"""
# Mock author profile in context
mock_author = {
'me': 'https://author.example.com',
'name': 'Test Author',
'photo': 'https://example.com/photo.jpg',
'url': 'https://author.example.com',
'note': 'Test bio',
'rel_me_links': [],
}
with patch('starpunk.author_discovery.get_author_profile', return_value=mock_author):
response = client.get(f'/note/{sample_note.slug}')
parsed = mf2py.parse(doc=response.data.decode(), url=f'http://localhost/note/{sample_note.slug}')
entry = [item for item in parsed['items'] if 'h-entry' in item.get('type', [])][0]
props = entry.get('properties', {})
# Should have p-author
assert 'author' in props, "h-entry should have p-author"
# Author should be h-card
author = props['author'][0]
if isinstance(author, dict):
assert 'h-card' in author.get('type', []), "p-author should be h-card"
author_props = author.get('properties', {})
assert 'name' in author_props, "h-card should have p-name"
assert author_props['name'][0] == 'Test Author'
def test_hcard_not_standalone(self, client, app, sample_note):
"""h-card only within h-entry, not standalone (per Q20)"""
response = client.get(f'/note/{sample_note.slug}')
parsed = mf2py.parse(doc=response.data.decode(), url=f'http://localhost/note/{sample_note.slug}')
# Find all h-cards at root level
root_hcards = [item for item in parsed['items'] if 'h-card' in item.get('type', [])]
# Should NOT have root-level h-cards (only nested in h-entry)
# Note: This might not be strictly enforced by mf2py parsing,
# but we can check that h-entry exists and contains h-card
entries = [item for item in parsed['items'] if 'h-entry' in item.get('type', [])]
assert len(entries) > 0, "Should have h-entry"
def test_hcard_has_required_properties(self, client, app, sample_note):
"""h-card has name and url at minimum"""
mock_author = {
'me': 'https://author.example.com',
'name': 'Test Author',
'photo': None,
'url': 'https://author.example.com',
'note': None,
'rel_me_links': [],
}
with patch('starpunk.author_discovery.get_author_profile', return_value=mock_author):
response = client.get(f'/note/{sample_note.slug}')
parsed = mf2py.parse(doc=response.data.decode(), url=f'http://localhost/note/{sample_note.slug}')
entry = [item for item in parsed['items'] if 'h-entry' in item.get('type', [])][0]
props = entry.get('properties', {})
author = props['author'][0]
if isinstance(author, dict):
author_props = author.get('properties', {})
assert 'name' in author_props, "h-card must have p-name"
assert 'url' in author_props, "h-card must have u-url"
class TestFeedHFeed:
"""Test h-feed markup on index page"""
def test_index_has_hfeed(self, client, app):
"""Index page has h-feed container (per Q24)"""
response = client.get('/')
assert response.status_code == 200
parsed = mf2py.parse(doc=response.data.decode(), url='http://localhost/')
# Should have h-feed
feeds = [item for item in parsed['items'] if 'h-feed' in item.get('type', [])]
assert len(feeds) >= 1, "Index should have h-feed"
def test_hfeed_has_name(self, client, app):
"""h-feed has p-name (feed title)"""
response = client.get('/')
parsed = mf2py.parse(doc=response.data.decode(), url='http://localhost/')
feed = [item for item in parsed['items'] if 'h-feed' in item.get('type', [])][0]
props = feed.get('properties', {})
assert 'name' in props, "h-feed should have p-name"
def test_hfeed_contains_hentries(self, client, app, sample_note):
"""h-feed contains h-entry children"""
response = client.get('/')
parsed = mf2py.parse(doc=response.data.decode(), url='http://localhost/')
feed = [item for item in parsed['items'] if 'h-feed' in item.get('type', [])][0]
# Should have children (h-entries)
children = feed.get('children', [])
entries = [child for child in children if 'h-entry' in child.get('type', [])]
assert len(entries) > 0, "h-feed should contain h-entry children"
def test_feed_entries_have_author(self, client, app, sample_note):
"""Each h-entry in feed has p-author h-card (per Q20)"""
mock_author = {
'me': 'https://author.example.com',
'name': 'Test Author',
'photo': None,
'url': 'https://author.example.com',
'note': None,
'rel_me_links': [],
}
with patch('starpunk.author_discovery.get_author_profile', return_value=mock_author):
response = client.get('/')
parsed = mf2py.parse(doc=response.data.decode(), url='http://localhost/')
feed = [item for item in parsed['items'] if 'h-feed' in item.get('type', [])][0]
children = feed.get('children', [])
entries = [child for child in children if 'h-entry' in child.get('type', [])]
# Each entry should have author
for entry in entries:
props = entry.get('properties', {})
assert 'author' in props, "Feed h-entry should have p-author"
class TestRelMe:
"""Test rel-me links in HTML head"""
def test_relme_links_in_head(self, client, app):
"""rel=me links present in HTML head (per Q20)"""
mock_author = {
'me': 'https://author.example.com',
'name': 'Test Author',
'photo': None,
'url': 'https://author.example.com',
'note': None,
'rel_me_links': [
'https://github.com/testuser',
'https://mastodon.social/@testuser',
],
}
with patch('starpunk.author_discovery.get_author_profile', return_value=mock_author):
response = client.get('/')
parsed = mf2py.parse(doc=response.data.decode(), url='http://localhost/')
# Check rel=me in rels
rels = parsed.get('rels', {})
assert 'me' in rels, "Should have rel=me links"
assert 'https://github.com/testuser' in rels['me']
assert 'https://mastodon.social/@testuser' in rels['me']
def test_no_relme_without_author(self, client, app):
"""No rel=me links if no author profile"""
with patch('starpunk.author_discovery.get_author_profile', return_value=None):
response = client.get('/')
parsed = mf2py.parse(doc=response.data.decode(), url='http://localhost/')
rels = parsed.get('rels', {})
# Should not have rel=me, or it should be empty
assert len(rels.get('me', [])) == 0, "Should not have rel=me without author"