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>
354 lines
12 KiB
Python
354 lines
12 KiB
Python
"""
|
|
Tests for author profile discovery from IndieAuth identity
|
|
|
|
Per v1.2.0 Phase 2 and developer Q&A Q31-Q35:
|
|
- Mock HTTP requests for author discovery
|
|
- Test discovery, caching, and fallback behavior
|
|
- Ensure login never blocks on discovery failure
|
|
"""
|
|
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
|
|
from starpunk.author_discovery import (
|
|
discover_author_profile,
|
|
get_author_profile,
|
|
save_author_profile,
|
|
DiscoveryError,
|
|
)
|
|
|
|
|
|
# Sample h-card HTML for testing (per Q35)
|
|
SAMPLE_HCARD_HTML = """
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Alice's Profile</title>
|
|
</head>
|
|
<body class="h-card">
|
|
<h1 class="p-name">Alice Example</h1>
|
|
<img class="u-photo" src="https://example.com/photo.jpg" alt="Alice">
|
|
<a class="u-url" href="https://alice.example.com">alice.example.com</a>
|
|
<p class="p-note">IndieWeb enthusiast and developer</p>
|
|
<a rel="me" href="https://github.com/alice">GitHub</a>
|
|
<a rel="me" href="https://twitter.com/alice">Twitter</a>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
MINIMAL_HCARD_HTML = """
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><title>Bob's Site</title></head>
|
|
<body>
|
|
<div class="h-card">
|
|
<a class="p-name u-url" href="https://bob.example.com">Bob</a>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
NO_HCARD_HTML = """
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><title>No Microformats Here</title></head>
|
|
<body>
|
|
<h1>Just a regular page</h1>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
class TestDiscoverAuthorProfile:
|
|
"""Test author profile discovery from h-card"""
|
|
|
|
@patch('starpunk.author_discovery.httpx.get')
|
|
def test_discover_hcard_from_valid_profile(self, mock_get, app):
|
|
"""Discover h-card from valid profile URL"""
|
|
# Mock HTTP response
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.text = SAMPLE_HCARD_HTML
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
with app.app_context():
|
|
profile = discover_author_profile('https://alice.example.com')
|
|
|
|
assert profile is not None
|
|
assert profile['name'] == 'Alice Example'
|
|
assert profile['photo'] == 'https://example.com/photo.jpg'
|
|
assert profile['url'] == 'https://alice.example.com'
|
|
assert profile['note'] == 'IndieWeb enthusiast and developer'
|
|
assert 'https://github.com/alice' in profile['rel_me_links']
|
|
assert 'https://twitter.com/alice' in profile['rel_me_links']
|
|
|
|
@patch('starpunk.author_discovery.httpx.get')
|
|
def test_discover_minimal_hcard(self, mock_get, app):
|
|
"""Handle minimal h-card with only name and URL"""
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.text = MINIMAL_HCARD_HTML
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
with app.app_context():
|
|
profile = discover_author_profile('https://bob.example.com')
|
|
|
|
assert profile is not None
|
|
assert profile['name'] == 'Bob'
|
|
assert profile['url'] == 'https://bob.example.com'
|
|
assert profile['photo'] is None
|
|
assert profile['note'] is None
|
|
assert profile['rel_me_links'] == []
|
|
|
|
@patch('starpunk.author_discovery.httpx.get')
|
|
def test_discover_no_hcard_returns_none(self, mock_get, app):
|
|
"""Gracefully handle missing h-card (per Q14)"""
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.text = NO_HCARD_HTML
|
|
mock_response.raise_for_status = Mock()
|
|
mock_get.return_value = mock_response
|
|
|
|
with app.app_context():
|
|
profile = discover_author_profile('https://example.com')
|
|
|
|
assert profile is None
|
|
|
|
@patch('starpunk.author_discovery.httpx.get')
|
|
def test_discover_timeout_raises_error(self, mock_get, app):
|
|
"""Handle network timeout gracefully (per Q38)"""
|
|
import httpx
|
|
mock_get.side_effect = httpx.TimeoutException('Timeout')
|
|
|
|
with app.app_context():
|
|
with pytest.raises(DiscoveryError, match='Timeout'):
|
|
discover_author_profile('https://slow.example.com')
|
|
|
|
@patch('starpunk.author_discovery.httpx.get')
|
|
def test_discover_http_error_raises_error(self, mock_get, app):
|
|
"""Handle HTTP errors gracefully"""
|
|
import httpx
|
|
mock_response = Mock()
|
|
mock_response.status_code = 404
|
|
mock_get.return_value = mock_response
|
|
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
'Not Found', request=Mock(), response=mock_response
|
|
)
|
|
|
|
with app.app_context():
|
|
with pytest.raises(DiscoveryError, match='HTTP error'):
|
|
discover_author_profile('https://missing.example.com')
|
|
|
|
|
|
class TestGetAuthorProfile:
|
|
"""Test author profile retrieval with caching"""
|
|
|
|
def test_get_profile_with_cache(self, app):
|
|
"""Use cached profile if valid (per Q14, Q19)"""
|
|
with app.app_context():
|
|
# Save a cached profile
|
|
test_profile = {
|
|
'name': 'Test User',
|
|
'photo': 'https://example.com/photo.jpg',
|
|
'url': 'https://test.example.com',
|
|
'note': 'Test bio',
|
|
'rel_me_links': ['https://github.com/test'],
|
|
}
|
|
save_author_profile('https://test.example.com', test_profile)
|
|
|
|
# Retrieve should use cache (no HTTP call)
|
|
profile = get_author_profile('https://test.example.com')
|
|
|
|
assert profile['name'] == 'Test User'
|
|
assert profile['photo'] == 'https://example.com/photo.jpg'
|
|
assert profile['me'] == 'https://test.example.com'
|
|
|
|
@patch('starpunk.author_discovery.discover_author_profile')
|
|
def test_get_profile_refresh_forces_discovery(self, mock_discover, app):
|
|
"""Force refresh bypasses cache (per Q20)"""
|
|
mock_discover.return_value = {
|
|
'name': 'Fresh Data',
|
|
'photo': None,
|
|
'url': 'https://test.example.com',
|
|
'note': None,
|
|
'rel_me_links': [],
|
|
}
|
|
|
|
with app.app_context():
|
|
# Save old cache
|
|
old_profile = {
|
|
'name': 'Old Data',
|
|
'photo': None,
|
|
'url': 'https://test.example.com',
|
|
'note': None,
|
|
'rel_me_links': [],
|
|
}
|
|
save_author_profile('https://test.example.com', old_profile)
|
|
|
|
# Get with refresh=True
|
|
profile = get_author_profile('https://test.example.com', refresh=True)
|
|
|
|
assert profile['name'] == 'Fresh Data'
|
|
mock_discover.assert_called_once()
|
|
|
|
def test_get_profile_expired_cache_fallback(self, app):
|
|
"""Use expired cache if discovery fails (per Q14)"""
|
|
with app.app_context():
|
|
# Save expired cache manually
|
|
from starpunk.database import get_db
|
|
db = get_db(app)
|
|
|
|
expired_time = (datetime.utcnow() - timedelta(hours=48)).isoformat()
|
|
|
|
db.execute(
|
|
"""
|
|
INSERT INTO author_profile
|
|
(me, name, photo, url, note, rel_me_links, discovered_at, cached_until)
|
|
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
|
|
""",
|
|
(
|
|
'https://expired.example.com',
|
|
'Expired User',
|
|
None,
|
|
'https://expired.example.com',
|
|
None,
|
|
json.dumps([]),
|
|
expired_time,
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
# Mock discovery failure
|
|
with patch('starpunk.author_discovery.discover_author_profile') as mock_discover:
|
|
mock_discover.side_effect = DiscoveryError('Network error')
|
|
|
|
# Should use expired cache as fallback
|
|
profile = get_author_profile('https://expired.example.com')
|
|
|
|
assert profile['name'] == 'Expired User'
|
|
assert profile['me'] == 'https://expired.example.com'
|
|
|
|
@patch('starpunk.author_discovery.discover_author_profile')
|
|
def test_get_profile_no_cache_no_discovery_uses_defaults(self, mock_discover, app):
|
|
"""Use minimal defaults if no cache and discovery fails (per Q14, Q21)"""
|
|
mock_discover.side_effect = DiscoveryError('Failed')
|
|
|
|
with app.app_context():
|
|
profile = get_author_profile('https://fallback.example.com')
|
|
|
|
# Should return defaults based on URL
|
|
assert profile['me'] == 'https://fallback.example.com'
|
|
assert profile['name'] == 'fallback.example.com' # domain as fallback
|
|
assert profile['photo'] is None
|
|
assert profile['url'] == 'https://fallback.example.com'
|
|
assert profile['note'] is None
|
|
assert profile['rel_me_links'] == []
|
|
|
|
|
|
class TestSaveAuthorProfile:
|
|
"""Test saving author profile to database"""
|
|
|
|
def test_save_profile_creates_record(self, app):
|
|
"""Save profile creates database record"""
|
|
with app.app_context():
|
|
from starpunk.database import get_db
|
|
|
|
profile = {
|
|
'name': 'Save Test',
|
|
'photo': 'https://example.com/photo.jpg',
|
|
'url': 'https://save.example.com',
|
|
'note': 'Test note',
|
|
'rel_me_links': ['https://github.com/test'],
|
|
}
|
|
|
|
save_author_profile('https://save.example.com', profile)
|
|
|
|
# Verify in database
|
|
db = get_db(app)
|
|
row = db.execute(
|
|
"SELECT * FROM author_profile WHERE me = ?",
|
|
('https://save.example.com',)
|
|
).fetchone()
|
|
|
|
assert row is not None
|
|
assert row['name'] == 'Save Test'
|
|
assert row['photo'] == 'https://example.com/photo.jpg'
|
|
|
|
# Check rel_me_links is stored as JSON
|
|
rel_me = json.loads(row['rel_me_links'])
|
|
assert 'https://github.com/test' in rel_me
|
|
|
|
def test_save_profile_sets_24_hour_cache(self, app):
|
|
"""Cache TTL is 24 hours (per Q14)"""
|
|
with app.app_context():
|
|
from starpunk.database import get_db
|
|
|
|
profile = {
|
|
'name': 'Cache Test',
|
|
'photo': None,
|
|
'url': 'https://cache.example.com',
|
|
'note': None,
|
|
'rel_me_links': [],
|
|
}
|
|
|
|
before_save = datetime.utcnow()
|
|
save_author_profile('https://cache.example.com', profile)
|
|
after_save = datetime.utcnow()
|
|
|
|
# Check cache expiry
|
|
db = get_db(app)
|
|
row = db.execute(
|
|
"SELECT cached_until FROM author_profile WHERE me = ?",
|
|
('https://cache.example.com',)
|
|
).fetchone()
|
|
|
|
cached_until = datetime.fromisoformat(row['cached_until'])
|
|
|
|
# Should be approximately 24 hours from now
|
|
expected_min = before_save + timedelta(hours=23, minutes=59)
|
|
expected_max = after_save + timedelta(hours=24, minutes=1)
|
|
|
|
assert expected_min <= cached_until <= expected_max
|
|
|
|
def test_save_profile_upserts_existing(self, app):
|
|
"""Saving again updates existing record"""
|
|
with app.app_context():
|
|
from starpunk.database import get_db
|
|
|
|
# Save first version
|
|
profile1 = {
|
|
'name': 'Version 1',
|
|
'photo': None,
|
|
'url': 'https://upsert.example.com',
|
|
'note': None,
|
|
'rel_me_links': [],
|
|
}
|
|
save_author_profile('https://upsert.example.com', profile1)
|
|
|
|
# Save updated version
|
|
profile2 = {
|
|
'name': 'Version 2',
|
|
'photo': 'https://example.com/new.jpg',
|
|
'url': 'https://upsert.example.com',
|
|
'note': 'Updated bio',
|
|
'rel_me_links': ['https://mastodon.social/@test'],
|
|
}
|
|
save_author_profile('https://upsert.example.com', profile2)
|
|
|
|
# Should have only one record with updated data
|
|
db = get_db(app)
|
|
rows = db.execute(
|
|
"SELECT * FROM author_profile WHERE me = ?",
|
|
('https://upsert.example.com',)
|
|
).fetchall()
|
|
|
|
assert len(rows) == 1
|
|
assert rows[0]['name'] == 'Version 2'
|
|
assert rows[0]['photo'] == 'https://example.com/new.jpg'
|
|
assert rows[0]['note'] == 'Updated bio'
|