A Python Extism plugin for Navidrome that scrobbles playback to a self-hosted Scrobbled (Last.fm-compatible) server, plus Gitea Actions CI that lints, tests, builds/validates, and publishes the .ndp artifact to pkgs.daubney.dev. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018n5ffHCn5QUjpDtuWTk3fh
143 lines
5.1 KiB
Python
143 lines
5.1 KiB
Python
"""Pure-logic tests for plugin/__init__.py.
|
|
|
|
These exercise only the business logic that has no dependency on the real
|
|
extism-py/wasm runtime (signing, form-encoding, track-field mapping), so they
|
|
run under plain python3/uv, with no wasm toolchain installed.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import unittest
|
|
from urllib.parse import parse_qs
|
|
|
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
STUBS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stubs")
|
|
|
|
# The stub `extism` module must be importable before `plugin` is, and take
|
|
# priority over any real `extism` package that happens to be installed.
|
|
sys.path.insert(0, STUBS_DIR)
|
|
sys.path.insert(0, REPO_ROOT)
|
|
|
|
import plugin # noqa: E402 (import after sys.path setup, by design)
|
|
|
|
SCROBBLING_SERVICE_SRC = "/Users/matt/PycharmProjects/ScrobblingService/src"
|
|
try:
|
|
sys.path.insert(0, SCROBBLING_SERVICE_SRC)
|
|
from scrobbler.lastfm import signature as scrobbling_service_signature
|
|
except ImportError:
|
|
scrobbling_service_signature = None
|
|
|
|
|
|
class ComputeApiSigTests(unittest.TestCase):
|
|
def test_matches_known_fixture(self):
|
|
params = {
|
|
"method": "auth.getMobileSession",
|
|
"api_key": "k",
|
|
"username": "alice",
|
|
"password": "p",
|
|
"format": "json",
|
|
}
|
|
# Hand-computed per the documented algorithm: sort keys (excluding
|
|
# format/callback/api_sig), concatenate key+value, append secret, md5-hex.
|
|
payload = "api_keykmethodauth.getMobileSessionpasswordpusernamealice"
|
|
expected = plugin._md5_hex((payload + "secret").encode("utf-8"))
|
|
self.assertEqual(plugin.compute_api_sig(params, "secret"), expected)
|
|
|
|
def test_excludes_unsigned_params(self):
|
|
with_format = {"a": "1", "format": "json"}
|
|
without_format = {"a": "1"}
|
|
self.assertEqual(
|
|
plugin.compute_api_sig(with_format, "secret"),
|
|
plugin.compute_api_sig(without_format, "secret"),
|
|
)
|
|
|
|
@unittest.skipUnless(
|
|
scrobbling_service_signature is not None,
|
|
"ScrobblingService repo not available on this machine",
|
|
)
|
|
def test_matches_scrobbling_service_signature(self):
|
|
params = {
|
|
"method": "track.scrobble",
|
|
"api_key": "abc123",
|
|
"sk": "sesskey",
|
|
"artist": "Boards of Canada",
|
|
"track": "Roygbiv",
|
|
"timestamp": "1700000000",
|
|
"format": "json",
|
|
}
|
|
expected = scrobbling_service_signature.sign(params, "shh")
|
|
self.assertEqual(plugin.compute_api_sig(params, "shh"), expected)
|
|
|
|
|
|
class Md5Tests(unittest.TestCase):
|
|
def test_known_vectors(self):
|
|
self.assertEqual(plugin._md5_hex(b""), "d41d8cd98f00b204e9800998ecf8427e")
|
|
self.assertEqual(plugin._md5_hex(b"abc"), "900150983cd24fb0d6963f7d28e17f72")
|
|
|
|
def test_fallback_matches_hashlib(self):
|
|
data = b"the quick brown fox jumps over the lazy dog, 123456789"
|
|
self.assertEqual(plugin._md5_fallback_hex(data), plugin._md5_hex(data))
|
|
|
|
|
|
class EncodeFormBodyTests(unittest.TestCase):
|
|
def test_round_trips_special_characters(self):
|
|
params = {"a": "1 2", "b": "x&y", "c": "h=llo"}
|
|
body = plugin.encode_form_body(params)
|
|
parsed = {k: v[0] for k, v in parse_qs(body).items()}
|
|
self.assertEqual(parsed, params)
|
|
|
|
def test_fallback_matches_urllib(self):
|
|
# Cross-check the hand-rolled fallback encoder against the real stdlib
|
|
# urlencode, since it must behave identically when it's the one used
|
|
# (i.e. when the extism-py runtime lacks urllib.parse).
|
|
from urllib.parse import urlencode as real_urlencode
|
|
|
|
params = {"a": "1 2", "b": "x&y=z", "c": "héllo"}
|
|
self.assertEqual(plugin._fallback_urlencode(params), real_urlencode(params))
|
|
|
|
|
|
class BuildTrackParamsTests(unittest.TestCase):
|
|
def test_required_fields_only(self):
|
|
track = {"artist": "Artist", "title": "Title"}
|
|
self.assertEqual(plugin.build_track_params(track), {"artist": "Artist", "track": "Title"})
|
|
|
|
def test_optional_fields_included_when_present(self):
|
|
track = {
|
|
"artist": "Artist",
|
|
"title": "Title",
|
|
"album": "Album",
|
|
"albumArtist": "Album Artist",
|
|
"duration": 123.6,
|
|
"trackNumber": 4,
|
|
"mbzRecordingId": "mbid-123",
|
|
}
|
|
params = plugin.build_track_params(track)
|
|
self.assertEqual(
|
|
params,
|
|
{
|
|
"artist": "Artist",
|
|
"track": "Title",
|
|
"album": "Album",
|
|
"albumArtist": "Album Artist",
|
|
"duration": "124",
|
|
"trackNumber": "4",
|
|
"mbid": "mbid-123",
|
|
},
|
|
)
|
|
|
|
def test_falsy_optional_fields_omitted(self):
|
|
track = {
|
|
"artist": "Artist",
|
|
"title": "Title",
|
|
"album": "",
|
|
"albumArtist": None,
|
|
"duration": 0,
|
|
"trackNumber": 0,
|
|
"mbzRecordingId": "",
|
|
}
|
|
self.assertEqual(plugin.build_track_params(track), {"artist": "Artist", "track": "Title"})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|