"""
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 = """
Alice's Profile
Alice Example
alice.example.com
IndieWeb enthusiast and developer
GitHub
Twitter
"""
MINIMAL_HCARD_HTML = """
Bob's Site
"""
NO_HCARD_HTML = """
No Microformats Here
Just a regular page
"""
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'