mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
328 lines
10 KiB
328 lines
10 KiB
"""Tests for core/playlists/explorer.py — playlist explorer build-tree route."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
|
|
import pytest
|
|
|
|
from core.playlists import explorer as ex
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fakes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _FakeRequest:
|
|
def __init__(self, payload):
|
|
self._payload = payload
|
|
|
|
def get_json(self):
|
|
return self._payload
|
|
|
|
|
|
class _FakeResponse:
|
|
"""Captures the streaming generator + headers."""
|
|
def __init__(self, generator, mimetype=None, headers=None):
|
|
self.generator = generator
|
|
self.mimetype = mimetype
|
|
self.headers = headers or {}
|
|
self.body_lines = list(generator)
|
|
|
|
|
|
def _fake_jsonify(payload):
|
|
"""Returns the payload dict as-is — the wrapper does the actual jsonify."""
|
|
return payload
|
|
|
|
|
|
@dataclass
|
|
class _FakeAlbum:
|
|
id: str
|
|
name: str
|
|
release_date: str = '2024-01-01'
|
|
image_url: str = ''
|
|
total_tracks: int = 10
|
|
album_type: str = 'album'
|
|
artist_ids: list = None
|
|
|
|
|
|
class _FakeArtistMeta:
|
|
def __init__(self, name='Artist', id='a-1'):
|
|
self.name = name
|
|
self.id = id
|
|
self.image_url = 'http://art-img'
|
|
|
|
|
|
class _FakeSpotify:
|
|
def __init__(self, *, authenticated=True, search_results=None, albums=None):
|
|
self._authenticated = authenticated
|
|
self._search_results = search_results or []
|
|
self._albums = albums or []
|
|
self.sp = object() # pretends to be spotify (skip_cache support)
|
|
|
|
def is_spotify_authenticated(self):
|
|
return self._authenticated
|
|
|
|
def search_artists(self, name, limit=5):
|
|
return self._search_results
|
|
|
|
def get_artist_albums(self, artist_id, album_type='album,single', skip_cache=False):
|
|
return self._albums
|
|
|
|
def get_artist(self, artist_id):
|
|
return None
|
|
|
|
|
|
class _FakeCursor:
|
|
def __init__(self):
|
|
self.queries = []
|
|
|
|
def execute(self, sql, params=None):
|
|
self.queries.append((sql, params))
|
|
|
|
def fetchall(self):
|
|
return []
|
|
|
|
|
|
class _FakeConn:
|
|
def __init__(self):
|
|
self.cur = _FakeCursor()
|
|
|
|
def cursor(self):
|
|
return self.cur
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
return False
|
|
|
|
|
|
class _FakeDB:
|
|
def __init__(self, playlist=None, tracks=None):
|
|
self._playlist = playlist
|
|
self._tracks = tracks or []
|
|
self.marked_explored = False
|
|
|
|
def get_mirrored_playlist(self, pid):
|
|
return self._playlist
|
|
|
|
def get_mirrored_playlist_tracks(self, pid):
|
|
return self._tracks
|
|
|
|
def mark_mirrored_playlist_explored(self, pid):
|
|
self.marked_explored = True
|
|
|
|
def _get_connection(self):
|
|
return _FakeConn()
|
|
|
|
|
|
class _FakeCache:
|
|
def __init__(self):
|
|
self.entries = {}
|
|
|
|
def get_entity(self, source, kind, key):
|
|
return self.entries.get((source, kind, key))
|
|
|
|
def store_entity(self, source, kind, key, value):
|
|
self.entries[(source, kind, key)] = value
|
|
|
|
|
|
def _build_deps(
|
|
*,
|
|
request_payload=None,
|
|
spotify=None,
|
|
db=None,
|
|
discovery_source='spotify',
|
|
fallback_source='itunes',
|
|
cache=None,
|
|
):
|
|
deps = ex.PlaylistExplorerDeps(
|
|
request=_FakeRequest(request_payload or {}),
|
|
flask_response=_FakeResponse,
|
|
flask_jsonify=_fake_jsonify,
|
|
spotify_client=spotify or _FakeSpotify(),
|
|
get_database=lambda: db or _FakeDB(),
|
|
get_active_discovery_source=lambda: discovery_source,
|
|
get_metadata_fallback_client=lambda: spotify or _FakeSpotify(),
|
|
get_metadata_fallback_source=lambda: fallback_source,
|
|
get_metadata_cache=lambda: cache or _FakeCache(),
|
|
)
|
|
return deps
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation early exits
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_no_data_returns_400():
|
|
"""Empty/None request body → 400."""
|
|
deps = _build_deps(request_payload=None)
|
|
deps.request = _FakeRequest(None)
|
|
result = ex.playlist_explorer_build_tree(deps)
|
|
payload, status = result
|
|
assert status == 400
|
|
assert payload == {"success": False, "error": "No data provided"}
|
|
|
|
|
|
def test_missing_playlist_id_returns_400():
|
|
"""Payload without playlist_id → 400."""
|
|
deps = _build_deps(request_payload={'mode': 'albums'})
|
|
payload, status = ex.playlist_explorer_build_tree(deps)
|
|
assert status == 400
|
|
assert 'playlist_id' in payload['error']
|
|
|
|
|
|
def test_invalid_mode_returns_400():
|
|
"""mode != 'albums'/'discographies' → 400."""
|
|
deps = _build_deps(request_payload={'playlist_id': '1', 'mode': 'invalid'})
|
|
payload, status = ex.playlist_explorer_build_tree(deps)
|
|
assert status == 400
|
|
assert "'albums' or 'discographies'" in payload['error']
|
|
|
|
|
|
def test_playlist_not_found_returns_404():
|
|
"""Database returns no playlist for the given ID → 404."""
|
|
db = _FakeDB(playlist=None)
|
|
deps = _build_deps(request_payload={'playlist_id': '99'}, db=db)
|
|
payload, status = ex.playlist_explorer_build_tree(deps)
|
|
assert status == 404
|
|
|
|
|
|
def test_playlist_with_no_tracks_returns_400():
|
|
"""Playlist found but has no tracks → 400."""
|
|
db = _FakeDB(playlist={'name': 'P', 'image_url': ''}, tracks=[])
|
|
deps = _build_deps(request_payload={'playlist_id': '1'}, db=db)
|
|
payload, status = ex.playlist_explorer_build_tree(deps)
|
|
assert status == 400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Streaming response
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_success_returns_streaming_response_with_meta_line():
|
|
"""Successful build → Response wrapper with NDJSON generator that starts with 'meta'."""
|
|
db = _FakeDB(
|
|
playlist={'name': 'My Playlist', 'image_url': 'http://img'},
|
|
tracks=[{'track_name': 'T1', 'artist_name': 'Artist One', 'album_name': 'Album X', 'extra_data': None}],
|
|
)
|
|
spotify = _FakeSpotify(
|
|
search_results=[_FakeArtistMeta(name='Artist One', id='a-1')],
|
|
albums=[_FakeAlbum(id='alb-1', name='Album X', release_date='2024')],
|
|
)
|
|
deps = _build_deps(
|
|
request_payload={'playlist_id': '1', 'mode': 'discographies'},
|
|
spotify=spotify,
|
|
db=db,
|
|
)
|
|
|
|
response = ex.playlist_explorer_build_tree(deps)
|
|
|
|
# _FakeResponse exposes body_lines pre-collected from the generator
|
|
assert response.mimetype == 'application/x-ndjson'
|
|
lines = response.body_lines
|
|
assert len(lines) >= 2
|
|
|
|
# First line should be meta
|
|
first = json.loads(lines[0])
|
|
assert first['type'] == 'meta'
|
|
assert first['playlist_name'] == 'My Playlist'
|
|
assert first['source'] == 'spotify'
|
|
|
|
# Last line should be 'complete'
|
|
last = json.loads(lines[-1])
|
|
assert last['type'] == 'complete'
|
|
|
|
|
|
def test_marks_playlist_explored_at_end_of_stream():
|
|
"""When the streaming generator runs to completion, mark_mirrored_playlist_explored fires."""
|
|
db = _FakeDB(
|
|
playlist={'name': 'P', 'image_url': ''},
|
|
tracks=[{'track_name': 'T', 'artist_name': 'A', 'album_name': 'B', 'extra_data': None}],
|
|
)
|
|
spotify = _FakeSpotify(search_results=[_FakeArtistMeta(name='A')])
|
|
deps = _build_deps(
|
|
request_payload={'playlist_id': '1', 'mode': 'discographies'},
|
|
spotify=spotify,
|
|
db=db,
|
|
)
|
|
|
|
ex.playlist_explorer_build_tree(deps)
|
|
|
|
assert db.marked_explored is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Discovered-track grouping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_discovered_artist_grouping_uses_matched_data():
|
|
"""Tracks with matching-source extra_data → use matched_data['artists'][0]."""
|
|
db = _FakeDB(
|
|
playlist={'name': 'P', 'image_url': ''},
|
|
tracks=[
|
|
{
|
|
'track_name': 'T',
|
|
'artist_name': 'Local Artist Name', # raw
|
|
'album_name': 'Local Album',
|
|
'extra_data': json.dumps({
|
|
'discovered': True,
|
|
'provider': 'spotify',
|
|
'matched_data': {
|
|
'artists': [{'name': 'Discovered Artist', 'id': 'sp-aid'}],
|
|
'album': {'name': 'Discovered Album'},
|
|
},
|
|
}),
|
|
},
|
|
],
|
|
)
|
|
spotify = _FakeSpotify(
|
|
search_results=[_FakeArtistMeta(name='Discovered Artist')],
|
|
albums=[_FakeAlbum(id='alb-1', name='Discovered Album', release_date='2024')],
|
|
)
|
|
deps = _build_deps(
|
|
request_payload={'playlist_id': '1', 'mode': 'discographies'},
|
|
spotify=spotify, db=db,
|
|
)
|
|
|
|
response = ex.playlist_explorer_build_tree(deps)
|
|
|
|
artist_lines = [json.loads(line) for line in response.body_lines if json.loads(line).get('type') == 'artist']
|
|
assert len(artist_lines) == 1
|
|
assert artist_lines[0]['name'] == 'Discovered Artist'
|
|
assert artist_lines[0]['artist_id'] == 'sp-aid'
|
|
|
|
|
|
def test_provider_mismatch_falls_back_to_raw_track_name():
|
|
"""If discovered provider != active source, ignore matched_data, use raw artist_name."""
|
|
db = _FakeDB(
|
|
playlist={'name': 'P', 'image_url': ''},
|
|
tracks=[
|
|
{
|
|
'track_name': 'T',
|
|
'artist_name': 'Raw Artist',
|
|
'album_name': 'Raw Album',
|
|
'extra_data': json.dumps({
|
|
'discovered': True,
|
|
'provider': 'itunes', # mismatch
|
|
'matched_data': {
|
|
'artists': [{'name': 'iTunes Artist'}],
|
|
},
|
|
}),
|
|
},
|
|
],
|
|
)
|
|
# Active source is spotify (default)
|
|
spotify = _FakeSpotify(search_results=[_FakeArtistMeta(name='Raw Artist')])
|
|
deps = _build_deps(
|
|
request_payload={'playlist_id': '1', 'mode': 'discographies'},
|
|
spotify=spotify, db=db,
|
|
)
|
|
|
|
response = ex.playlist_explorer_build_tree(deps)
|
|
|
|
artist_lines = [json.loads(line) for line in response.body_lines if json.loads(line).get('type') == 'artist']
|
|
assert artist_lines[0]['name'] == 'Raw Artist' # NOT 'iTunes Artist'
|