- Add pytest config with 90% coverage threshold - 15 test files covering all routers, services, schemas, models - 300 tests: unit tests, integration tests, edge cases, mocked external APIs - Update CI workflow to run pytest with coverage enforcement - Mock external services (Genius, MusicBrainz, RadioBrowser) - In-memory SQLite DB per test via conftest fixtures
460 lines
17 KiB
Python
460 lines
17 KiB
Python
"""Tests for deep coverage of services and middleware."""
|
|
from unittest.mock import MagicMock, patch, AsyncMock
|
|
import tempfile
|
|
import os
|
|
|
|
|
|
class TestAPIKeyMiddleware:
|
|
def test_health_no_auth(self, client):
|
|
r = client.get("/health")
|
|
assert r.status_code == 200
|
|
|
|
def test_static_no_auth(self, client):
|
|
r = client.get("/static/test.txt")
|
|
assert r.status_code in (200, 404) # 404 ok if file missing
|
|
|
|
|
|
class TestMoodEngineDeep:
|
|
def test_seed_mood_categories(self, db_module):
|
|
from app.services.mood_engine import seed_mood_categories
|
|
from app.models.mood import MoodCategory
|
|
db = db_module.SessionLocal()
|
|
seed_mood_categories(db)
|
|
cats = db.query(MoodCategory).all()
|
|
assert len(cats) == 10
|
|
names = [c.name for c in cats]
|
|
assert "Happy" in names
|
|
assert "Sad" in names
|
|
db.close()
|
|
|
|
def test_seed_idempotent(self, db_module):
|
|
from app.services.mood_engine import seed_mood_categories
|
|
from app.models.mood import MoodCategory
|
|
db = db_module.SessionLocal()
|
|
seed_mood_categories(db)
|
|
seed_mood_categories(db)
|
|
cats = db.query(MoodCategory).all()
|
|
assert len(cats) == 10
|
|
db.close()
|
|
|
|
def test_analyze_song_mood_no_lyrics(self, db_module):
|
|
from app.services.mood_engine import analyze_song_mood
|
|
from app.models.song import Song
|
|
db = db_module.SessionLocal()
|
|
s = Song(id="s1", title="T", artist="A", file_path="/tmp/t.mp3")
|
|
db.add(s)
|
|
db.commit()
|
|
result = analyze_song_mood("s1", db)
|
|
assert result["song_id"] == "s1"
|
|
assert result["top_mood"] is None
|
|
assert result["confidence"] == 0
|
|
db.close()
|
|
|
|
def test_get_mood_playlist_with_songs(self, db_module):
|
|
from app.services.mood_engine import get_mood_playlist
|
|
from app.models.song import Song
|
|
from app.models.mood import MoodSong
|
|
db = db_module.SessionLocal()
|
|
s = Song(id="s1", title="T", artist="A", file_path="/tmp/t.mp3")
|
|
ms = MoodSong(mood_id="happy", song_id="s1", confidence_score=0.9)
|
|
db.add(s)
|
|
db.add(ms)
|
|
db.commit()
|
|
songs = get_mood_playlist("happy", db, limit=50)
|
|
assert len(songs) == 1
|
|
db.close()
|
|
|
|
|
|
class TestLyricsServiceMocked:
|
|
def test_fetch_lyrics_no_key(self):
|
|
from app.services.lyrics import fetch_lyrics, GENIUS_API_KEY
|
|
assert GENIUS_API_KEY == ""
|
|
assert fetch_lyrics("song", "artist") is None
|
|
|
|
def test_fetch_lyrics_request_error(self):
|
|
import app.services.lyrics as lyrics_mod
|
|
original_key = lyrics_mod.GENIUS_API_KEY
|
|
lyrics_mod.GENIUS_API_KEY = "fake_key"
|
|
import requests
|
|
with patch.object(requests, "get", side_effect=requests.RequestException("fail")):
|
|
result = lyrics_mod.fetch_lyrics("song", "artist")
|
|
assert result is None
|
|
lyrics_mod.GENIUS_API_KEY = original_key
|
|
|
|
def test_fetch_lyrics_no_hits(self):
|
|
import app.services.lyrics as lyrics_mod
|
|
original_key = lyrics_mod.GENIUS_API_KEY
|
|
lyrics_mod.GENIUS_API_KEY = "fake_key"
|
|
import requests
|
|
mock_resp = MagicMock()
|
|
mock_resp.status_code = 200
|
|
mock_resp.json.return_value = {"response": {"hits": []}}
|
|
with patch.object(requests, "get", return_value=mock_resp):
|
|
result = lyrics_mod.fetch_lyrics("song", "artist")
|
|
assert result is None
|
|
lyrics_mod.GENIUS_API_KEY = original_key
|
|
|
|
def test_fetch_lyrics_non_200(self):
|
|
import app.services.lyrics as lyrics_mod
|
|
original_key = lyrics_mod.GENIUS_API_KEY
|
|
lyrics_mod.GENIUS_API_KEY = "fake_key"
|
|
import requests
|
|
mock_resp = MagicMock()
|
|
mock_resp.status_code = 401
|
|
with patch.object(requests, "get", return_value=mock_resp):
|
|
result = lyrics_mod.fetch_lyrics("song", "artist")
|
|
assert result is None
|
|
lyrics_mod.GENIUS_API_KEY = original_key
|
|
|
|
def test_fetch_lyrics_no_song_id(self):
|
|
import app.services.lyrics as lyrics_mod
|
|
original_key = lyrics_mod.GENIUS_API_KEY
|
|
lyrics_mod.GENIUS_API_KEY = "fake_key"
|
|
import requests
|
|
mock_resp = MagicMock()
|
|
mock_resp.status_code = 200
|
|
mock_resp.json.return_value = {"response": {"hits": [{"result": {}}]}}
|
|
with patch.object(requests, "get", return_value=mock_resp):
|
|
result = lyrics_mod.fetch_lyrics("song", "artist")
|
|
assert result is None
|
|
lyrics_mod.GENIUS_API_KEY = original_key
|
|
|
|
def test_fetch_lyrics_empty_lyrics(self):
|
|
import app.services.lyrics as lyrics_mod
|
|
original_key = lyrics_mod.GENIUS_API_KEY
|
|
lyrics_mod.GENIUS_API_KEY = "fake_key"
|
|
import requests
|
|
search_resp = MagicMock()
|
|
search_resp.status_code = 200
|
|
search_resp.json.return_value = {"response": {"hits": [{"result": {"id": 123}}]}}
|
|
|
|
song_resp = MagicMock()
|
|
song_resp.status_code = 200
|
|
song_resp.json.return_value = {"response": {"lyrics": ""}}
|
|
|
|
with patch.object(requests, "get", side_effect=[search_resp, song_resp]):
|
|
result = lyrics_mod.fetch_lyrics("song", "artist")
|
|
assert result is None
|
|
lyrics_mod.GENIUS_API_KEY = original_key
|
|
|
|
def test_fetch_lyrics_not_written_yet(self):
|
|
import app.services.lyrics as lyrics_mod
|
|
original_key = lyrics_mod.GENIUS_API_KEY
|
|
lyrics_mod.GENIUS_API_KEY = "fake_key"
|
|
import requests
|
|
search_resp = MagicMock()
|
|
search_resp.status_code = 200
|
|
search_resp.json.return_value = {"response": {"hits": [{"result": {"id": 123}}]}}
|
|
|
|
song_resp = MagicMock()
|
|
song_resp.status_code = 200
|
|
song_resp.json.return_value = {"response": {"lyrics": "[Lyrics are not written yet]"}}
|
|
|
|
with patch.object(requests, "get", side_effect=[search_resp, song_resp]):
|
|
result = lyrics_mod.fetch_lyrics("song", "artist")
|
|
assert result is None
|
|
lyrics_mod.GENIUS_API_KEY = original_key
|
|
|
|
def test_fetch_lyrics_success(self):
|
|
import app.services.lyrics as lyrics_mod
|
|
original_key = lyrics_mod.GENIUS_API_KEY
|
|
lyrics_mod.GENIUS_API_KEY = "fake_key"
|
|
import requests
|
|
search_resp = MagicMock()
|
|
search_resp.status_code = 200
|
|
search_resp.json.return_value = {"response": {"hits": [{"result": {"id": 123}}]}}
|
|
|
|
song_resp = MagicMock()
|
|
song_resp.status_code = 200
|
|
song_resp.json.return_value = {"response": {"lyrics": "test lyrics here"}}
|
|
|
|
with patch.object(requests, "get", side_effect=[search_resp, song_resp]):
|
|
result = lyrics_mod.fetch_lyrics("song", "artist")
|
|
assert result == "test lyrics here"
|
|
lyrics_mod.GENIUS_API_KEY = original_key
|
|
|
|
|
|
class TestMusicbrainzMocked:
|
|
def test_search_artist_404(self):
|
|
import app.services.musicbrainz as mb
|
|
import requests
|
|
mock_resp = MagicMock()
|
|
mock_resp.status_code = 404
|
|
with patch.object(requests, "get", return_value=mock_resp):
|
|
result = mb.search_artist("unknown")
|
|
assert result is None
|
|
|
|
def test_search_artist_no_results(self):
|
|
import app.services.musicbrainz as mb
|
|
import requests
|
|
mock_resp = MagicMock()
|
|
mock_resp.status_code = 200
|
|
mock_resp.json.return_value = {"artists": []}
|
|
with patch.object(requests, "get", return_value=mock_resp):
|
|
result = mb.search_artist("unknown")
|
|
assert result is None
|
|
|
|
def test_search_artist_success(self):
|
|
import app.services.musicbrainz as mb
|
|
import requests
|
|
mock_resp = MagicMock()
|
|
mock_resp.status_code = 200
|
|
mock_resp.json.return_value = {"artists": [{"id": "a1", "name": "Test Artist"}]}
|
|
with patch.object(requests, "get", return_value=mock_resp):
|
|
result = mb.search_artist("Test")
|
|
assert result["id"] == "a1"
|
|
assert result["name"] == "Test Artist"
|
|
|
|
def test_get_artist_releases_404(self):
|
|
import app.services.musicbrainz as mb
|
|
import requests
|
|
mock_resp = MagicMock()
|
|
mock_resp.status_code = 404
|
|
with patch.object(requests, "get", return_value=mock_resp):
|
|
result = mb.get_artist_releases("a1")
|
|
assert result == []
|
|
|
|
def test_get_artist_releases_success(self):
|
|
import app.services.musicbrainz as mb
|
|
import requests
|
|
mock_resp = MagicMock()
|
|
mock_resp.status_code = 200
|
|
mock_resp.json.return_value = {
|
|
"releases": [
|
|
{
|
|
"id": "r1",
|
|
"title": "Album 1",
|
|
"date": "2025-01-01",
|
|
"media": [{"tracks": [{"title": "Song 1", "length": 245000}]}]
|
|
}
|
|
]
|
|
}
|
|
with patch.object(requests, "get", return_value=mock_resp):
|
|
result = mb.get_artist_releases("a1")
|
|
assert len(result) == 1
|
|
assert result[0]["title"] == "Album 1"
|
|
assert len(result[0]["tracks"]) == 1
|
|
|
|
def test_get_artist_releases_error(self):
|
|
import app.services.musicbrainz as mb
|
|
import requests
|
|
with patch.object(requests, "get", side_effect=requests.RequestException("fail")):
|
|
result = mb.get_artist_releases("a1")
|
|
assert result == []
|
|
|
|
|
|
class TestAudioServiceDeep:
|
|
def test_scan_existing_skipped(self, db_module):
|
|
from app.services.audio import scan_directory
|
|
from app.models.song import Song
|
|
tmpdir = tempfile.mkdtemp()
|
|
# Create a fake audio file
|
|
fp = os.path.join(tmpdir, "test.mp3")
|
|
with open(fp, "wb") as f:
|
|
f.write(b"fake mp3")
|
|
db = db_module.SessionLocal()
|
|
# Pre-add to DB
|
|
s = Song(id="s1", title="T", artist="A", file_path=fp)
|
|
db.add(s)
|
|
db.commit()
|
|
result = scan_directory(tmpdir, db)
|
|
assert result.scanned == 1
|
|
assert result.added == 0
|
|
db.close()
|
|
os.unlink(fp)
|
|
os.rmdir(tmpdir)
|
|
|
|
def test_transcode_timeout(self):
|
|
from app.services.audio import transcode_to_ogg
|
|
import subprocess
|
|
with patch("app.services.audio.subprocess.run", side_effect=subprocess.TimeoutExpired("cmd", 1)):
|
|
result = transcode_to_ogg("/tmp/test.mp3", "/tmp")
|
|
assert result is None
|
|
|
|
def test_transcode_file_not_found(self):
|
|
from app.services.audio import transcode_to_ogg
|
|
with patch("app.services.audio.subprocess.run", side_effect=FileNotFoundError()):
|
|
result = transcode_to_ogg("/tmp/test.mp3", "/tmp")
|
|
assert result is None
|
|
|
|
|
|
class TestSharePlayManagerDeep:
|
|
def test_add_to_cue_success(self, db_module):
|
|
from app.services.shareplay import SharePlayManager
|
|
from app.models.song import Song
|
|
db = db_module.SessionLocal()
|
|
s = Song(id="s1", title="T", artist="A", file_path="/tmp/t.mp3")
|
|
db.add(s)
|
|
db.commit()
|
|
m = SharePlayManager()
|
|
result = m.add_to_cue(db, "room1", "s1", "alice")
|
|
assert result is True
|
|
db.close()
|
|
|
|
def test_next_cue_success(self, db_module):
|
|
from app.services.shareplay import SharePlayManager
|
|
from app.models.song import Song
|
|
from app.models.shareplay import SharePlayCue as CueModel
|
|
db = db_module.SessionLocal()
|
|
s = Song(id="s1", title="T", artist="A", file_path="/tmp/t.mp3")
|
|
cue = CueModel(id="c1", room_id="room1", song_id="s1", position=0, added_by="alice")
|
|
db.add(s)
|
|
db.add(cue)
|
|
db.commit()
|
|
m = SharePlayManager()
|
|
result = m.next_cue(db, "room1")
|
|
assert result == "s1"
|
|
db.close()
|
|
|
|
def test_get_cue_with_songs(self, db_module):
|
|
from app.services.shareplay import SharePlayManager
|
|
from app.models.song import Song
|
|
from app.models.shareplay import SharePlayCue as CueModel
|
|
db = db_module.SessionLocal()
|
|
s = Song(id="s1", title="Test Song", artist="Artist A", file_path="/tmp/t.mp3")
|
|
cue = CueModel(id="c1", room_id="room1", song_id="s1", position=0, added_by="alice")
|
|
db.add(s)
|
|
db.add(cue)
|
|
db.commit()
|
|
m = SharePlayManager()
|
|
result = m.get_cue(db, "room1")
|
|
assert len(result) == 1
|
|
assert result[0]["song"]["title"] == "Test Song"
|
|
assert result[0]["added_by"] == "alice"
|
|
db.close()
|
|
|
|
|
|
class TestMoodKeywords:
|
|
def test_all_moods_have_15_keywords(self):
|
|
from app.services.mood_engine import MOOD_KEYWORDS
|
|
for mood, keywords in MOOD_KEYWORDS.items():
|
|
assert len(keywords) == 15, f"{mood} has {len(keywords)} keywords"
|
|
|
|
def test_all_weights_positive(self):
|
|
from app.services.mood_engine import MOOD_KEYWORDS
|
|
for mood, keywords in MOOD_KEYWORDS.items():
|
|
for word, weight in keywords:
|
|
assert weight > 0, f"{mood}/{word} has non-positive weight"
|
|
|
|
def test_keywords_are_words(self):
|
|
from app.services.mood_engine import MOOD_KEYWORDS
|
|
for mood, keywords in MOOD_KEYWORDS.items():
|
|
for word, weight in keywords:
|
|
assert " " not in word, f"{mood}/{word} contains space"
|
|
assert word == word.lower(), f"{mood}/{word} not lowercase"
|
|
|
|
|
|
class TestDefaultChannels:
|
|
def test_default_channels_count(self):
|
|
from app.routers.lofi import DEFAULT_CHANNELS
|
|
assert len(DEFAULT_CHANNELS) == 3
|
|
|
|
def test_default_channels_have_fields(self):
|
|
from app.routers.lofi import DEFAULT_CHANNELS
|
|
for ch in DEFAULT_CHANNELS:
|
|
assert "id" in ch
|
|
assert "name" in ch
|
|
assert "stream_url" in ch
|
|
assert ch.get("is_active") is True
|
|
|
|
|
|
class TestPlaceholderEvents:
|
|
def test_placeholder_events_count(self):
|
|
from app.routers.events import PLACEHOLDER_EVENTS
|
|
assert len(PLACEHOLDER_EVENTS) >= 3
|
|
|
|
def test_placeholder_events_have_fields(self):
|
|
from app.routers.events import PLACEHOLDER_EVENTS
|
|
for e in PLACEHOLDER_EVENTS:
|
|
assert "id" in e
|
|
assert "name" in e
|
|
assert "date" in e
|
|
|
|
|
|
class TestRadioBrowserFormatEdge:
|
|
def test_format_station_none_geo(self):
|
|
from app.services.radio_browser import _format_station
|
|
s = {
|
|
"stationuuid": "u1",
|
|
"name": "Test",
|
|
"codec": "mp3",
|
|
"url_resolved": "http://s.com",
|
|
"geo_lat": None,
|
|
"geo_long": None,
|
|
"tag": "",
|
|
"countryname": "",
|
|
"language": "",
|
|
"bitrate": None,
|
|
"votes": None,
|
|
}
|
|
result = _format_station(s)
|
|
assert result["location_lat"] is None
|
|
assert result["bitrate"] is None
|
|
assert result["votes"] == 0
|
|
|
|
def test_format_station_empty_tags(self):
|
|
from app.services.radio_browser import _format_station
|
|
s = {
|
|
"stationuuid": "u1", "name": "T", "codec": "mp3",
|
|
"url_resolved": "http://s.com", "geo_lat": None, "geo_long": None,
|
|
"tag": None, "countryname": "", "language": "",
|
|
"bitrate": None, "votes": None,
|
|
}
|
|
result = _format_station(s)
|
|
assert result["tags"] == []
|
|
|
|
|
|
class TestHaversineEdgeCases:
|
|
def test_poles(self):
|
|
from app.services.radio_browser import _haversine
|
|
d = _haversine(90, 0, -90, 0)
|
|
assert 19000 < d < 21000 # ~20000km pole to pole
|
|
|
|
def test_equator(self):
|
|
from app.services.radio_browser import _haversine
|
|
d = _haversine(0, 0, 0, 180)
|
|
assert 19000 < d < 21000 # ~20000km equator half-circle
|
|
|
|
|
|
class TestDurationFormat:
|
|
def test_zero(self):
|
|
from app.services.musicbrainz import _format_duration
|
|
assert _format_duration(0) == "0:00"
|
|
|
|
def test_under_minute(self):
|
|
from app.services.musicbrainz import _format_duration
|
|
assert _format_duration(45) == "0:45"
|
|
|
|
def test_exact_minute(self):
|
|
from app.services.musicbrainz import _format_duration
|
|
assert _format_duration(60) == "1:00"
|
|
|
|
def test_over_hour(self):
|
|
from app.services.musicbrainz import _format_duration
|
|
assert _format_duration(3725) == "62:05"
|
|
|
|
|
|
class TestScanResultSchema:
|
|
def test_scan_result_defaults(self):
|
|
from app.schemas.song import ScanResult
|
|
sr = ScanResult(scanned=0, added=0, skipped=0, errors=[])
|
|
assert sr.errors == []
|
|
|
|
def test_scan_result_with_errors(self):
|
|
from app.schemas.song import ScanResult
|
|
sr = ScanResult(scanned=5, added=3, skipped=1, errors=["err1", "err2"])
|
|
assert len(sr.errors) == 2
|
|
|
|
|
|
class TestUserSettingsDefaults:
|
|
def test_all_defaults(self):
|
|
from app.schemas.settings import UserSettingsResponse
|
|
us = UserSettingsResponse()
|
|
assert us.audio_quality == "high"
|
|
assert us.theme == "dark"
|
|
assert us.scan_directories == []
|
|
assert us.user_name == "User"
|
|
assert us.user_avatar is None
|
|
assert us.auto_transcode is True
|
|
assert us.default_mood is None
|