From fc54885df8b8413ac619c2572c07adf1d7f80479 Mon Sep 17 00:00:00 2001 From: Praneel Pothukanuri Date: Wed, 26 Aug 2026 04:48:29 +0000 Subject: [PATCH] Add backend unit test coverage Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .coveragerc | 6 + .gitignore | 5 + backend/routes/playlist.py | 1 + pytest.ini | 4 + requirements-dev.txt | 3 + tests/conftest.py | 27 ++++ tests/test_ai_service.py | 82 ++++++++++ tests/test_app_and_config.py | 53 +++++++ tests/test_auth_routes.py | 195 +++++++++++++++++++++++ tests/test_playlist_routes.py | 280 ++++++++++++++++++++++++++++++++++ tests/test_spotify_service.py | 271 ++++++++++++++++++++++++++++++++ 11 files changed, 927 insertions(+) create mode 100644 .coveragerc create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_ai_service.py create mode 100644 tests/test_app_and_config.py create mode 100644 tests/test_auth_routes.py create mode 100644 tests/test_playlist_routes.py create mode 100644 tests/test_spotify_service.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..d2a8075 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,6 @@ +[run] +source = backend + +[report] +include_namespace_packages = true +show_missing = true diff --git a/.gitignore b/.gitignore index da59f65..493194e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,10 +21,15 @@ wheels/ *.egg # Virtual Environment +.venv/ venv/ env/ ENV/ +# Test coverage +.coverage +htmlcov/ + # Environment Variables .env diff --git a/backend/routes/playlist.py b/backend/routes/playlist.py index 778bf3b..2888b10 100644 --- a/backend/routes/playlist.py +++ b/backend/routes/playlist.py @@ -1,5 +1,6 @@ from flask import Blueprint, request, session, jsonify import concurrent.futures +import requests from ..config import Config from ..services.spotify import SpotifyService from ..services.ai import AIService diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..1f3ca48 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +addopts = --cov=backend --cov-report=term-missing --cov-fail-under=90 +pythonpath = . +testpaths = tests diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..e69df5e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==8.3.5 +pytest-cov==6.0.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..73c23d0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,27 @@ +import pytest + +from backend.app import create_app +from backend.config import Config + + +@pytest.fixture +def app(tmp_path, monkeypatch): + monkeypatch.setattr( + Config, "SESSION_FILE_DIR", str(tmp_path / "flask_session") + ) + application = create_app() + application.config.update(TESTING=True) + return application + + +@pytest.fixture +def client(app): + return app.test_client() + + +@pytest.fixture +def authenticated_client(client): + with client.session_transaction() as session: + session["access_token"] = "access-token" + session["spotify_user_id"] = "user-1" + return client diff --git a/tests/test_ai_service.py b/tests/test_ai_service.py new file mode 100644 index 0000000..5770d0d --- /dev/null +++ b/tests/test_ai_service.py @@ -0,0 +1,82 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from backend.services import ai + + +def test_initializes_without_model_when_api_key_is_missing(monkeypatch): + monkeypatch.setattr(ai.Config, "GENAI_API_KEY", None) + + service = ai.AIService() + + assert service.model is None + + +def test_configures_gemini_model_when_api_key_is_present(monkeypatch): + model = object() + configure = Mock() + generative_model = Mock(return_value=model) + monkeypatch.setattr(ai.Config, "GENAI_API_KEY", "test-key") + monkeypatch.setattr(ai.genai, "configure", configure) + monkeypatch.setattr(ai.genai, "GenerativeModel", generative_model) + + service = ai.AIService() + + configure.assert_called_once_with(api_key="test-key") + generative_model.assert_called_once_with("gemini-2.5-flash") + assert service.model is model + + +def test_generate_playlist_requires_configured_model(): + service = ai.AIService.__new__(ai.AIService) + service.model = None + + with pytest.raises(Exception, match="AI Service not configured"): + service.generate_playlist_params({"moods": ["calm"]}) + + +def test_generate_playlist_returns_parsed_songs_and_includes_exclusions(): + model = Mock() + model.generate_content.return_value = SimpleNamespace( + text='[{"name": "Midnight City", "artist": "M83"}]' + ) + service = ai.AIService.__new__(ai.AIService) + service.model = model + + songs = service.generate_playlist_params( + {"moods": ["energetic"]}, + count=1, + exclude_tracks=[{"name": "Intro", "artist": "The xx"}], + ) + + assert songs == [{"name": "Midnight City", "artist": "M83"}] + prompt = model.generate_content.call_args.args[0] + assert "Generate a unique list of 1 songs" in prompt + assert '"energetic"' in prompt + assert "Ensure no songs are repeated" in prompt + model.generate_content.assert_called_once_with( + prompt, + generation_config={"response_mime_type": "application/json"}, + ) + + +@pytest.mark.parametrize( + ("response_text", "message"), + [ + ("", "Empty response from AI"), + ('{"name": "not a list"}', "AI did not return a list"), + ("not-json", "Expecting value"), + ], +) +def test_generate_playlist_wraps_invalid_ai_responses(response_text, message): + model = Mock() + model.generate_content.return_value = SimpleNamespace(text=response_text) + service = ai.AIService.__new__(ai.AIService) + service.model = model + + with pytest.raises(Exception, match="Failed to generate playlist") as error: + service.generate_playlist_params({"genres": ["pop"]}) + + assert message in str(error.value) diff --git a/tests/test_app_and_config.py b/tests/test_app_and_config.py new file mode 100644 index 0000000..90289e9 --- /dev/null +++ b/tests/test_app_and_config.py @@ -0,0 +1,53 @@ +import pytest + +from backend.config import Config + + +def test_health_check(client): + response = client.get("/") + + assert response.status_code == 200 + assert response.get_json() == { + "status": "ok", + "service": "Spotify AI Backend", + } + + +def test_create_app_registers_expected_routes(app): + routes = {rule.rule for rule in app.url_map.iter_rules()} + + assert { + "/login", + "/callback", + "/auth/status", + "/logout", + "/Playlist_Generator", + "/Generate_Preview", + "/Create_Playlist", + "/Search_Track", + "/Get_Playlists", + } <= routes + + +def test_config_validate_accepts_complete_configuration(monkeypatch): + monkeypatch.setattr(Config, "SPOTIFY_CLIENT_ID", "client-id") + monkeypatch.setattr(Config, "SPOTIFY_CLIENT_SECRET", "client-secret") + monkeypatch.setattr(Config, "SPOTIFY_REDIRECT_URI", "http://callback") + monkeypatch.setattr(Config, "GENAI_API_KEY", "api-key") + + assert Config.validate() is None + + +def test_config_validate_lists_missing_variables(monkeypatch): + monkeypatch.setattr(Config, "SPOTIFY_CLIENT_ID", None) + monkeypatch.setattr(Config, "SPOTIFY_CLIENT_SECRET", None) + monkeypatch.setattr(Config, "SPOTIFY_REDIRECT_URI", None) + monkeypatch.setattr(Config, "GENAI_API_KEY", None) + + with pytest.raises(ValueError) as error: + Config.validate() + + assert str(error.value) == ( + "Missing required environment variables: " + "CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, GENAI_API_KEY" + ) diff --git a/tests/test_auth_routes.py b/tests/test_auth_routes.py new file mode 100644 index 0000000..47de2ed --- /dev/null +++ b/tests/test_auth_routes.py @@ -0,0 +1,195 @@ +from datetime import datetime +from unittest.mock import Mock +from urllib.parse import parse_qs, urlparse + +from backend.config import Config + + +def set_session(client, **values): + with client.session_transaction() as session: + session.update(values) + + +def test_login_redirects_to_spotify_and_stores_state( + client, monkeypatch +): + monkeypatch.setattr(Config, "SPOTIFY_CLIENT_ID", "client-id") + monkeypatch.setattr(Config, "SPOTIFY_CLIENT_SECRET", "client-secret") + monkeypatch.setattr(Config, "SPOTIFY_REDIRECT_URI", "http://callback") + monkeypatch.setattr(Config, "GENAI_API_KEY", "api-key") + monkeypatch.setattr( + "backend.routes.auth.secrets.token_urlsafe", + Mock(return_value="oauth-state"), + ) + + response = client.get("/login?redirect=http://frontend/success") + + assert response.status_code == 302 + parsed_url = urlparse(response.location) + query = parse_qs(parsed_url.query) + assert parsed_url.netloc == "accounts.spotify.com" + assert query["client_id"] == ["client-id"] + assert query["redirect_uri"] == ["http://callback"] + assert query["state"] == ["oauth-state"] + with client.session_transaction() as session: + assert session["oauth_state"] == "oauth-state" + assert session["frontend_redirect"] == "http://frontend/success" + + +def test_callback_rejects_invalid_state(client): + set_session(client, oauth_state="expected") + + response = client.get("/callback?state=unexpected&code=code") + + assert response.status_code == 400 + assert response.get_json()["error"] == "Invalid state parameter" + + +def test_callback_returns_spotify_error(client): + set_session(client, oauth_state="state") + + response = client.get("/callback?state=state&error=access_denied") + + assert response.status_code == 400 + assert response.get_json() == {"error": "access_denied"} + + +def test_callback_requires_code(client): + set_session(client, oauth_state="state") + + response = client.get("/callback?state=state") + + assert response.status_code == 400 + assert response.get_json() == {"error": "No code provided"} + + +def test_callback_handles_token_exchange_failure(client, monkeypatch): + spotify = Mock() + spotify.exchange_code_for_token.side_effect = RuntimeError("bad code") + monkeypatch.setattr( + "backend.routes.auth.SpotifyService", + Mock(return_value=spotify), + ) + set_session(client, oauth_state="state") + + response = client.get("/callback?state=state&code=code") + + assert response.status_code == 500 + assert response.get_json() == { + "error": "Token exchange failed", + "details": "bad code", + } + + +def test_callback_stores_tokens_and_profile(client, monkeypatch): + spotify = Mock() + spotify.exchange_code_for_token.return_value = { + "access_token": "access", + "refresh_token": "refresh", + "expires_in": 120, + } + spotify.get_user_profile.return_value = { + "id": "user-1", + "display_name": "Listener", + } + monkeypatch.setattr( + "backend.routes.auth.SpotifyService", + Mock(return_value=spotify), + ) + set_session( + client, + oauth_state="state", + frontend_redirect="http://frontend/preferences", + ) + + response = client.get("/callback?state=state&code=code") + + assert response.status_code == 302 + assert response.location == "http://frontend/preferences" + with client.session_transaction() as session: + assert session["access_token"] == "access" + assert session["refresh_token"] == "refresh" + assert session["spotify_user_id"] == "user-1" + assert session["spotify_display_name"] == "Listener" + + +def test_auth_status_requires_access_token(client): + response = client.get("/auth/status") + + assert response.status_code == 401 + assert response.get_json() == {"authenticated": False} + + +def test_auth_status_refreshes_expired_token(client, monkeypatch): + spotify = Mock() + spotify.refresh_token.return_value = { + "access_token": "new-access", + "refresh_token": "new-refresh", + "expires_in": 600, + } + monkeypatch.setattr( + "backend.routes.auth.SpotifyService", + Mock(return_value=spotify), + ) + set_session( + client, + access_token="old-access", + refresh_token="old-refresh", + expires_at=datetime.now().timestamp() - 1, + spotify_display_name="Listener", + ) + + response = client.get("/auth/status") + + assert response.status_code == 200 + assert response.get_json()["authenticated"] is True + spotify.refresh_token.assert_called_once_with("old-refresh") + with client.session_transaction() as session: + assert session["access_token"] == "new-access" + assert session["refresh_token"] == "new-refresh" + + +def test_auth_status_clears_session_when_refresh_fails( + client, monkeypatch +): + spotify = Mock() + spotify.refresh_token.side_effect = RuntimeError("expired") + monkeypatch.setattr( + "backend.routes.auth.SpotifyService", + Mock(return_value=spotify), + ) + set_session( + client, + access_token="old-access", + refresh_token="old-refresh", + expires_at=datetime.now().timestamp() - 1, + ) + + response = client.get("/auth/status") + + assert response.status_code == 401 + assert response.get_json() == {"authenticated": False} + with client.session_transaction() as session: + assert "access_token" not in session + + +def test_auth_status_clears_expired_session_without_refresh_token(client): + set_session( + client, + access_token="old-access", + expires_at=datetime.now().timestamp() - 1, + ) + + response = client.get("/auth/status") + + assert response.status_code == 401 + assert response.get_json() == {"authenticated": False} + + +def test_logout_clears_session(authenticated_client): + response = authenticated_client.post("/logout") + + assert response.status_code == 200 + assert response.get_json() == {"message": "Logged out"} + with authenticated_client.session_transaction() as session: + assert "access_token" not in session diff --git a/tests/test_playlist_routes.py b/tests/test_playlist_routes.py new file mode 100644 index 0000000..a722a9d --- /dev/null +++ b/tests/test_playlist_routes.py @@ -0,0 +1,280 @@ +from unittest.mock import Mock + + +def response(status_code=200, json_data=None): + mock_response = Mock() + mock_response.status_code = status_code + mock_response.json.return_value = json_data + return mock_response + + +def test_generate_preview_requires_authentication(client): + result = client.post("/Generate_Preview", json={"preferences": {}}) + + assert result.status_code == 401 + assert result.get_json()["error"] == "Not authenticated" + + +def test_generate_preview_requires_preferences(authenticated_client): + result = authenticated_client.post("/Generate_Preview", json={}) + + assert result.status_code == 400 + assert result.get_json() == {"error": "No preferences provided"} + + +def test_generate_preview_handles_ai_failure( + authenticated_client, monkeypatch +): + ai_service = Mock() + ai_service.generate_playlist_params.side_effect = RuntimeError( + "model unavailable" + ) + monkeypatch.setattr( + "backend.routes.playlist.AIService", + Mock(return_value=ai_service), + ) + + result = authenticated_client.post( + "/Generate_Preview", + json={"preferences": {"playlistLength": [1]}}, + ) + + assert result.status_code == 500 + assert result.get_json() == { + "error": "AI Generation failed", + "details": "model unavailable", + } + + +def test_generate_preview_formats_found_tracks( + authenticated_client, monkeypatch +): + ai_service = Mock() + ai_service.generate_playlist_params.return_value = [ + {"name": "Song", "artist": "Artist"} + ] + spotify_service = Mock() + spotify_service.search_track.return_value = { + "id": "track-1", + "uri": "spotify:track:1", + "name": "Song", + "artists": [{"name": "Artist"}], + "album": { + "name": "Album", + "images": [{"url": "http://image"}], + }, + "duration_ms": 185000, + "preview_url": "http://preview", + } + monkeypatch.setattr( + "backend.routes.playlist.AIService", + Mock(return_value=ai_service), + ) + monkeypatch.setattr( + "backend.routes.playlist.SpotifyService", + Mock(return_value=spotify_service), + ) + + result = authenticated_client.post( + "/Playlist_Generator", + json={"preferences": {"playlistLength": ["1"]}}, + ) + + assert result.status_code == 200 + assert result.get_json() == { + "tracks": [ + { + "id": "track-1", + "uri": "spotify:track:1", + "title": "Song", + "artist": "Artist", + "album": "Album", + "duration": "3:05", + "image": "http://image", + "preview_url": "http://preview", + } + ], + "count": 1, + "totalDuration": "Calculating...", + } + ai_service.generate_playlist_params.assert_called_once_with( + {"playlistLength": ["1"]}, count=11 + ) + + +def test_generate_preview_returns_not_found_when_searches_fail( + authenticated_client, monkeypatch +): + ai_service = Mock() + ai_service.generate_playlist_params.return_value = [ + {"name": "Missing", "artist": "Unknown"} + ] + spotify_service = Mock() + spotify_service.search_track.return_value = None + monkeypatch.setattr( + "backend.routes.playlist.AIService", + Mock(return_value=ai_service), + ) + monkeypatch.setattr( + "backend.routes.playlist.SpotifyService", + Mock(return_value=spotify_service), + ) + + result = authenticated_client.post( + "/Generate_Preview", + json={"preferences": {"playlistLength": "invalid"}}, + ) + + assert result.status_code == 404 + assert result.get_json()["error"] == ( + "No songs found on Spotify matching the criteria" + ) + ai_service.generate_playlist_params.assert_called_once_with( + {"playlistLength": "invalid"}, count=40 + ) + + +def test_create_playlist_requires_tracks(authenticated_client): + result = authenticated_client.post("/Create_Playlist", json={}) + + assert result.status_code == 400 + assert result.get_json() == {"error": "No tracks provided"} + + +def test_create_playlist_creates_playlist_and_adds_tracks( + authenticated_client, monkeypatch +): + spotify_service = Mock() + spotify_service.create_playlist.return_value = {"id": "playlist-1"} + monkeypatch.setattr( + "backend.routes.playlist.SpotifyService", + Mock(return_value=spotify_service), + ) + + result = authenticated_client.post( + "/Create_Playlist", + json={ + "name": "Road Trip", + "description": "Driving songs", + "uris": ["spotify:track:1"], + "image": "encoded-image", + }, + ) + + assert result.status_code == 200 + assert result.get_json() == { + "playlist_id": "playlist-1", + "message": "Playlist created successfully", + } + spotify_service.create_playlist.assert_called_once_with( + "access-token", + "user-1", + name="Road Trip", + description="Driving songs", + public=True, + ) + spotify_service.add_tracks_to_playlist.assert_called_once_with( + "access-token", "playlist-1", ["spotify:track:1"] + ) + spotify_service.upload_playlist_cover.assert_called_once_with( + "access-token", "playlist-1", "encoded-image" + ) + + +def test_create_playlist_fetches_missing_user_profile( + client, monkeypatch +): + with client.session_transaction() as session: + session["access_token"] = "access-token" + spotify_service = Mock() + spotify_service.get_user_profile.return_value = {"id": "user-2"} + spotify_service.create_playlist.return_value = {"id": "playlist-2"} + monkeypatch.setattr( + "backend.routes.playlist.SpotifyService", + Mock(return_value=spotify_service), + ) + + result = client.post( + "/Create_Playlist", + json={"uris": ["spotify:track:2"]}, + ) + + assert result.status_code == 200 + spotify_service.get_user_profile.assert_called_once_with("access-token") + with client.session_transaction() as session: + assert session["spotify_user_id"] == "user-2" + + +def test_search_track_requires_authentication(client): + assert client.get("/Search_Track?q=song").status_code == 401 + + +def test_search_track_requires_query(authenticated_client): + assert authenticated_client.get("/Search_Track").status_code == 400 + + +def test_search_track_returns_formatted_results( + authenticated_client, monkeypatch +): + spotify_response = response( + json_data={ + "tracks": { + "items": [ + { + "id": "track-1", + "uri": "spotify:track:1", + "name": "Song", + "artists": [{"name": "Artist"}], + "album": {"name": "Album", "images": []}, + "duration_ms": 61000, + } + ] + } + } + ) + monkeypatch.setattr( + "backend.routes.playlist.requests.get", + Mock(return_value=spotify_response), + ) + + result = authenticated_client.get("/Search_Track?q=Song") + + assert result.status_code == 200 + assert result.get_json()[0] == { + "id": "track-1", + "uri": "spotify:track:1", + "title": "Song", + "artist": "Artist", + "album": "Album", + "duration": "1:01", + "image": ( + "https://images.unsplash.com/photo-1493225457124-" + "a3eb161ffa5f?w=100&h=100&fit=crop" + ), + } + + +def test_search_track_propagates_spotify_error_status( + authenticated_client, monkeypatch +): + monkeypatch.setattr( + "backend.routes.playlist.requests.get", + Mock(return_value=response(status_code=429)), + ) + + result = authenticated_client.get("/Search_Track?q=Song") + + assert result.status_code == 429 + assert result.get_json() == {"error": "Spotify search failed"} + + +def test_get_playlists_returns_spotify_response( + authenticated_client, monkeypatch +): + get = Mock(return_value=response(json_data={"items": [{"id": "p1"}]})) + monkeypatch.setattr("backend.routes.playlist.requests.get", get) + + result = authenticated_client.get("/Get_Playlists") + + assert result.status_code == 200 + assert result.get_json() == {"items": [{"id": "p1"}]} diff --git a/tests/test_spotify_service.py b/tests/test_spotify_service.py new file mode 100644 index 0000000..274b923 --- /dev/null +++ b/tests/test_spotify_service.py @@ -0,0 +1,271 @@ +import base64 +from unittest.mock import Mock + +import pytest + +from backend.services.spotify import SpotifyService + + +@pytest.fixture +def service(): + return SpotifyService("client-id", "client-secret") + + +def response(status_code=200, json_data=None, text=""): + mock_response = Mock() + mock_response.status_code = status_code + mock_response.json.return_value = json_data + mock_response.text = text + return mock_response + + +def test_get_auth_headers(service): + assert service.get_auth_headers("token") == { + "Authorization": "Bearer token", + "Content-Type": "application/json", + } + + +def test_exchange_code_for_token(service, monkeypatch): + token_response = response(json_data={"access_token": "access"}) + post = Mock(return_value=token_response) + monkeypatch.setattr("backend.services.spotify.requests.post", post) + + result = service.exchange_code_for_token("code", "http://callback") + + encoded_credentials = base64.b64encode( + b"client-id:client-secret" + ).decode("ascii") + post.assert_called_once_with( + SpotifyService.AUTH_URL, + data={ + "code": "code", + "redirect_uri": "http://callback", + "grant_type": "authorization_code", + }, + headers={ + "Authorization": f"Basic {encoded_credentials}", + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + assert result == {"access_token": "access"} + + +def test_exchange_code_for_token_raises_on_failure(service, monkeypatch): + monkeypatch.setattr( + "backend.services.spotify.requests.post", + Mock(return_value=response(status_code=400, text="invalid code")), + ) + + with pytest.raises(Exception, match="Token exchange failed: invalid code"): + service.exchange_code_for_token("code", "http://callback") + + +def test_refresh_token(service, monkeypatch): + token_response = response(json_data={"access_token": "new-access"}) + post = Mock(return_value=token_response) + monkeypatch.setattr("backend.services.spotify.requests.post", post) + + result = service.refresh_token("refresh") + + post.assert_called_once() + assert post.call_args.kwargs["data"] == { + "grant_type": "refresh_token", + "refresh_token": "refresh", + } + assert result == {"access_token": "new-access"} + + +def test_refresh_token_raises_on_failure(service, monkeypatch): + monkeypatch.setattr( + "backend.services.spotify.requests.post", + Mock(return_value=response(status_code=401, text="expired")), + ) + + with pytest.raises(Exception, match="Token refresh failed: expired"): + service.refresh_token("refresh") + + +@pytest.mark.parametrize( + ("song_name", "artist_name"), + [(None, "Artist"), ("Song", None), ("", "Artist"), ("Song", "")], +) +def test_search_track_rejects_incomplete_queries( + service, monkeypatch, song_name, artist_name +): + get = Mock() + monkeypatch.setattr("backend.services.spotify.requests.get", get) + + assert service.search_track("token", song_name, artist_name) is None + get.assert_not_called() + + +def test_search_track_returns_strict_match(service, monkeypatch): + track = {"id": "track-1"} + get = Mock( + return_value=response( + json_data={"tracks": {"items": [track]}} + ) + ) + monkeypatch.setattr("backend.services.spotify.requests.get", get) + + result = service.search_track("token", "Song", "Artist") + + assert result == track + get.assert_called_once() + assert get.call_args.kwargs["params"]["q"] == "track:Song artist:Artist" + + +def test_search_track_stops_when_rate_limited(service, monkeypatch): + get = Mock(return_value=response(status_code=429)) + monkeypatch.setattr("backend.services.spotify.requests.get", get) + + assert service.search_track("token", "Song", "Artist") is None + get.assert_called_once() + + +def test_search_track_uses_relaxed_fallback(service, monkeypatch): + track = {"id": "track-2"} + get = Mock( + side_effect=[ + response(json_data={"tracks": {"items": []}}), + response(json_data={"tracks": {"items": [track]}}), + ] + ) + monkeypatch.setattr("backend.services.spotify.requests.get", get) + + result = service.search_track("token", "Song", "Artist") + + assert result == track + assert get.call_count == 2 + assert get.call_args.kwargs["params"]["q"] == "Song Artist" + + +def test_search_track_returns_none_when_fallback_has_no_match( + service, monkeypatch +): + get = Mock( + side_effect=[ + response(json_data={"tracks": {"items": []}}), + response(json_data={"tracks": {"items": []}}), + ] + ) + monkeypatch.setattr("backend.services.spotify.requests.get", get) + + assert service.search_track("token", "Song", "Artist") is None + assert get.call_count == 2 + + +def test_search_track_returns_none_when_requests_fail(service, monkeypatch): + monkeypatch.setattr( + "backend.services.spotify.requests.get", + Mock(side_effect=RuntimeError("network unavailable")), + ) + + assert service.search_track("token", "Song", "Artist") is None + + +def test_create_playlist(service, monkeypatch): + playlist_response = response(json_data={"id": "playlist-1"}) + post = Mock(return_value=playlist_response) + monkeypatch.setattr("backend.services.spotify.requests.post", post) + + result = service.create_playlist( + "token", + "user-1", + "Road Trip", + description="Driving songs", + public=False, + ) + + post.assert_called_once_with( + f"{SpotifyService.BASE_URL}/users/user-1/playlists", + headers=service.get_auth_headers("token"), + json={ + "name": "Road Trip", + "description": "Driving songs", + "public": False, + }, + ) + playlist_response.raise_for_status.assert_called_once_with() + assert result == {"id": "playlist-1"} + + +def test_add_tracks_skips_empty_uri_list(service, monkeypatch): + post = Mock() + monkeypatch.setattr("backend.services.spotify.requests.post", post) + + assert service.add_tracks_to_playlist("token", "playlist-1", []) is None + post.assert_not_called() + + +def test_add_tracks_to_playlist(service, monkeypatch): + add_response = response(json_data={"snapshot_id": "snapshot-1"}) + post = Mock(return_value=add_response) + monkeypatch.setattr("backend.services.spotify.requests.post", post) + + result = service.add_tracks_to_playlist( + "token", "playlist-1", ["spotify:track:1"] + ) + + post.assert_called_once_with( + f"{SpotifyService.BASE_URL}/playlists/playlist-1/tracks", + headers=service.get_auth_headers("token"), + json={"uris": ["spotify:track:1"]}, + ) + add_response.raise_for_status.assert_called_once_with() + assert result == {"snapshot_id": "snapshot-1"} + + +def test_get_user_profile(service, monkeypatch): + profile_response = response(json_data={"id": "user-1"}) + monkeypatch.setattr( + "backend.services.spotify.requests.get", + Mock(return_value=profile_response), + ) + + assert service.get_user_profile("token") == {"id": "user-1"} + + +def test_get_user_profile_raises_on_failure(service, monkeypatch): + monkeypatch.setattr( + "backend.services.spotify.requests.get", + Mock(return_value=response(status_code=403, text="forbidden")), + ) + + with pytest.raises(Exception, match="Failed to fetch profile: forbidden"): + service.get_user_profile("token") + + +def test_upload_playlist_cover_strips_data_url_prefix(service, monkeypatch): + put = Mock(return_value=response(status_code=202)) + monkeypatch.setattr("backend.services.spotify.requests.put", put) + + result = service.upload_playlist_cover( + "token", "playlist-1", "data:image/jpeg;base64,encoded-image" + ) + + put.assert_called_once_with( + f"{SpotifyService.BASE_URL}/playlists/playlist-1/items", + headers={ + "Authorization": "Bearer token", + "Content-Type": "image/jpeg", + "Accept": "application/json", + }, + data="encoded-image", + ) + assert result is True + + +def test_upload_playlist_cover_returns_false_on_failure(service, monkeypatch): + monkeypatch.setattr( + "backend.services.spotify.requests.put", + Mock(return_value=response(status_code=400, text="invalid image")), + ) + + assert ( + service.upload_playlist_cover( + "token", "playlist-1", "encoded-image" + ) + is False + )