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.
229 lines
9.5 KiB
229 lines
9.5 KiB
"""Pin the pure helpers in `core/library/artist_image.py`.
|
|
|
|
These back the new artist-image-to-disk feature added for issue
|
|
#572 (Navidrome can't show real artist photos because Navidrome has
|
|
no API for setting them — only reads `artist.jpg` from the artist
|
|
folder on disk).
|
|
|
|
Tests are intentionally fixture-driven (tmp_path) so they actually
|
|
exercise the filesystem code (atomic replace, overwrite guard,
|
|
missing folder), not just mock interactions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# derive_artist_folder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDeriveArtistFolder:
|
|
def test_one_level_up_from_album(self):
|
|
from core.library.artist_image import derive_artist_folder
|
|
# POSIX path
|
|
assert derive_artist_folder("/music/Drake/Views") == "/music/Drake"
|
|
|
|
def test_handles_trailing_slash(self):
|
|
"""Caller might pass an album folder with a trailing slash.
|
|
Without trimming, `os.path.dirname` returns the input unchanged
|
|
— silently breaks the up-one-level contract."""
|
|
from core.library.artist_image import derive_artist_folder
|
|
assert derive_artist_folder("/music/Drake/Views/") == "/music/Drake"
|
|
|
|
def test_empty_string_returns_empty(self):
|
|
from core.library.artist_image import derive_artist_folder
|
|
assert derive_artist_folder("") == ""
|
|
|
|
def test_none_returns_empty(self):
|
|
from core.library.artist_image import derive_artist_folder
|
|
assert derive_artist_folder(None) == ""
|
|
|
|
def test_non_string_returns_empty(self):
|
|
"""Defensive — caller might hand us a Path object or similar.
|
|
Currently we require str; return empty rather than raise."""
|
|
from core.library.artist_image import derive_artist_folder
|
|
assert derive_artist_folder(42) == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# pick_artist_image_url
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPickArtistImageUrl:
|
|
def test_returns_image_url_when_set(self):
|
|
from core.library.artist_image import pick_artist_image_url
|
|
artist = SimpleNamespace(image_url="https://example.com/drake.jpg")
|
|
assert pick_artist_image_url(artist) == "https://example.com/drake.jpg"
|
|
|
|
def test_returns_none_when_empty_string(self):
|
|
from core.library.artist_image import pick_artist_image_url
|
|
assert pick_artist_image_url(SimpleNamespace(image_url="")) is None
|
|
|
|
def test_returns_none_when_attribute_missing(self):
|
|
from core.library.artist_image import pick_artist_image_url
|
|
assert pick_artist_image_url(SimpleNamespace()) is None
|
|
|
|
def test_returns_none_when_artist_is_none(self):
|
|
from core.library.artist_image import pick_artist_image_url
|
|
assert pick_artist_image_url(None) is None
|
|
|
|
def test_strips_whitespace(self):
|
|
from core.library.artist_image import pick_artist_image_url
|
|
artist = SimpleNamespace(image_url=" https://example.com/drake.jpg ")
|
|
assert pick_artist_image_url(artist) == "https://example.com/drake.jpg"
|
|
|
|
def test_returns_none_when_non_string(self):
|
|
from core.library.artist_image import pick_artist_image_url
|
|
# int / list / dict would all hit the `isinstance(..., str)` guard
|
|
assert pick_artist_image_url(SimpleNamespace(image_url=42)) is None
|
|
assert pick_artist_image_url(SimpleNamespace(image_url=["url"])) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# download_image_bytes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _fake_response(status_code=200, content_type="image/jpeg", body=b"\x89PNG..."):
|
|
resp = MagicMock()
|
|
resp.status_code = status_code
|
|
resp.headers = {"Content-Type": content_type}
|
|
resp.content = body
|
|
return resp
|
|
|
|
|
|
class TestDownloadImageBytes:
|
|
def test_returns_bytes_on_success(self):
|
|
from core.library import artist_image as ai
|
|
fake = _fake_response(body=b"image-data-here")
|
|
with patch.object(ai, "requests") as r:
|
|
r.get.return_value = fake
|
|
result = ai.download_image_bytes("https://example.com/x.jpg")
|
|
assert result == b"image-data-here"
|
|
|
|
def test_returns_none_on_404(self):
|
|
from core.library import artist_image as ai
|
|
fake = _fake_response(status_code=404)
|
|
with patch.object(ai, "requests") as r:
|
|
r.get.return_value = fake
|
|
assert ai.download_image_bytes("https://example.com/x.jpg") is None
|
|
|
|
def test_returns_none_on_non_image_content_type(self):
|
|
"""Defensive: if a URL returns HTML or JSON (e.g. an error page),
|
|
don't try to write it as artist.jpg."""
|
|
from core.library import artist_image as ai
|
|
fake = _fake_response(content_type="text/html")
|
|
with patch.object(ai, "requests") as r:
|
|
r.get.return_value = fake
|
|
assert ai.download_image_bytes("https://example.com/x.jpg") is None
|
|
|
|
def test_returns_none_on_empty_body(self):
|
|
from core.library import artist_image as ai
|
|
fake = _fake_response(body=b"")
|
|
with patch.object(ai, "requests") as r:
|
|
r.get.return_value = fake
|
|
assert ai.download_image_bytes("https://example.com/x.jpg") is None
|
|
|
|
def test_returns_none_on_exception(self):
|
|
"""Network timeout / DNS failure / etc shouldn't raise to
|
|
the caller — caller just sees None and surfaces a generic
|
|
'image fetch failed' error to the user."""
|
|
from core.library import artist_image as ai
|
|
with patch.object(ai, "requests") as r:
|
|
r.get.side_effect = RuntimeError("network down")
|
|
assert ai.download_image_bytes("https://example.com/x.jpg") is None
|
|
|
|
def test_returns_none_for_empty_url(self):
|
|
from core.library.artist_image import download_image_bytes
|
|
assert download_image_bytes("") is None
|
|
assert download_image_bytes(None) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# write_artist_jpg
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWriteArtistJpg:
|
|
def test_writes_file_on_success(self, tmp_path):
|
|
from core.library.artist_image import write_artist_jpg
|
|
success, path = write_artist_jpg(str(tmp_path), b"image-bytes")
|
|
assert success is True
|
|
assert os.path.exists(path)
|
|
assert open(path, "rb").read() == b"image-bytes"
|
|
|
|
def test_returns_failure_when_folder_missing(self, tmp_path):
|
|
from core.library.artist_image import write_artist_jpg
|
|
missing = str(tmp_path / "does-not-exist")
|
|
success, reason = write_artist_jpg(missing, b"image-bytes")
|
|
assert success is False
|
|
assert "does not exist" in reason
|
|
|
|
def test_returns_failure_for_empty_bytes(self, tmp_path):
|
|
from core.library.artist_image import write_artist_jpg
|
|
success, reason = write_artist_jpg(str(tmp_path), b"")
|
|
assert success is False
|
|
assert "image bytes" in reason
|
|
|
|
def test_returns_failure_for_empty_folder(self):
|
|
from core.library.artist_image import write_artist_jpg
|
|
success, reason = write_artist_jpg("", b"image-bytes")
|
|
assert success is False
|
|
assert "folder" in reason
|
|
|
|
def test_respects_existing_file_without_overwrite(self, tmp_path):
|
|
"""Default overwrite=False protects user-supplied artist.jpg
|
|
from being clobbered by a programmatic update. User must opt
|
|
in to overwrite."""
|
|
from core.library.artist_image import write_artist_jpg
|
|
target = tmp_path / "artist.jpg"
|
|
target.write_bytes(b"user-supplied")
|
|
|
|
success, reason = write_artist_jpg(str(tmp_path), b"new-bytes")
|
|
assert success is False
|
|
assert "already exists" in reason
|
|
# Existing file must be untouched.
|
|
assert target.read_bytes() == b"user-supplied"
|
|
|
|
def test_overwrites_when_flag_set(self, tmp_path):
|
|
from core.library.artist_image import write_artist_jpg
|
|
target = tmp_path / "artist.jpg"
|
|
target.write_bytes(b"old-bytes")
|
|
|
|
success, path = write_artist_jpg(str(tmp_path), b"new-bytes", overwrite=True)
|
|
assert success is True
|
|
assert target.read_bytes() == b"new-bytes"
|
|
|
|
def test_atomic_write_no_temp_left_on_success(self, tmp_path):
|
|
"""`.tmp` artifact must be cleaned up by `os.replace`. Don't
|
|
leave litter behind for the next backup / sync run to puzzle
|
|
over."""
|
|
from core.library.artist_image import write_artist_jpg
|
|
success, _ = write_artist_jpg(str(tmp_path), b"image-bytes")
|
|
assert success is True
|
|
assert not (tmp_path / "artist.jpg.tmp").exists()
|
|
|
|
def test_atomic_write_cleans_temp_on_failure(self, tmp_path, monkeypatch):
|
|
"""If `os.replace` fails (permission, cross-device, etc),
|
|
the helper should remove the temp file rather than leaving
|
|
a half-written `.tmp` on disk."""
|
|
from core.library import artist_image as ai
|
|
|
|
def _failing_replace(src, dst):
|
|
raise OSError("simulated replace failure")
|
|
|
|
monkeypatch.setattr(os, "replace", _failing_replace)
|
|
success, reason = ai.write_artist_jpg(str(tmp_path), b"image-bytes")
|
|
assert success is False
|
|
assert "write failed" in reason
|
|
# Temp must not be left behind
|
|
assert not (tmp_path / "artist.jpg.tmp").exists()
|