Files
StarPunk/tests/test_author_discovery.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

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'