/
/
/
1"""Tests for provider unload."""
2
3from __future__ import annotations
4
5import asyncio
6from typing import TYPE_CHECKING
7from unittest.mock import AsyncMock, MagicMock
8
9from music_assistant_models.background_task import TaskSchedule
10from music_assistant_models.config_entries import ProviderConfig
11from music_assistant_models.enums import MediaType, ProviderType
12from music_assistant_models.errors import LoginFailed
13from music_assistant_models.provider import ProviderManifest
14
15from music_assistant.controllers.music import MusicController
16from music_assistant.controllers.tasks import TasksController
17from music_assistant.controllers.tasks.constants import TASK_UPDATE_TIMER_ID
18from music_assistant.mass import MusicAssistant
19from music_assistant.models.music_provider import MusicProvider
20
21if TYPE_CHECKING:
22 import pytest
23 from music_assistant_models.config_entries import ProviderError
24
25
26def _make_mass(
27 monkeypatch: pytest.MonkeyPatch,
28) -> tuple[MusicAssistant, list[ProviderError], AsyncMock]:
29 """Return a bare MusicAssistant (bypassing __init__) recording last-error writes."""
30 mass = object.__new__(MusicAssistant)
31 recorded: list[ProviderError] = []
32 config = MagicMock()
33 config.update_provider_last_error = MagicMock(
34 side_effect=lambda _instance_id, error: recorded.append(error)
35 )
36 unload = AsyncMock()
37 monkeypatch.setattr(mass, "config", config, raising=False)
38 monkeypatch.setattr(mass, "unload_provider", unload)
39 return mass, recorded, unload
40
41
42async def test_unload_provider_with_error_preserves_auth_code(
43 monkeypatch: pytest.MonkeyPatch,
44) -> None:
45 """A LoginFailed keeps its error code + translation so the provider shows AUTH_REQUIRED."""
46 mass, recorded, unload = _make_mass(monkeypatch)
47 await mass.unload_provider_with_error("spotify--1", LoginFailed("token revoked"))
48 assert recorded[0].error_code == LoginFailed.error_code
49 assert recorded[0].translation_key == LoginFailed.translation_key
50 unload.assert_awaited_once_with("spotify--1")
51
52
53async def test_unload_provider_with_error_string_is_generic(
54 monkeypatch: pytest.MonkeyPatch,
55) -> None:
56 """A plain string message is recorded as a generic error (code 999)."""
57 mass, recorded, _unload = _make_mass(monkeypatch)
58 await mass.unload_provider_with_error("airplay--1", "daemon failed to start")
59 assert recorded[0].error_code == 999
60 assert recorded[0].message == "daemon failed to start"
61
62
63async def test_unload_provider_waits_for_running_sync(
64 mass_minimal: MusicAssistant,
65 monkeypatch: pytest.MonkeyPatch,
66) -> None:
67 """A provider must not be unloaded while its own library sync is still unwinding."""
68 mass_minimal.tasks = TasksController(mass_minimal)
69 await mass_minimal.tasks.setup(await mass_minimal.config.get_core_config("tasks"))
70 mass_minimal.tasks.initialized.set()
71 mass_minimal.music = MusicController(mass_minimal)
72 # discovery is not set up on the minimal instance and plays no part in this test
73 monkeypatch.setattr(mass_minimal.discovery, "on_provider_unload", MagicMock())
74
75 sync_started = asyncio.Event()
76 sync_finished = False
77 sync_finished_on_unload: bool | None = None
78
79 class SyncingProvider(MusicProvider):
80 """Provider that records the sync state observed by its unload."""
81
82 async def sync_library(self, media_type: MediaType) -> None:
83 """Unused: the sync task handler is registered directly by this test."""
84
85 async def unload(self, is_removed: bool = False) -> None:
86 """Handle unload of the provider."""
87 nonlocal sync_finished_on_unload
88 sync_finished_on_unload = sync_finished
89
90 provider_config = ProviderConfig(
91 values={},
92 type=ProviderType.MUSIC,
93 domain="test_provider",
94 instance_id="test_provider--instance",
95 name="Test provider",
96 )
97 monkeypatch.setattr(provider_config, "get_value", lambda *_args, **_kwargs: "GLOBAL")
98 provider = SyncingProvider(
99 mass_minimal,
100 manifest=ProviderManifest(
101 type=ProviderType.MUSIC,
102 domain="test_provider",
103 name="Test provider",
104 description="Test provider",
105 codeowners=["@music-assistant"],
106 ),
107 config=provider_config,
108 )
109 provider.available = True
110 mass_minimal._providers[provider.instance_id] = provider
111
112 async def sync_handler() -> None:
113 nonlocal sync_finished
114 sync_started.set()
115 try:
116 await asyncio.sleep(30)
117 finally:
118 # cleanup that yields to the event loop, like a sync releasing its resources
119 await asyncio.sleep(0.05)
120 sync_finished = True
121
122 task_id = mass_minimal.music._get_sync_task_id(provider, MediaType.TRACK)
123 mass_minimal.tasks.register_scheduled_task(
124 task_id=task_id,
125 name="Sync tracks",
126 handler=sync_handler,
127 schedule=TaskSchedule.hourly(every=12),
128 )
129 mass_minimal.tasks.run_task(task_id)
130 await asyncio.wait_for(sync_started.wait(), timeout=2)
131
132 try:
133 await mass_minimal.unload_provider(provider.instance_id)
134 finally:
135 mass_minimal.cancel_timer(TASK_UPDATE_TIMER_ID)
136 await mass_minimal.tasks.close()
137
138 assert sync_finished_on_unload is True
139