Files
musicseerr/backend/tests/routes/test_playlist_routes.py
T
2026-04-03 15:53:00 +01:00

388 lines
14 KiB
Python

import pytest
from unittest.mock import AsyncMock, patch
from io import BytesIO
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.v1.routes.playlists import router as playlists_router
from core.dependencies import get_playlist_service, get_jellyfin_library_service, get_local_files_service, get_navidrome_library_service
from core.exceptions import PlaylistNotFoundError, InvalidPlaylistDataError, ResourceNotFoundError, ValidationError
from core.exception_handlers import resource_not_found_handler, validation_error_handler, general_exception_handler
from repositories.playlist_repository import PlaylistRecord, PlaylistSummaryRecord, PlaylistTrackRecord
def _playlist(id="p-1", name="Test", cover_image_path=None) -> PlaylistRecord:
return PlaylistRecord(
id=id, name=name, cover_image_path=cover_image_path,
created_at="2025-01-01T00:00:00+00:00",
updated_at="2025-01-01T00:00:00+00:00",
)
def _summary(id="p-1", name="Test") -> PlaylistSummaryRecord:
return PlaylistSummaryRecord(
id=id, name=name, track_count=3, total_duration=600,
cover_urls=["http://example.com/cover.jpg"],
cover_image_path=None,
created_at="2025-01-01T00:00:00+00:00",
updated_at="2025-01-01T00:00:00+00:00",
)
def _track(id="t-1", playlist_id="p-1", position=0) -> PlaylistTrackRecord:
return PlaylistTrackRecord(
id=id, playlist_id=playlist_id, position=position,
track_name="Song", artist_name="Artist", album_name="Album",
album_id=None, artist_id=None, track_source_id=None, cover_url="http://img/1",
source_type="local", available_sources=None, format=None,
track_number=1, disc_number=2, duration=180,
created_at="2025-01-01T00:00:00+00:00",
)
@pytest.fixture
def mock_playlist_service():
mock = AsyncMock()
mock.create_playlist.return_value = _playlist()
mock.get_playlist.return_value = _playlist()
mock.get_all_playlists.return_value = [_summary()]
mock.get_playlist_with_tracks.return_value = (_playlist(), [_track()])
mock.update_playlist.return_value = _playlist()
mock.update_playlist_with_detail.return_value = (_playlist(), [_track()])
mock.delete_playlist.return_value = None
mock.add_tracks.return_value = [_track()]
mock.remove_track.return_value = None
mock.reorder_track.return_value = 2
mock.update_track_source.return_value = _track()
mock.get_tracks.return_value = [_track()]
mock.upload_cover.return_value = "/api/v1/playlists/p-1/cover"
mock.get_cover_path.return_value = None
mock.remove_cover.return_value = None
return mock
@pytest.fixture
def mock_jf_service():
return AsyncMock()
@pytest.fixture
def mock_local_service():
return AsyncMock()
@pytest.fixture
def mock_nd_service():
return AsyncMock()
@pytest.fixture
def client(mock_playlist_service, mock_jf_service, mock_local_service, mock_nd_service):
app = FastAPI()
app.include_router(playlists_router)
app.dependency_overrides[get_playlist_service] = lambda: mock_playlist_service
app.dependency_overrides[get_jellyfin_library_service] = lambda: mock_jf_service
app.dependency_overrides[get_local_files_service] = lambda: mock_local_service
app.dependency_overrides[get_navidrome_library_service] = lambda: mock_nd_service
app.add_exception_handler(ResourceNotFoundError, resource_not_found_handler)
app.add_exception_handler(ValidationError, validation_error_handler)
app.add_exception_handler(Exception, general_exception_handler)
return TestClient(app)
class TestListPlaylists:
def test_success(self, client):
resp = client.get("/playlists")
assert resp.status_code == 200
data = resp.json()
assert len(data["playlists"]) == 1
assert data["playlists"][0]["name"] == "Test"
assert data["playlists"][0]["track_count"] == 3
def test_empty(self, client, mock_playlist_service):
mock_playlist_service.get_all_playlists.return_value = []
resp = client.get("/playlists")
assert resp.status_code == 200
assert len(resp.json()["playlists"]) == 0
class TestCreatePlaylist:
def test_success(self, client):
resp = client.post(
"/playlists",
content=b'{"name": "My Playlist"}',
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 201
data = resp.json()
assert data["id"] == "p-1"
assert data["tracks"] == []
assert "cover_image_path" not in data
def test_validation_error(self, client, mock_playlist_service):
mock_playlist_service.create_playlist.side_effect = InvalidPlaylistDataError("empty name")
resp = client.post(
"/playlists",
content=b'{"name": ""}',
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 400
class TestGetPlaylist:
def test_success(self, client):
resp = client.get("/playlists/p-1")
assert resp.status_code == 200
data = resp.json()
assert data["id"] == "p-1"
assert len(data["tracks"]) == 1
assert data["track_count"] == 1
assert data["tracks"][0]["disc_number"] == 2
assert "cover_image_path" not in data
def test_not_found(self, client, mock_playlist_service):
mock_playlist_service.get_playlist_with_tracks.side_effect = PlaylistNotFoundError("nope")
resp = client.get("/playlists/nonexistent")
assert resp.status_code == 404
class TestUpdatePlaylist:
def test_success(self, client):
resp = client.put(
"/playlists/p-1",
content=b'{"name": "Renamed"}',
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 200
assert resp.json()["id"] == "p-1"
assert "cover_image_path" not in resp.json()
def test_not_found(self, client, mock_playlist_service):
mock_playlist_service.update_playlist_with_detail.side_effect = PlaylistNotFoundError("nope")
resp = client.put(
"/playlists/nonexistent",
content=b'{"name": "X"}',
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 404
class TestDeletePlaylist:
def test_success(self, client):
resp = client.delete("/playlists/p-1")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
def test_not_found(self, client, mock_playlist_service):
mock_playlist_service.delete_playlist.side_effect = PlaylistNotFoundError("nope")
resp = client.delete("/playlists/nonexistent")
assert resp.status_code == 404
class TestAddTracks:
def test_success(self, client):
body = {
"tracks": [
{"track_name": "S", "artist_name": "A", "album_name": "AL", "disc_number": 2},
],
}
resp = client.post(
"/playlists/p-1/tracks",
content=__import__("json").dumps(body).encode(),
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 201
assert len(resp.json()["tracks"]) == 1
assert resp.json()["tracks"][0]["disc_number"] == 2
def test_empty_tracks(self, client, mock_playlist_service):
mock_playlist_service.add_tracks.side_effect = InvalidPlaylistDataError("empty")
resp = client.post(
"/playlists/p-1/tracks",
content=b'{"tracks": []}',
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 400
def test_playlist_not_found(self, client, mock_playlist_service):
mock_playlist_service.add_tracks.side_effect = PlaylistNotFoundError("nope")
body = {"tracks": [{"track_name": "S", "artist_name": "A", "album_name": "AL"}]}
resp = client.post(
"/playlists/nonexistent/tracks",
content=__import__("json").dumps(body).encode(),
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 404
class TestRemoveTrack:
def test_success(self, client):
resp = client.delete("/playlists/p-1/tracks/t-1")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
def test_not_found(self, client, mock_playlist_service):
mock_playlist_service.remove_track.side_effect = PlaylistNotFoundError("nope")
resp = client.delete("/playlists/p-1/tracks/nonexistent")
assert resp.status_code == 404
class TestReorderTrack:
def test_success(self, client):
resp = client.patch(
"/playlists/p-1/tracks/reorder",
content=b'{"track_id": "t-1", "new_position": 2}',
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["actual_position"] == 2
def test_invalid_position(self, client, mock_playlist_service):
mock_playlist_service.reorder_track.side_effect = InvalidPlaylistDataError("neg")
resp = client.patch(
"/playlists/p-1/tracks/reorder",
content=b'{"track_id": "t-1", "new_position": -1}',
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 400
class TestUpdateTrack:
def test_success(self, client, mock_playlist_service):
updated = _track()
updated.source_type = "youtube"
mock_playlist_service.update_track_source.return_value = updated
resp = client.patch(
"/playlists/p-1/tracks/t-1",
content=b'{"source_type": "youtube"}',
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 200
assert resp.json()["source_type"] == "youtube"
def test_not_found(self, client, mock_playlist_service):
mock_playlist_service.update_track_source.side_effect = PlaylistNotFoundError("nope")
resp = client.patch(
"/playlists/p-1/tracks/nonexistent",
content=b'{"source_type": "youtube"}',
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 404
class TestUploadCover:
def test_success(self, client):
resp = client.post(
"/playlists/p-1/cover",
files={"cover_image": ("cover.png", b"PNG_DATA", "image/png")},
)
assert resp.status_code == 200
assert resp.json()["cover_url"] == "/api/v1/playlists/p-1/cover"
def test_invalid_type(self, client, mock_playlist_service):
mock_playlist_service.upload_cover.side_effect = InvalidPlaylistDataError("bad type")
resp = client.post(
"/playlists/p-1/cover",
files={"cover_image": ("doc.pdf", b"data", "application/pdf")},
)
assert resp.status_code == 400
class TestGetCover:
def test_no_cover(self, client):
resp = client.get("/playlists/p-1/cover")
assert resp.status_code == 404
def test_with_cover(self, client, mock_playlist_service, tmp_path):
cover_file = tmp_path / "cover.png"
cover_file.write_bytes(b"\x89PNG\r\n\x1a\n")
mock_playlist_service.get_cover_path.return_value = cover_file
resp = client.get("/playlists/p-1/cover")
assert resp.status_code == 200
assert resp.headers["content-type"] == "image/png"
assert "max-age=3600" in resp.headers.get("cache-control", "")
class TestRemoveCover:
def test_success(self, client):
resp = client.delete("/playlists/p-1/cover")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
class TestCheckTrackMembership:
def test_success(self, client, mock_playlist_service):
mock_playlist_service.check_track_membership.return_value = {
"p-1": [0, 1],
"p-2": [1],
}
resp = client.post(
"/playlists/check-tracks",
json={
"tracks": [
{"track_name": "Song A", "artist_name": "Artist", "album_name": "Album"},
{"track_name": "Song B", "artist_name": "Artist2", "album_name": "Album2"},
]
},
)
assert resp.status_code == 200
data = resp.json()
assert data["membership"]["p-1"] == [0, 1]
assert data["membership"]["p-2"] == [1]
def test_empty_tracks(self, client, mock_playlist_service):
mock_playlist_service.check_track_membership.return_value = {}
resp = client.post(
"/playlists/check-tracks",
json={"tracks": []},
)
assert resp.status_code == 200
assert resp.json()["membership"] == {}
class TestResolveSources:
def test_success(self, client, mock_playlist_service):
mock_playlist_service.resolve_track_sources.return_value = {
"t-1": ["jellyfin", "local"],
"t-2": ["jellyfin"],
}
resp = client.post("/playlists/p-1/resolve-sources")
assert resp.status_code == 200
data = resp.json()
assert data["sources"]["t-1"] == ["jellyfin", "local"]
assert data["sources"]["t-2"] == ["jellyfin"]
def test_empty_sources(self, client, mock_playlist_service):
mock_playlist_service.resolve_track_sources.return_value = {}
resp = client.post("/playlists/p-1/resolve-sources")
assert resp.status_code == 200
assert resp.json()["sources"] == {}
def test_not_found(self, client, mock_playlist_service):
mock_playlist_service.resolve_track_sources.side_effect = PlaylistNotFoundError("nope")
resp = client.post("/playlists/p-1/resolve-sources")
assert resp.status_code == 404
class TestUpdateTrackSourceResolution:
def test_returns_updated_track_source_id(self, client, mock_playlist_service):
updated = _track()
updated.source_type = "jellyfin"
updated.track_source_id = "jf-resolved-id"
updated.available_sources = ["jellyfin", "local"]
mock_playlist_service.update_track_source.return_value = updated
resp = client.patch(
"/playlists/p-1/tracks/t-1",
content=b'{"source_type": "jellyfin"}',
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 200
data = resp.json()
assert data["source_type"] == "jellyfin"
assert data["track_source_id"] == "jf-resolved-id"
assert data["available_sources"] == ["jellyfin", "local"]