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.
SoulSync/tests/test_acoustid_error_reporti...

181 lines
6.5 KiB

"""AcoustID error-vs-no-match reporting.
Regression for the masking bug: an invalid API key (and other lookup errors)
used to collapse into the same `None` as a genuine no-match, so the UI showed a
benign "Skipped" and the "Test API key" button reported a dead key as valid.
These tests pin the distinction end to end:
- lookup_with_status separates ok / no_match / error / no_backend / unavailable
- fingerprint_and_lookup (legacy) stays dict-or-None
- verify_audio_file -> ERROR for a real error, SKIP for a genuine no-match
- test_api_key reports an invalid key (API error code 4) as invalid
"""
from __future__ import annotations
import sys
import types
from unittest.mock import MagicMock, patch
import pytest
import core.acoustid_client as acc
from core.acoustid_client import AcoustIDClient
from core.acoustid_verification import AcoustIDVerification, VerificationResult
# ── lookup_with_status: structured status distinction ──────────────────────
def _client_with_fake_acoustid(monkeypatch, *, match=None, raises=None):
"""An AcoustIDClient wired to a fake `acoustid` module so we can drive
match() without network or chromaprint."""
fake = types.ModuleType("acoustid")
class WebServiceError(Exception):
pass
class NoBackendError(Exception):
pass
class FingerprintGenerationError(Exception):
pass
fake.WebServiceError = WebServiceError
fake.NoBackendError = NoBackendError
fake.FingerprintGenerationError = FingerprintGenerationError
def _match(api_key, audio_file, parse=True):
if raises is not None:
raise raises
return match or []
fake.match = _match
monkeypatch.setitem(sys.modules, "acoustid", fake)
monkeypatch.setattr(acc, "ACOUSTID_AVAILABLE", True)
c = AcoustIDClient()
c._api_key = "testkey123" # bypass config
return c, fake
def test_lookup_status_ok(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"not audio") # mutagen -> None, channel check skipped
c, _ = _client_with_fake_acoustid(monkeypatch, match=[(0.95, "mbid-1", "Title", "Artist")])
res = c.lookup_with_status(str(f))
assert res["status"] == "ok"
assert res["recordings"] and res["recordings"][0]["mbid"] == "mbid-1"
def test_lookup_status_no_match(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"not audio")
c, _ = _client_with_fake_acoustid(monkeypatch, match=[])
res = c.lookup_with_status(str(f))
assert res["status"] == "no_match"
assert res["recordings"] == []
def test_lookup_status_error_on_webservice(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"not audio")
c, fake = _client_with_fake_acoustid(monkeypatch)
# invalid key surfaces (old pyacoustid) as the bare "status: error"
monkeypatch.setattr(c, "_api_key", "testkey123")
def _raise(*a, **k):
raise fake.WebServiceError("status: error")
fake.match = _raise
res = c.lookup_with_status(str(f))
assert res["status"] == "error"
assert res["invalid_key"] is True
def test_lookup_status_no_backend(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"not audio")
c, fake = _client_with_fake_acoustid(monkeypatch)
def _raise(*a, **k):
raise fake.NoBackendError()
fake.match = _raise
assert c.lookup_with_status(str(f))["status"] == "no_backend"
def test_lookup_status_unavailable_without_key(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"x")
monkeypatch.setattr(acc, "ACOUSTID_AVAILABLE", True)
c = AcoustIDClient()
c._api_key = "" # no key
assert c.lookup_with_status(str(f))["status"] == "unavailable"
# ── fingerprint_and_lookup keeps its dict-or-None contract ─────────────────
def test_legacy_wrapper_returns_dict_on_match(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"not audio")
c, _ = _client_with_fake_acoustid(monkeypatch, match=[(0.9, "mbid-1", "T", "A")])
out = c.fingerprint_and_lookup(str(f))
assert out is not None and out["recordings"][0]["mbid"] == "mbid-1"
def test_legacy_wrapper_returns_none_on_error(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"not audio")
c, fake = _client_with_fake_acoustid(monkeypatch)
def _raise(*a, **k):
raise fake.WebServiceError("status: error")
fake.match = _raise
assert c.fingerprint_and_lookup(str(f)) is None
# ── verify_audio_file: ERROR vs SKIP ───────────────────────────────────────
def _verifier_with_lookup(result):
v = AcoustIDVerification()
client = MagicMock()
client.is_available.return_value = (True, "ready")
client.lookup_with_status.return_value = result
v.acoustid_client = client
return v
def test_verify_reports_error_for_api_error():
v = _verifier_with_lookup({"status": "error", "recordings": [], "error": "AcoustID API error: invalid"})
result, msg = v.verify_audio_file("/x.flac", "Song", "Artist")
assert result == VerificationResult.ERROR
assert "error" in msg.lower() or "invalid" in msg.lower()
def test_verify_reports_skip_for_no_match():
v = _verifier_with_lookup({"status": "no_match", "recordings": [], "error": "Track not found in AcoustID database"})
result, msg = v.verify_audio_file("/x.flac", "Song", "Artist")
assert result == VerificationResult.SKIP
assert "no match" in msg.lower() or "not found" in msg.lower()
# ── test_api_key: invalid key reported as invalid ──────────────────────────
def _api_response(payload):
r = MagicMock()
r.json.return_value = payload
return r
def test_test_api_key_invalid_when_code_4(monkeypatch):
c = AcoustIDClient()
c._api_key = "badkey"
with patch("requests.get",
return_value=_api_response({"status": "error", "error": {"code": 4, "message": "invalid API key"}})):
ok, msg = c.test_api_key()
assert ok is False
assert "invalid" in msg.lower()
def test_test_api_key_valid_when_accepted(monkeypatch):
c = AcoustIDClient()
c._api_key = "goodkey"
# A non-key error (e.g. bad dummy fingerprint) means the key was accepted.
with patch("requests.get",
return_value=_api_response({"status": "error", "error": {"code": 3, "message": "invalid fingerprint"}})):
ok, msg = c.test_api_key()
assert ok is True
assert "valid" in msg.lower()