/
/
/
1"""Tests for the hourly periodic-refresh scheduled task."""
2
3from __future__ import annotations
4
5from typing import TYPE_CHECKING, Any
6from unittest.mock import AsyncMock, MagicMock
7
8import pytest
9
10from music_assistant.providers.sonic_similarity import (
11 SonicSimilarityPlugin,
12 setup,
13)
14from music_assistant.providers.sonic_similarity import clap_index as clap_index_module
15from music_assistant.providers.sonic_similarity.constants import (
16 PERIODIC_REFRESH_INTERVAL_HOURS,
17 PERIODIC_REFRESH_TASK_ID,
18 SUPPORTED_FEATURES,
19)
20
21if TYPE_CHECKING:
22 from collections.abc import Callable
23
24
25def _make_manifest() -> MagicMock:
26 """Build a ProviderManifest mock for setup()."""
27 manifest = MagicMock()
28 manifest.instance_id = "test-instance-id"
29 manifest.domain = "sonic_similarity"
30 return manifest
31
32
33def _make_config() -> MagicMock:
34 """Build a minimal ProviderConfig mock for setup()."""
35 config = MagicMock()
36 config_values = {
37 "log_level": "GLOBAL",
38 "enable_clap_index": False,
39 "enable_text_search": False,
40 "enable_discover_row": True,
41 "discover_preset": "discover",
42 "discover_diversity": 0.2,
43 }
44 config.get_value = lambda key: config_values.get(key)
45 return config
46
47
48class TestPeriodicRefresh:
49 """_periodic_refresh skips when the row count is unchanged, rebuilds otherwise."""
50
51 @pytest.mark.asyncio
52 async def test_skips_rebuild_when_row_count_unchanged(
53 self, make_plugin: Callable[..., Any], mock_mass: MagicMock
54 ) -> None:
55 """No row count delta â no rebuild calls and counter stays put."""
56 plugin = make_plugin(clap_enabled=True)
57 plugin._last_seen_row_count = 42
58 mock_mass.music.database.get_count_from_query = AsyncMock(return_value=42)
59 plugin._rebuild_search_index = AsyncMock()
60 plugin._rebuild_clap_index_from_database = AsyncMock()
61
62 await plugin._periodic_refresh()
63
64 plugin._rebuild_search_index.assert_not_awaited()
65 plugin._rebuild_clap_index_from_database.assert_not_awaited()
66 assert plugin._last_seen_row_count == 42
67
68 @pytest.mark.asyncio
69 async def test_rebuilds_when_row_count_increased(
70 self, make_plugin: Callable[..., Any], mock_mass: MagicMock
71 ) -> None:
72 """Row count grew â both indexes rebuild; the real _rebuild_search_index bumps the counter."""
73 plugin = make_plugin(clap_enabled=True)
74 plugin._last_seen_row_count = 10
75 mock_mass.music.database.get_count_from_query = AsyncMock(return_value=15)
76 # Real _rebuild_search_index runs end-to-end (iter_merged is empty) and
77 # bumps _last_seen_row_count via its own pre-count snapshot.
78 plugin._rebuild_clap_index_from_database = AsyncMock()
79
80 await plugin._periodic_refresh()
81
82 plugin._rebuild_clap_index_from_database.assert_awaited_once()
83 assert plugin._last_seen_row_count == 15
84
85 @pytest.mark.asyncio
86 async def test_rebuilds_when_row_count_decreased(
87 self, make_plugin: Callable[..., Any], mock_mass: MagicMock
88 ) -> None:
89 """Defensive: a row-count drop also triggers rebuild (handles future deletes)."""
90 plugin = make_plugin(clap_enabled=True)
91 plugin._last_seen_row_count = 50
92 mock_mass.music.database.get_count_from_query = AsyncMock(return_value=40)
93 plugin._rebuild_clap_index_from_database = AsyncMock()
94
95 await plugin._periodic_refresh()
96
97 plugin._rebuild_clap_index_from_database.assert_awaited_once()
98 assert plugin._last_seen_row_count == 40
99
100 @pytest.mark.asyncio
101 async def test_counter_not_advanced_when_rebuild_fails(
102 self, make_plugin: Callable[..., Any], mock_mass: MagicMock
103 ) -> None:
104 """A failing 18-dim rebuild leaves the counter untouched so the next tick retries."""
105 plugin = make_plugin(clap_enabled=True)
106 plugin._last_seen_row_count = 10
107 mock_mass.music.database.get_count_from_query = AsyncMock(return_value=15)
108 # _rebuild_search_index raises â _safe_rebuild swallows into _last_rebuild_error
109 # â counter is never advanced because the pre-count bump line is unreached.
110 plugin._rebuild_search_index = AsyncMock(side_effect=RuntimeError("disk full"))
111 plugin._rebuild_clap_index_from_database = AsyncMock()
112
113 await plugin._periodic_refresh()
114
115 assert plugin._last_seen_row_count == 10
116 assert plugin._last_rebuild_error.get("Traits") == "disk full"
117
118 @pytest.mark.asyncio
119 async def test_count_query_failure_skips_tick(
120 self, make_plugin: Callable[..., Any], mock_mass: MagicMock
121 ) -> None:
122 """If the count query itself fails, skip the tick â no rebuild attempted."""
123 plugin = make_plugin(clap_enabled=True)
124 plugin._last_seen_row_count = 10
125 mock_mass.music.database.get_count_from_query = AsyncMock(side_effect=RuntimeError("oops"))
126 plugin._rebuild_search_index = AsyncMock()
127 plugin._rebuild_clap_index_from_database = AsyncMock()
128
129 await plugin._periodic_refresh()
130
131 plugin._rebuild_search_index.assert_not_awaited()
132 plugin._rebuild_clap_index_from_database.assert_not_awaited()
133 assert plugin._last_seen_row_count == 10
134
135 @pytest.mark.asyncio
136 async def test_clap_skipped_when_index_disabled(
137 self, make_plugin: Callable[..., Any], mock_mass: MagicMock
138 ) -> None:
139 """When CLAP isn't configured, only 18-dim is rebuilt."""
140 plugin = make_plugin() # clap_enabled=False â _clap_index stays None
141 plugin._last_seen_row_count = 0
142 mock_mass.music.database.get_count_from_query = AsyncMock(return_value=5)
143 plugin._rebuild_clap_index_from_database = AsyncMock()
144
145 await plugin._periodic_refresh()
146
147 plugin._rebuild_clap_index_from_database.assert_not_awaited()
148
149
150class TestPeriodicRefreshLifecycle:
151 """loaded_in_mass registers the scheduled task; unload unregisters it."""
152
153 @pytest.mark.asyncio
154 async def test_loaded_in_mass_registers_scheduled_task(
155 self, mock_mass: MagicMock, monkeypatch: pytest.MonkeyPatch
156 ) -> None:
157 """loaded_in_mass wires the periodic refresh into mass.tasks with the expected schedule."""
158
159 async def _noop_load(_self: Any) -> None:
160 return None
161
162 # Defensive: load() is a no-op even though clap is disabled in this config.
163 monkeypatch.setattr(clap_index_module.ClapIndex, "load", _noop_load)
164
165 plugin = await setup(mock_mass, _make_manifest(), _make_config())
166 assert isinstance(plugin, SonicSimilarityPlugin)
167 await plugin.loaded_in_mass()
168
169 mock_mass.tasks.register_scheduled_task.assert_called_once()
170 call_kwargs = mock_mass.tasks.register_scheduled_task.call_args.kwargs
171 assert call_kwargs["task_id"] == PERIODIC_REFRESH_TASK_ID
172 assert call_kwargs["handler"] == plugin._periodic_refresh
173 assert call_kwargs["schedule"].every == PERIODIC_REFRESH_INTERVAL_HOURS
174
175 @pytest.mark.asyncio
176 async def test_unload_unregisters_scheduled_task(self, mock_mass: MagicMock) -> None:
177 """unload() detaches the periodic-refresh task from mass.tasks."""
178 manifest = MagicMock()
179 manifest.instance_id = "iid"
180 manifest.domain = "sonic_similarity"
181 plugin = SonicSimilarityPlugin(mock_mass, manifest, _make_config(), SUPPORTED_FEATURES)
182
183 await plugin.unload()
184
185 mock_mass.tasks.unregister_scheduled_task.assert_called_once_with(PERIODIC_REFRESH_TASK_ID)
186