"""Unit tests for synchronizer algorithm helpers. These tests exercise the pure-Python logic inside each synchronizer module — no DB, no HTTP, no Celery. Any function that takes only plain data structures (lists, dicts, dataclasses) is tested here. """ from dataclasses import dataclass from typing import Optional # --------------------------------------------------------------------------- # Helpers to build minimal track-like objects / dicts # --------------------------------------------------------------------------- @dataclass class _Track: """Minimal stand-in for GenericTrack (only fields used by algorithm helpers).""" spotify_uri: str isrc: Optional[str] = None name: str = "Track" artists: list = None deezer_id: Optional[int] = None def __post_init__(self): if self.artists is None: self.artists = [] def _item(uri: str, is_local: bool = False) -> dict: """Build a minimal Spotify playlist item dict.""" return {"track": {"uri": uri, "name": "T"}, "is_local": is_local} # =========================================================================== # SpotifySynchronizer helpers # =========================================================================== from playlist_sync.synchronizers.spotify import ( _get_local_positions, compare_sequences, ) class TestGetLocalPositions: def test_no_locals(self): items = [_item("a"), _item("b")] assert _get_local_positions(items) == [] def test_mixed(self): items = [ _item("a"), _item("b", is_local=True), _item("c"), _item("d", is_local=True), ] assert _get_local_positions(items) == [1, 3] def test_all_local(self): items = [_item("a", is_local=True), _item("b", is_local=True)] assert _get_local_positions(items) == [0, 1] class TestCompareSequences: """compare_sequences yields the minimal edit script + running target offset.""" def test_equal_sequences_yield_nothing(self): assert list(compare_sequences(["a", "b", "c"], ["a", "b", "c"])) == [] def test_empty_target_all_insert(self): ops = list(compare_sequences(["a", "b"], [])) assert ops == [("insert", 0, 0, 0, 2, 0)] def test_empty_source_all_delete(self): ops = list(compare_sequences([], ["a", "b"])) assert ops == [("delete", 0, 2, 0, 0, 0)] def test_preserves_source_duplicates_on_insert(self): # source has two "a" then "b"; reproducing them all must be one insert ops = list(compare_sequences(["a", "a", "b"], [])) assert ops == [("insert", 0, 0, 0, 3, 0)] def test_offset_accumulates_across_ops(self): # source [a, x, b], target [a, b] → insert x at index 1 (offset starts 0) ops = list(compare_sequences(["a", "x", "b"], ["a", "b"])) # the equal "a" at [0:1] is skipped; insert "x" at t1=1 with offset 0 assert ops == [("insert", 1, 1, 1, 2, 0)] def test_delete_then_insert(self): # source [a, c], target [a, b] → delete b, insert c (or replace) ops = list(compare_sequences(["a", "c"], ["a", "b"])) # SequenceMatcher emits this as a single replace assert ops == [("replace", 1, 2, 1, 2, 0)] # =========================================================================== # YoutubeSynchronizer — _score_video # =========================================================================== from playlist_sync.synchronizers.youtube import _score_video def _yt_item( channel_id: str = "ch1", channel_title: str = "Some Channel", title: str = "Cool Song", ) -> dict: return { "snippet": { "channelId": channel_id, "channelTitle": channel_title, "title": title, } } class TestScoreVideo: def test_blacklisted_channel_gets_very_low_score(self): item = _yt_item(channel_id="bad_channel") score = _score_video( item, "ch_name", whitelisted=[], blacklisted=["bad_channel"] ) assert score == -1000 def test_whitelisted_channel_gets_boost(self): item = _yt_item(channel_id="good_channel") score = _score_video( item, "ch_name", whitelisted=["good_channel"], blacklisted=[] ) assert score >= 100 def test_official_suffix_in_channel_name_adds_score(self): item = _yt_item(channel_title="ArtistName Official") base = _score_video(_yt_item(channel_title="Just A Band"), "", [], []) official = _score_video(item, "", [], []) assert official > base def test_blacklisted_title_word_reduces_score(self): clean = _score_video(_yt_item(title="Good Song"), "", [], []) dirty = _score_video(_yt_item(title="Good Song (karaoke)"), "", [], []) assert dirty < clean def test_neutral_item_scores_zero(self): item = _yt_item( channel_id="neutral", channel_title="Some Artist", title="Song Title" ) score = _score_video(item, "some_name", whitelisted=[], blacklisted=[]) assert score == 0 def test_whitelisted_beats_blacklisted_title(self): """Even a blacklisted title phrase shouldn't outweigh a whitelisted channel.""" item = _yt_item(channel_id="wl", channel_title="Artist", title="Song (karaoke)") score = _score_video(item, "wl", whitelisted=["wl"], blacklisted=[]) assert score > 0 # =========================================================================== # DeezerSynchronizer — ISRC cache cutoff (regression: naive vs aware datetime) # =========================================================================== from datetime import datetime, timedelta from playlist_sync.synchronizers.deezer import _ISRC_CACHE_MAX_AGE_DAYS @dataclass class _CacheEntry: """Minimal stand-in for SynchronizationTrack (only match_date is needed here).""" isrc: str track_id: str match_date: datetime # always naive, as MySQL returns def _make_cutoff() -> datetime: """Mirrors the production code: returns a naive UTC datetime.""" return datetime.utcnow() - timedelta(days=_ISRC_CACHE_MAX_AGE_DAYS) class TestIsrcCacheCutoff: def test_cutoff_is_naive(self): """The cutoff must be a naive datetime so it can be compared to DB values.""" cutoff = _make_cutoff() assert cutoff.tzinfo is None, ( "cutoff must be timezone-naive to match MySQL values" ) def test_recent_entry_passes_cutoff(self): entry = _CacheEntry(isrc="A", track_id="1", match_date=datetime.utcnow()) cutoff = _make_cutoff() assert entry.match_date >= cutoff def test_old_entry_fails_cutoff(self): old_date = datetime.utcnow() - timedelta(days=_ISRC_CACHE_MAX_AGE_DAYS + 1) entry = _CacheEntry(isrc="A", track_id="1", match_date=old_date) cutoff = _make_cutoff() assert entry.match_date < cutoff def test_entry_exactly_at_boundary_passes(self): """An entry exactly at the cutoff age should still be valid (>= comparison).""" boundary = datetime.utcnow() - timedelta(days=_ISRC_CACHE_MAX_AGE_DAYS) entry = _CacheEntry(isrc="A", track_id="1", match_date=boundary) cutoff = _make_cutoff() # Allow 1 second tolerance for test execution time assert entry.match_date >= cutoff - timedelta(seconds=1) def test_naive_vs_aware_raises(self): """Regression: naive match_date vs aware datetime must raise TypeError.""" from datetime import timezone aware_cutoff = datetime.now(timezone.utc) - timedelta( days=_ISRC_CACHE_MAX_AGE_DAYS ) entry = _CacheEntry(isrc="A", track_id="1", match_date=datetime.utcnow()) try: _ = entry.match_date >= aware_cutoff raise AssertionError("Expected TypeError for naive vs aware comparison") except TypeError: pass # expected — this is the bug we fixed