Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[run]
source = backend

[report]
include_namespace_packages = true
show_missing = true
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,15 @@ wheels/
*.egg

# Virtual Environment
.venv/
venv/
env/
ENV/

# Test coverage
.coverage
htmlcov/

# Environment Variables
.env

Expand Down
1 change: 1 addition & 0 deletions backend/routes/playlist.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[pytest]
addopts = --cov=backend --cov-report=term-missing --cov-fail-under=90
pythonpath = .
testpaths = tests
3 changes: 3 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-r requirements.txt
pytest==8.3.5
pytest-cov==6.0.0
27 changes: 27 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
82 changes: 82 additions & 0 deletions tests/test_ai_service.py
Original file line number Diff line number Diff line change
@@ -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)
53 changes: 53 additions & 0 deletions tests/test_app_and_config.py
Original file line number Diff line number Diff line change
@@ -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"
)
195 changes: 195 additions & 0 deletions tests/test_auth_routes.py
Original file line number Diff line number Diff line change
@@ -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
Loading