/
/
/
1"""Test Tidal Provider integration."""
2
3import json
4from collections.abc import AsyncGenerator
5from datetime import datetime
6from typing import Any
7from unittest.mock import AsyncMock, Mock, patch
8
9import pytest
10from music_assistant_models.enums import ExternalID, MediaType
11from music_assistant_models.errors import LoginFailed, MediaNotFoundError
12from music_assistant_models.media_items import Album, Artist, Playlist, Track
13
14from music_assistant.providers.tidal.provider import TidalProvider
15from tests.common import use_real_create_task
16
17
18@pytest.fixture
19def mass_mock() -> Mock:
20 """Return a mock MusicAssistant instance."""
21 mass = Mock()
22 mass.http_session = AsyncMock()
23 mass.metadata.locale = "en_US"
24 mass.cache.get = AsyncMock(return_value=None)
25 mass.cache.get_with_freshness = AsyncMock(return_value=(None, False, False))
26 mass.cache.set = AsyncMock()
27 mass.cache.delete = AsyncMock()
28 use_real_create_task(mass)
29 return mass
30
31
32@pytest.fixture
33def manifest_mock() -> Mock:
34 """Return a mock provider manifest."""
35 manifest = Mock()
36 manifest.domain = "tidal"
37 return manifest
38
39
40@pytest.fixture
41def config_mock() -> Mock:
42 """Return a mock provider config."""
43 config = Mock()
44 config.name = "Tidal Test"
45 config.instance_id = "tidal_test"
46 config.enabled = True
47 config.get_value.side_effect = lambda key: {
48 "auth_token": "mock_access_token",
49 "refresh_token": "mock_refresh_token",
50 "expiry_time": 1234567890,
51 "user_id": "12345",
52 "log_level": "INFO",
53 }.get(key, "INFO" if "log" in key else None)
54 return config
55
56
57@pytest.fixture
58def provider(mass_mock: Mock, manifest_mock: Mock, config_mock: Mock) -> TidalProvider:
59 """Return a TidalProvider instance."""
60 return TidalProvider(mass_mock, manifest_mock, config_mock)
61
62
63async def test_provider_initialization(
64 mass_mock: Mock, manifest_mock: Mock, config_mock: Mock
65) -> None:
66 """Test provider initialization creates all managers."""
67 provider = TidalProvider(mass_mock, manifest_mock, config_mock)
68
69 assert provider.auth is not None
70 assert provider.api is not None
71 assert provider.library is not None
72 assert provider.media is not None
73 assert provider.playlists is not None
74 assert provider.recommendations_manager is not None
75 assert provider.streaming is not None
76
77
78_SETUP_VALUES = {
79 "auth_token": "mock_access_token",
80 "refresh_token": "mock_refresh_token",
81 "expiry_time": 1234567890,
82 "user_id": "12345",
83}
84
85
86async def test_handle_async_init_success(provider: TidalProvider) -> None:
87 """Test successful async initialization."""
88 with (
89 patch.object(
90 provider,
91 "get_setup_value",
92 side_effect=lambda key, default=None: _SETUP_VALUES.get(key, default),
93 ),
94 patch.object(provider.auth, "initialize", new_callable=AsyncMock) as mock_init,
95 patch.object(provider.api, "get", new_callable=AsyncMock) as mock_get,
96 patch.object(provider, "get_user", new_callable=AsyncMock) as mock_get_user,
97 patch.object(provider.auth, "update_user_info", new_callable=AsyncMock),
98 ):
99 mock_init.return_value = True
100 mock_get.return_value = {"userId": "12345", "sessionId": "session_123"}
101 mock_get_user.return_value = {"id": "12345", "username": "testuser"}
102
103 await provider.handle_async_init()
104
105 mock_init.assert_called_once()
106 mock_get.assert_called_with("sessions")
107
108
109async def test_handle_async_init_migrates_iso_expiry(provider: TidalProvider) -> None:
110 """Test a legacy ISO-string expiry_time is converted to a timestamp and persisted."""
111 values = dict(_SETUP_VALUES)
112 values["expiry_time"] = "2026-01-01T12:00:00+00:00"
113
114 with (
115 patch.object(
116 provider,
117 "get_setup_value",
118 side_effect=lambda key, default=None: values.get(key, default),
119 ),
120 patch.object(provider, "_update_setup_data") as mock_update,
121 patch.object(provider.auth, "initialize", new_callable=AsyncMock) as mock_init,
122 patch.object(provider.api, "get", new_callable=AsyncMock) as mock_get,
123 patch.object(provider, "get_user", new_callable=AsyncMock) as mock_get_user,
124 patch.object(provider.auth, "update_user_info", new_callable=AsyncMock),
125 ):
126 mock_init.return_value = True
127 mock_get.return_value = {"userId": "12345", "sessionId": "session_123"}
128 mock_get_user.return_value = {"id": "12345"}
129
130 await provider.handle_async_init()
131
132 expected_ts = datetime.fromisoformat("2026-01-01T12:00:00+00:00").timestamp()
133 mock_update.assert_called_once_with("expiry_time", expected_ts)
134 auth_blob = json.loads(mock_init.call_args[0][0])
135 assert auth_blob["expires_at"] == expected_ts
136
137
138async def test_handle_async_init_missing_auth() -> None:
139 """Test async initialization fails with missing auth."""
140 mass = Mock()
141 mass.http_session = AsyncMock()
142 mass.metadata.locale = "en_US"
143
144 manifest = Mock()
145 manifest.domain = "tidal"
146
147 config = Mock()
148 config.name = "Tidal Test"
149 config.instance_id = "tidal_test"
150 config.enabled = True
151 config.get_value.side_effect = lambda key: "INFO" if "log" in key else None # Missing auth data
152
153 provider = TidalProvider(mass, manifest, config)
154
155 with (
156 patch.object(provider, "get_setup_value", return_value=None),
157 pytest.raises(LoginFailed, match="Missing authentication data"),
158 ):
159 await provider.handle_async_init()
160
161
162async def test_handle_async_init_auth_failed(provider: TidalProvider) -> None:
163 """Test async initialization fails when auth initialize fails."""
164 with (
165 patch.object(
166 provider,
167 "get_setup_value",
168 side_effect=lambda key, default=None: _SETUP_VALUES.get(key, default),
169 ),
170 patch.object(provider.auth, "initialize", new_callable=AsyncMock) as mock_init,
171 ):
172 mock_init.return_value = False
173
174 with pytest.raises(LoginFailed, match="Failed to authenticate with Tidal"):
175 await provider.handle_async_init()
176
177
178async def test_search_delegates_to_media(provider: TidalProvider) -> None:
179 """Test search delegates to media manager."""
180 with patch.object(provider.media, "search", new_callable=AsyncMock) as mock_search:
181 mock_search.return_value = Mock()
182
183 await provider.search("test query", [MediaType.ARTIST], limit=10)
184
185 mock_search.assert_called_with("test query", [MediaType.ARTIST], 10)
186
187
188async def test_get_similar_tracks_delegates_to_media(provider: TidalProvider) -> None:
189 """Test get_similar_tracks delegates to media manager."""
190 with patch.object(provider.media, "get_similar_tracks", new_callable=AsyncMock) as mock_get:
191 mock_get.return_value = []
192
193 result = await provider.get_similar_tracks("123", limit=30)
194
195 mock_get.assert_called_with("123", 30)
196 assert result == []
197
198
199async def test_get_artist_delegates_to_media(provider: TidalProvider) -> None:
200 """Test get_artist delegates to media manager."""
201 with patch.object(provider.media, "get_artist", new_callable=AsyncMock) as mock_get:
202 mock_get.return_value = Mock(spec=Artist)
203
204 result = await provider.get_artist("123")
205
206 mock_get.assert_called_with("123")
207 assert result is not None
208
209
210async def test_get_album_delegates_to_media(provider: TidalProvider) -> None:
211 """Test get_album delegates to media manager."""
212 with patch.object(provider.media, "get_album", new_callable=AsyncMock) as mock_get:
213 mock_get.return_value = Mock(spec=Album)
214
215 result = await provider.get_album("123")
216
217 mock_get.assert_called_with("123")
218 assert result is not None
219
220
221async def test_get_track_delegates_to_media(provider: TidalProvider) -> None:
222 """Test get_track delegates to media manager."""
223 with patch.object(provider.media, "get_track", new_callable=AsyncMock) as mock_get:
224 mock_get.return_value = Mock(spec=Track)
225
226 result = await provider.get_track("123")
227
228 mock_get.assert_called_with("123")
229 assert result is not None
230
231
232async def test_get_playlist_delegates_to_media(provider: TidalProvider) -> None:
233 """Test get_playlist delegates to media manager."""
234 with patch.object(provider.media, "get_playlist", new_callable=AsyncMock) as mock_get:
235 mock_get.return_value = Mock(spec=Playlist)
236
237 result = await provider.get_playlist("123")
238
239 mock_get.assert_called_with("123")
240 assert result is not None
241
242
243async def test_get_album_tracks_delegates_to_media(provider: TidalProvider) -> None:
244 """Test get_album_tracks delegates to media manager."""
245 with patch.object(provider.media, "get_album_tracks", new_callable=AsyncMock) as mock_get:
246 mock_get.return_value = []
247
248 result = await provider.get_album_tracks("123")
249
250 mock_get.assert_called_with("123")
251 assert result == []
252
253
254async def test_get_artist_albums_delegates_to_media(provider: TidalProvider) -> None:
255 """Test get_artist_albums delegates to media manager."""
256 with patch.object(provider.media, "get_artist_albums", new_callable=AsyncMock) as mock_get:
257 mock_get.return_value = []
258
259 result = await provider.get_artist_albums("123")
260
261 mock_get.assert_called_with("123")
262 assert result == []
263
264
265async def test_get_artist_toptracks_delegates_to_media(provider: TidalProvider) -> None:
266 """Test get_artist_toptracks delegates to media manager."""
267 with patch.object(provider.media, "get_artist_toptracks", new_callable=AsyncMock) as mock_get:
268 mock_get.return_value = []
269
270 await provider.get_artist_toptracks("123")
271
272 mock_get.assert_called_with("123")
273
274
275async def test_get_playlist_tracks_delegates_to_media(provider: TidalProvider) -> None:
276 """Test get_playlist_tracks delegates to media manager."""
277 with patch.object(provider.media, "get_playlist_tracks", new_callable=AsyncMock) as mock_get:
278 mock_get.return_value = []
279
280 await provider.get_playlist_tracks("123", page=2)
281
282 mock_get.assert_called_with("123", 2)
283
284
285async def test_get_stream_details_delegates_to_streaming(provider: TidalProvider) -> None:
286 """Test get_stream_details delegates to streaming manager."""
287 with patch.object(provider.streaming, "get_stream_details", new_callable=AsyncMock) as mock_get:
288 mock_get.return_value = Mock()
289
290 result = await provider.get_stream_details("123")
291
292 mock_get.assert_called_with("123")
293 assert result is not None
294
295
296async def test_get_item_mapping(provider: TidalProvider) -> None:
297 """Test get_item_mapping creates correct ItemMapping."""
298 mapping = provider.get_item_mapping(MediaType.ARTIST, "123", "Test Artist")
299
300 assert mapping.media_type == MediaType.ARTIST
301 assert mapping.item_id == "123"
302 assert mapping.provider == provider.instance_id
303 assert mapping.name == "Test Artist"
304
305
306async def test_get_library_artists_delegates_to_library(provider: TidalProvider) -> None:
307 """Test get_library_artists delegates to library manager."""
308
309 async def mock_generator() -> AsyncGenerator[Any]:
310 yield Mock(spec=Artist)
311 yield Mock(spec=Artist)
312
313 with patch.object(provider.library, "get_artists", return_value=mock_generator()):
314 artists = []
315 async for artist in provider.get_library_artists():
316 artists.append(artist)
317
318 assert len(artists) == 2
319
320
321async def test_get_library_albums_delegates_to_library(provider: TidalProvider) -> None:
322 """Test get_library_albums delegates to library manager."""
323
324 async def mock_generator() -> AsyncGenerator[Any]:
325 yield Mock(spec=Album)
326
327 with patch.object(provider.library, "get_albums", return_value=mock_generator()):
328 albums = []
329 async for album in provider.get_library_albums():
330 albums.append(album)
331
332 assert len(albums) == 1
333
334
335async def test_get_library_tracks_delegates_to_library(provider: TidalProvider) -> None:
336 """Test get_library_tracks delegates to library manager."""
337
338 async def mock_generator() -> AsyncGenerator[Any]:
339 yield Mock(spec=Track)
340 yield Mock(spec=Track)
341 yield Mock(spec=Track)
342
343 with patch.object(provider.library, "get_tracks", return_value=mock_generator()):
344 tracks = []
345 async for track in provider.get_library_tracks():
346 tracks.append(track)
347
348 assert len(tracks) == 3
349
350
351async def test_get_library_playlists_delegates_to_library(provider: TidalProvider) -> None:
352 """Test get_library_playlists delegates to library manager."""
353
354 async def mock_generator() -> AsyncGenerator[Any]:
355 yield Mock(spec=Playlist)
356
357 with patch.object(provider.library, "get_playlists", return_value=mock_generator()):
358 playlists = []
359 async for playlist in provider.get_library_playlists():
360 playlists.append(playlist)
361
362 assert len(playlists) == 1
363
364
365async def test_library_add_delegates_to_library(provider: TidalProvider) -> None:
366 """Test library_add delegates to library manager."""
367 with patch.object(provider.library, "add_item", new_callable=AsyncMock) as mock_add:
368 mock_add.return_value = True
369 item = Mock()
370
371 result = await provider.library_add(item)
372
373 assert result is True
374 mock_add.assert_called_with(item)
375
376
377async def test_library_remove_delegates_to_library(provider: TidalProvider) -> None:
378 """Test library_remove delegates to library manager."""
379 with patch.object(provider.library, "remove_item", new_callable=AsyncMock) as mock_remove:
380 mock_remove.return_value = True
381
382 result = await provider.library_remove("123", MediaType.TRACK)
383
384 assert result is True
385 mock_remove.assert_called_with("123", MediaType.TRACK)
386
387
388async def test_create_playlist_delegates_to_playlists(provider: TidalProvider) -> None:
389 """Test create_playlist delegates to playlist manager."""
390 with patch.object(provider.playlists, "create", new_callable=AsyncMock) as mock_create:
391 mock_create.return_value = Mock(spec=Playlist)
392
393 await provider.create_playlist("New Playlist", media_types={MediaType.TRACK})
394
395 mock_create.assert_called_with("New Playlist")
396
397
398async def test_add_playlist_tracks_delegates_to_playlists(provider: TidalProvider) -> None:
399 """Test add_playlist_tracks delegates to playlist manager."""
400 with patch.object(provider.playlists, "add_tracks", new_callable=AsyncMock) as mock_add:
401 await provider.add_playlist_tracks("123", ["track1", "track2"])
402
403 mock_add.assert_called_with("123", ["track1", "track2"])
404
405
406async def test_remove_playlist_tracks_delegates_to_playlists(provider: TidalProvider) -> None:
407 """Test remove_playlist_tracks delegates to playlist manager."""
408 with patch.object(provider.playlists, "remove_tracks", new_callable=AsyncMock) as mock_remove:
409 await provider.remove_playlist_tracks("123", (1, 2, 3))
410
411 mock_remove.assert_called_with("123", (1, 2, 3))
412
413
414async def test_get_user(provider: TidalProvider) -> None:
415 """Test get_user fetches user data."""
416 with patch.object(provider.api, "get", new_callable=AsyncMock) as mock_get:
417 mock_get.return_value = {"id": "123", "username": "testuser"}
418
419 user = await provider.get_user("123")
420
421 assert user["id"] == "123"
422 mock_get.assert_called_with("users/123")
423
424
425async def test_redirect_cached_id_hit(provider: TidalProvider, mass_mock: Mock) -> None:
426 """Test redirect_cached_id returns the cached live id on a cache hit."""
427 mass_mock.cache.get = AsyncMock(return_value="live_456")
428
429 result = await provider.redirect_cached_id("stale_123")
430
431 assert result == "live_456"
432 mass_mock.cache.get.assert_called_with(
433 "stale_123",
434 provider=provider.instance_id,
435 category=2, # CACHE_CATEGORY_ISRC_MAP
436 )
437
438
439async def test_redirect_cached_id_miss(provider: TidalProvider, mass_mock: Mock) -> None:
440 """Test redirect_cached_id returns the original id on a cache miss."""
441 mass_mock.cache.get = AsyncMock(return_value=None)
442
443 result = await provider.redirect_cached_id("stale_123")
444
445 assert result == "stale_123"
446
447
448async def test_note_replaced_track_schedules_healing(
449 provider: TidalProvider, mass_mock: Mock
450) -> None:
451 """Test a REPLACED projection is turned into a cached redirect and a mapping heal."""
452 item = {
453 "id": "live_456",
454 "type": "tracks",
455 "meta": {
456 "replacement": {"status": "REPLACED", "original": {"id": "stale_123", "type": "tracks"}}
457 },
458 }
459
460 mass_mock.create_task = Mock(side_effect=lambda coro, **_kw: coro.close())
461
462 with patch.object(provider, "_apply_replacement", new_callable=AsyncMock) as apply_mock:
463 provider.note_replaced_track(item)
464
465 mass_mock.create_task.assert_called_once()
466 apply_mock.assert_called_once_with("stale_123", "live_456")
467
468
469async def test_apply_replacement_caches_and_heals(provider: TidalProvider, mass_mock: Mock) -> None:
470 """Test _apply_replacement stores the redirect and heals an existing library mapping."""
471 lib_track = Mock()
472 lib_track.item_id = 42
473 mass_mock.music.tracks.get_library_item_by_prov_id = AsyncMock(return_value=lib_track)
474
475 with patch.object(provider, "_heal_track_mapping", new_callable=AsyncMock) as heal_mock:
476 await provider._apply_replacement("stale_123", "live_456")
477
478 mass_mock.cache.set.assert_called_once_with(
479 key="stale_123",
480 data="live_456",
481 provider=provider.instance_id,
482 category=2, # CACHE_CATEGORY_ISRC_MAP
483 persistent=True,
484 expiration=86400 * 90,
485 )
486 heal_mock.assert_called_once_with(42, "stale_123", "live_456")
487
488
489async def test_apply_replacement_without_library_track(
490 provider: TidalProvider, mass_mock: Mock
491) -> None:
492 """Test _apply_replacement still caches the redirect when no library track exists."""
493 mass_mock.music.tracks.get_library_item_by_prov_id = AsyncMock(return_value=None)
494
495 with patch.object(provider, "_heal_track_mapping", new_callable=AsyncMock) as heal_mock:
496 await provider._apply_replacement("stale_123", "live_456")
497
498 mass_mock.cache.set.assert_called_once()
499 heal_mock.assert_not_called()
500
501
502@pytest.mark.parametrize(
503 "meta",
504 [
505 {},
506 {"replacement": {"status": "ORIGINAL"}},
507 {"replacement": {"status": "NOT_REPLACED", "original": {"id": "live_456"}}},
508 {"replacement": {"status": "REPLACED", "original": {"id": "live_456"}}},
509 ],
510 ids=["no-meta", "original", "not-replaced", "replaced-with-same-id"],
511)
512async def test_note_replaced_track_ignores_non_replacements(
513 provider: TidalProvider, mass_mock: Mock, meta: dict[str, Any]
514) -> None:
515 """Test nothing is scheduled unless the id actually changed."""
516 provider.note_replaced_track({"id": "live_456", "type": "tracks", "meta": meta})
517
518 mass_mock.create_task.assert_not_called()
519
520
521async def test_resolve_live_track_id_cache_hit_different(
522 provider: TidalProvider, mass_mock: Mock
523) -> None:
524 """Test resolve_live_track_id returns the cached id when it is still live."""
525 mass_mock.cache.get = AsyncMock(return_value="live_456")
526
527 # The liveness check must bypass the cached provider wrapper, so it is
528 # served by the media manager directly; the wrapper must not be touched.
529 with (
530 patch.object(provider.media, "get_track", new_callable=AsyncMock),
531 patch.object(provider, "get_track", new_callable=AsyncMock) as mock_cached,
532 ):
533 result = await provider.resolve_live_track_id("stale_123")
534
535 assert result == "live_456"
536 mock_cached.assert_not_called()
537
538
539async def test_resolve_live_track_id_cache_hit_dead_reresolves(
540 provider: TidalProvider, mass_mock: Mock
541) -> None:
542 """Test a dead cached id is dropped and re-resolved from the ISRC (double churn)."""
543 mass_mock.cache.get = AsyncMock(return_value="dead_456")
544 mass_mock.cache.delete = AsyncMock()
545 mass_mock.cache.set = AsyncMock()
546 lib_track = Mock()
547 lib_track.item_id = 1
548 lib_track.external_ids = [(ExternalID.ISRC, "US1234567890")]
549 mass_mock.music.tracks.get_library_item_by_prov_id = AsyncMock(return_value=lib_track)
550 mass_mock.create_task = Mock(side_effect=lambda coro, **_kw: coro.close())
551
552 with (
553 patch.object(
554 provider.media,
555 "get_track",
556 new_callable=AsyncMock,
557 side_effect=MediaNotFoundError("gone"),
558 ),
559 patch.object(provider, "get_track", new_callable=AsyncMock) as mock_cached,
560 patch.object(provider.api, "get", new_callable=AsyncMock) as mock_get,
561 patch.object(provider, "_heal_track_mapping", new_callable=AsyncMock),
562 ):
563 mock_get.return_value = {"data": [{"id": "new_789"}]}
564
565 result = await provider.resolve_live_track_id("stale_123")
566
567 assert result == "new_789"
568 mass_mock.cache.delete.assert_called_once()
569 # the liveness check must not consult the cached wrapper, which would keep
570 # serving a stale track for a dead id
571 mock_cached.assert_not_called()
572
573
574async def test_resolve_live_track_id_cache_hit_same(
575 provider: TidalProvider, mass_mock: Mock
576) -> None:
577 """Test resolve_live_track_id returns None when the cached id equals the input."""
578 mass_mock.cache.get = AsyncMock(return_value="123")
579
580 result = await provider.resolve_live_track_id("123")
581
582 assert result is None
583
584
585async def test_resolve_live_track_id_no_library_item(
586 provider: TidalProvider, mass_mock: Mock
587) -> None:
588 """Test resolve_live_track_id returns None when there is no library item."""
589 mass_mock.cache.get = AsyncMock(return_value=None)
590 mass_mock.music.tracks.get_library_item_by_prov_id = AsyncMock(return_value=None)
591
592 result = await provider.resolve_live_track_id("123")
593
594 assert result is None
595
596
597async def test_resolve_live_track_id_no_isrc(provider: TidalProvider, mass_mock: Mock) -> None:
598 """Test resolve_live_track_id returns None when the library item has no ISRC."""
599 mass_mock.cache.get = AsyncMock(return_value=None)
600 lib_track = Mock()
601 lib_track.external_ids = [(ExternalID.BARCODE, "some-id")]
602 mass_mock.music.tracks.get_library_item_by_prov_id = AsyncMock(return_value=lib_track)
603
604 result = await provider.resolve_live_track_id("123")
605
606 assert result is None
607
608
609async def test_resolve_live_track_id_isrc_lookup_empty(
610 provider: TidalProvider, mass_mock: Mock
611) -> None:
612 """Test resolve_live_track_id returns None when the ISRC lookup returns no data."""
613 mass_mock.cache.get = AsyncMock(return_value=None)
614 lib_track = Mock()
615 lib_track.external_ids = [(ExternalID.ISRC, "US1234567890")]
616 mass_mock.music.tracks.get_library_item_by_prov_id = AsyncMock(return_value=lib_track)
617
618 with patch.object(provider.api, "get", new_callable=AsyncMock) as mock_get:
619 mock_get.return_value = {"data": []}
620
621 result = await provider.resolve_live_track_id("123")
622
623 assert result is None
624
625
626async def test_resolve_live_track_id_not_stale(provider: TidalProvider, mass_mock: Mock) -> None:
627 """Test resolve_live_track_id returns None when the resolved id equals the input."""
628 mass_mock.cache.get = AsyncMock(return_value=None)
629 lib_track = Mock()
630 lib_track.external_ids = [(ExternalID.ISRC, "US1234567890")]
631 mass_mock.music.tracks.get_library_item_by_prov_id = AsyncMock(return_value=lib_track)
632
633 with patch.object(provider.api, "get", new_callable=AsyncMock) as mock_get:
634 mock_get.return_value = {"data": [{"id": "123"}]}
635
636 result = await provider.resolve_live_track_id("123")
637
638 assert result is None
639
640
641async def test_resolve_live_track_id_stale_caches_and_schedules_heal(
642 provider: TidalProvider, mass_mock: Mock
643) -> None:
644 """Test resolve_live_track_id returns the live id, caches it, and schedules a heal."""
645 mass_mock.cache.get = AsyncMock(return_value=None)
646 mass_mock.cache.set = AsyncMock()
647
648 lib_track = Mock()
649 lib_track.item_id = 1
650 lib_track.external_ids = [(ExternalID.ISRC, "US1234567890")]
651 mass_mock.music.tracks.get_library_item_by_prov_id = AsyncMock(return_value=lib_track)
652
653 # Discard the scheduled coroutine (avoid "never awaited" warnings) while
654 # still recording the call for assertions.
655 mass_mock.create_task = Mock(side_effect=lambda coro, **_kw: coro.close())
656
657 with (
658 patch.object(provider.api, "get", new_callable=AsyncMock) as mock_get,
659 patch.object(provider, "_heal_track_mapping", new_callable=AsyncMock) as mock_heal,
660 ):
661 mock_get.return_value = {"data": [{"id": "NEW"}]}
662
663 result = await provider.resolve_live_track_id("123")
664
665 assert result == "NEW"
666 mass_mock.cache.set.assert_called_once_with(
667 key="123",
668 data="NEW",
669 provider=provider.instance_id,
670 category=2, # CACHE_CATEGORY_ISRC_MAP
671 persistent=True,
672 expiration=86400 * 90,
673 )
674 mass_mock.create_task.assert_called_once()
675 mock_heal.assert_called_once_with(1, "123", "NEW")
676
677
678async def test_heal_track_mapping_adds_before_removing(
679 provider: TidalProvider, mass_mock: Mock
680) -> None:
681 """Test _heal_track_mapping adds the new mapping before removing the stale one."""
682 call_order: list[str] = []
683
684 async def _fake_add(*_args: Any, **_kwargs: Any) -> None:
685 call_order.append("add")
686
687 async def _fake_remove(*_args: Any, **_kwargs: Any) -> None:
688 call_order.append("remove")
689
690 new_mapping = Mock()
691 new_mapping.provider_instance = provider.instance_id
692
693 live_track = Mock()
694 live_track.provider_mappings = [new_mapping]
695
696 mass_mock.music.tracks.add_provider_mappings = AsyncMock(side_effect=_fake_add)
697 mass_mock.music.tracks.remove_provider_mapping = AsyncMock(side_effect=_fake_remove)
698
699 with patch.object(provider, "get_track", new_callable=AsyncMock) as mock_get_track:
700 mock_get_track.return_value = live_track
701
702 await provider._heal_track_mapping(1, "stale_123", "NEW")
703
704 assert call_order == ["add", "remove"]
705 mass_mock.music.tracks.add_provider_mappings.assert_called_once_with(1, [new_mapping])
706 mass_mock.music.tracks.remove_provider_mapping.assert_called_once_with(
707 1, provider.instance_id, "stale_123"
708 )
709
710
711async def test_heal_track_mapping_no_matching_mapping_does_nothing(
712 provider: TidalProvider, mass_mock: Mock
713) -> None:
714 """Test _heal_track_mapping does nothing when the live track has no matching mapping."""
715 other_mapping = Mock()
716 other_mapping.provider_instance = "some_other_instance"
717
718 live_track = Mock()
719 live_track.provider_mappings = [other_mapping]
720
721 mass_mock.music.tracks.add_provider_mappings = AsyncMock()
722 mass_mock.music.tracks.remove_provider_mapping = AsyncMock()
723
724 with patch.object(provider, "get_track", new_callable=AsyncMock) as mock_get_track:
725 mock_get_track.return_value = live_track
726
727 await provider._heal_track_mapping(1, "stale_123", "NEW")
728
729 mass_mock.music.tracks.add_provider_mappings.assert_not_called()
730 mass_mock.music.tracks.remove_provider_mapping.assert_not_called()
731
732
733async def test_heal_track_mapping_swallows_errors(provider: TidalProvider) -> None:
734 """Test _heal_track_mapping swallows expected errors without raising."""
735 with patch.object(provider, "get_track", new_callable=AsyncMock) as mock_get_track:
736 mock_get_track.side_effect = MediaNotFoundError("Track not found")
737
738 # Should not raise.
739 await provider._heal_track_mapping(1, "stale_123", "NEW")
740