#!/usr/bin/env python3 """ Spotify Profile Scraper Test Suite Tests the spotify_profile_scraper module for fetching public playlists from Spotify user profiles without using the API. All tests use MOCKED data - no network requests to Spotify. Usage: python tools/test_spotify_profile_scraper.py """ import sys import os import base64 import json from unittest.mock import patch, Mock # Add parent directory to path to import from core sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from utils.logging_config import get_logger from core.spotify_profile_scraper import ( fetch_profile_playlists, _extract_from_initial_state, _extract_from_html_links, _fetch_playlist_track_count ) logger = get_logger("spotify_scraper_test") # ============================================================================= # MOCK DATA # ============================================================================= # Well-known Spotify editorial playlist IDs for realistic mock data MOCK_PLAYLISTS = [ {"id": "37i9dQZF1DXcBWIGoYBM5M", "name": "Today's Top Hits", "followers": 35000000}, {"id": "37i9dQZF1DX0XUsuxWHRQd", "name": "RapCaviar", "followers": 15000000}, {"id": "37i9dQZF1DX4JAvHpjipBk", "name": "New Music Friday", "followers": 8000000}, ] def _create_mock_initial_state(user_id: str, playlists: list) -> str: """Create a mock initialState JSON structure matching Spotify's format""" playlist_items = [] for p in playlists: playlist_items.append({ "__typename": "PlaylistResponseWrapper", "_uri": f"spotify:playlist:{p['id']}", "data": { "__typename": "Playlist", "followers": p.get("followers", 0), "name": p["name"], "uri": f"spotify:playlist:{p['id']}", "images": {"items": [{"sources": [{"url": f"https://example.com/{p['id']}.jpg"}]}]} } }) state = { "entities": { "items": { f"spotify:user:{user_id}": { "__typename": "User", "id": user_id, "name": f"Test User", "publicPlaylistsV2": {"items": playlist_items, "totalCount": len(playlist_items)} } } } } return base64.b64encode(json.dumps(state).encode()).decode() def _create_mock_html(user_id: str, playlists: list) -> str: """Create mock Spotify profile HTML page""" state = _create_mock_initial_state(user_id, playlists) return f'
' MOCK_USER = "testuser" MOCK_HTML = _create_mock_html(MOCK_USER, MOCK_PLAYLISTS) def _create_mock_playlist_page(playlist_id: str, track_count: int) -> str: """Create mock Spotify playlist page HTML with track count in initialState""" state = { "entities": { "items": { f"spotify:playlist:{playlist_id}": { "__typename": "Playlist", "content": { "__typename": "PlaylistItemsPage", "items": [], "totalCount": track_count, "pagingInfo": {"nextOffset": None} }, "name": "Test Playlist", "id": playlist_id } } } } encoded = base64.b64encode(json.dumps(state).encode()).decode() return f'' # ============================================================================= # TESTS # ============================================================================= class TestSpotifyProfileScraper: """All tests use mocked data""" def __init__(self): self.passed = 0 self.failed = 0 def run_all(self): print("\n" + "=" * 60) print("๐งช SPOTIFY PROFILE SCRAPER TESTS (mocked)") print("=" * 60 + "\n") self.test_extract_initial_state() self.test_extract_html_links() self.test_playlist_fields() self.test_fetch_with_playwright_mock() self.test_playwright_not_installed() self.test_track_count_fetch() print("\n" + "=" * 60) print(f"๐ RESULTS: {self.passed}/{self.passed + self.failed} passed") print("=" * 60) if self.failed == 0: print("๐ All tests passed!") return self.failed == 0 def _check(self, cond, name, detail=""): if cond: print(f" โ {name}") self.passed += 1 else: print(f" โ {name}" + (f" - {detail}" if detail else "")) self.failed += 1 def test_extract_initial_state(self): """Parse playlists from initialState JSON""" print("๐ฆ Parse initialState JSON") result = _extract_from_initial_state(MOCK_HTML, MOCK_USER) self._check(len(result) == 3, "Extracts 3 playlists") self._check(result[0]['id'] == MOCK_PLAYLISTS[0]['id'], "Correct playlist ID") self._check(result[0]['name'] == "Today's Top Hits", "Correct playlist name") self._check(result[0]['source'] == 'friend_profile', "Source is friend_profile") def test_extract_html_links(self): """Fallback: parse playlist IDs from HTML links""" print("\n๐ฆ Parse HTML links (fallback)") html = 'P1P2P1' result = _extract_from_html_links(html) self._check(len(result) == 2, "Deduplicates to 2 playlists") self._check(result[0]['id'] == 'abc123', "First ID correct") self._check(result[1]['id'] == 'def456', "Second ID correct") def test_playlist_fields(self): """Playlist dict has required fields""" print("\n๐ฆ Playlist structure") result = _extract_from_initial_state(MOCK_HTML, MOCK_USER) p = result[0] if result else {} for f in ['id', 'name', 'owner', 'source']: self._check(f in p, f"Has '{f}' field") def test_fetch_with_playwright_mock(self): """fetch_profile_playlists with mocked playwright""" print("\n๐ฆ fetch_profile_playlists (playwright mocked)") # Create expected result from playwright mock_playlists = [ {'id': MOCK_PLAYLISTS[0]['id'], 'name': MOCK_PLAYLISTS[0]['name'], 'owner': MOCK_USER, 'track_count': 50, 'source': 'friend_profile'} ] with patch('core.spotify_profile_scraper._fetch_playlists_with_playwright', return_value=mock_playlists): result = fetch_profile_playlists(MOCK_USER) self._check(len(result) == 1, "Returns mocked playlist") self._check(result[0]['name'] == MOCK_PLAYLISTS[0]['name'], "Correct playlist name") def test_playwright_not_installed(self): """Raises RuntimeError when playwright is not installed""" print("\n๐ฆ Playwright not installed handling") with patch('core.spotify_profile_scraper.sync_playwright', None): from core.spotify_profile_scraper import is_playwright_available # Need to reimport to pick up the patched value with patch('core.spotify_profile_scraper.is_playwright_available', return_value=False): try: fetch_profile_playlists("any_user") self._check(False, "Should raise RuntimeError") except RuntimeError as e: self._check("Playwright is required" in str(e), "Raises correct RuntimeError") def test_track_count_fetch(self): """Fetches track count from playlist page""" print("\n๐ฆ Track count fetching") playlist_id = "test123" expected_count = 42 mock_html = _create_mock_playlist_page(playlist_id, expected_count) mock_resp = Mock(text=mock_html, raise_for_status=Mock()) with patch('core.spotify_profile_scraper.requests.get', return_value=mock_resp): result = _fetch_playlist_track_count(playlist_id) self._check(result == expected_count, f"Returns correct track count ({expected_count})") # Test error handling - returns 0 on failure import requests with patch('core.spotify_profile_scraper.requests.get', side_effect=requests.RequestException("fail")): result = _fetch_playlist_track_count(playlist_id) self._check(result == 0, "Returns 0 on request failure") if __name__ == "__main__": suite = TestSpotifyProfileScraper() sys.exit(0 if suite.run_all() else 1)