""" 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 alice.example.com

IndieWeb enthusiast and developer

GitHub Twitter """ MINIMAL_HCARD_HTML = """ Bob's Site
Bob
""" 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'