"""Tests für Cover-Funktionen.""" from io import BytesIO from pathlib import Path from unittest.mock import MagicMock, patch import httpx from PIL import Image from musiksammlung.cover import copy_covers, download_caa_covers, find_cover, prepare_cover def _make_image(path: Path, size: tuple[int, int] = (100, 100), mode: str = "RGB") -> Path: img = Image.new(mode, size, color=(200, 100, 50)) fmt = "PNG" if path.suffix.lower() == ".png" else "JPEG" if mode == "RGBA" and fmt == "JPEG": img = img.convert("RGB") img.save(str(path), fmt) return path class TestFindCover: """Tests für find_cover.""" def test_finds_frontcover_jpg(self, tmp_path: Path) -> None: (tmp_path / "front.jpg").touch() assert find_cover(tmp_path, "front") == tmp_path / "front.jpg" def test_finds_frontcover_png(self, tmp_path: Path) -> None: (tmp_path / "front.png").touch() assert find_cover(tmp_path, "front") == tmp_path / "front.png" def test_jpg_preferred_over_png(self, tmp_path: Path) -> None: (tmp_path / "front.jpg").touch() (tmp_path / "front.png").touch() # .jpg wird zuerst geprüft assert find_cover(tmp_path, "front") == tmp_path / "front.jpg" def test_finds_backcover(self, tmp_path: Path) -> None: (tmp_path / "back.jpg").touch() assert find_cover(tmp_path, "back") == tmp_path / "back.jpg" def test_returns_none_if_missing(self, tmp_path: Path) -> None: assert find_cover(tmp_path, "front") is None assert find_cover(tmp_path, "back") is None def test_follows_symlink(self, tmp_path: Path) -> None: real = tmp_path / "original.jpg" real.touch() link = tmp_path / "front.jpg" link.symlink_to(real) assert find_cover(tmp_path, "front") == link def test_ignores_wrong_names(self, tmp_path: Path) -> None: (tmp_path / "cover.jpg").touch() (tmp_path / "back.jpg").touch() assert find_cover(tmp_path, "front") is None class TestPrepareCover: def test_creates_jpeg_output(self, tmp_path: Path) -> None: src = _make_image(tmp_path / "src.jpg") dst = tmp_path / "out.jpg" prepare_cover(src, dst) assert dst.exists() assert Image.open(dst).format == "JPEG" def test_scales_down_large_image(self, tmp_path: Path) -> None: src = _make_image(tmp_path / "src.jpg", size=(2000, 2000)) dst = tmp_path / "out.jpg" prepare_cover(src, dst, max_size=1200) assert max(Image.open(dst).size) <= 1200 def test_does_not_upscale_small_image(self, tmp_path: Path) -> None: src = _make_image(tmp_path / "src.jpg", size=(300, 300)) dst = tmp_path / "out.jpg" prepare_cover(src, dst, max_size=1200) assert max(Image.open(dst).size) <= 300 def test_converts_rgba_to_rgb(self, tmp_path: Path) -> None: src = tmp_path / "src.png" Image.new("RGBA", (100, 100), (255, 0, 0, 128)).save(str(src), "PNG") dst = tmp_path / "out.jpg" prepare_cover(src, dst) assert Image.open(dst).mode == "RGB" def test_creates_parent_directory(self, tmp_path: Path) -> None: src = _make_image(tmp_path / "src.jpg") dst = tmp_path / "subdir" / "nested" / "out.jpg" prepare_cover(src, dst) assert dst.exists() class TestCopyCovers: def test_copies_front_cover(self, tmp_path: Path) -> None: src = _make_image(tmp_path / "src.jpg") copy_covers(src, None, tmp_path) assert (tmp_path / "front.jpg").exists() def test_copies_back_cover(self, tmp_path: Path) -> None: src = _make_image(tmp_path / "src.jpg") copy_covers(None, src, tmp_path) assert (tmp_path / "back.jpg").exists() def test_copies_both_covers(self, tmp_path: Path) -> None: front = _make_image(tmp_path / "front.jpg") back = _make_image(tmp_path / "back.jpg") copy_covers(front, back, tmp_path) assert (tmp_path / "front.jpg").exists() assert (tmp_path / "back.jpg").exists() def test_skips_nonexistent_front(self, tmp_path: Path) -> None: copy_covers(tmp_path / "nope.jpg", None, tmp_path) assert not (tmp_path / "front.jpg").exists() def test_skips_nonexistent_back(self, tmp_path: Path) -> None: copy_covers(None, tmp_path / "nope.jpg", tmp_path) assert not (tmp_path / "back.jpg").exists() def test_existing_frontcover_not_overwritten_when_no_source(self, tmp_path: Path) -> None: existing = _make_image(tmp_path / "front.jpg") original_mtime = existing.stat().st_mtime copy_covers(None, None, tmp_path) assert (tmp_path / "front.jpg").stat().st_mtime == original_mtime def _fake_image_bytes() -> bytes: """Erzeugt ein gültiges JPEG-Bild als bytes.""" buf = BytesIO() Image.new("RGB", (200, 200), (100, 150, 200)).save(buf, "JPEG") return buf.getvalue() def _mock_caa_response(status_code: int = 200, content: bytes = b"") -> MagicMock: """Erstellt ein Mock-httpx.Response für CAA-Requests.""" resp = MagicMock(spec=httpx.Response) resp.status_code = status_code resp.content = content resp.raise_for_status.return_value = None return resp class TestDownloadCaaCovers: """Tests für download_caa_covers.""" def test_downloads_front_and_back(self, tmp_path: Path) -> None: """Beide Cover werden heruntergeladen und als JPEG gespeichert.""" img_bytes = _fake_image_bytes() resp = _mock_caa_response(200, img_bytes) with patch("musiksammlung.cover.httpx.get", return_value=resp): download_caa_covers("test-mbid", tmp_path) assert (tmp_path / "front.jpg").exists() assert (tmp_path / "back.jpg").exists() # Ergebnis ist ein gültiges JPEG assert Image.open(tmp_path / "front.jpg").format == "JPEG" def test_404_skips_cover(self, tmp_path: Path) -> None: """404 → kein Cover, kein Fehler.""" resp_404 = _mock_caa_response(404) with patch("musiksammlung.cover.httpx.get", return_value=resp_404): download_caa_covers("no-cover-mbid", tmp_path) assert not (tmp_path / "front.jpg").exists() assert not (tmp_path / "back.jpg").exists() def test_http_error_continues(self, tmp_path: Path) -> None: """Netzwerkfehler → Warnung, kein Abbruch.""" with patch( "musiksammlung.cover.httpx.get", side_effect=httpx.HTTPError("timeout"), ): download_caa_covers("error-mbid", tmp_path) assert not (tmp_path / "front.jpg").exists() assert not (tmp_path / "back.jpg").exists() def test_skips_existing_cover(self, tmp_path: Path) -> None: """Bereits vorhandene Cover werden nicht überschrieben.""" existing = _make_image(tmp_path / "front.jpg") original_size = existing.stat().st_size img_bytes = _fake_image_bytes() resp = _mock_caa_response(200, img_bytes) with patch("musiksammlung.cover.httpx.get", return_value=resp) as mock_get: download_caa_covers("test-mbid", tmp_path) # front.jpg bleibt unverändert assert (tmp_path / "front.jpg").stat().st_size == original_size # back.jpg wird heruntergeladen (war nicht vorhanden) assert (tmp_path / "back.jpg").exists() # Nur ein HTTP-Request (für back), nicht zwei assert mock_get.call_count == 1 def test_front_only_on_back_404(self, tmp_path: Path) -> None: """Front 200, Back 404 → nur front.jpg erstellt.""" img_bytes = _fake_image_bytes() resp_ok = _mock_caa_response(200, img_bytes) resp_404 = _mock_caa_response(404) with patch( "musiksammlung.cover.httpx.get", side_effect=[resp_ok, resp_404], ): download_caa_covers("mixed-mbid", tmp_path) assert (tmp_path / "front.jpg").exists() assert not (tmp_path / "back.jpg").exists()