/
/
/
1"""Tests for the OneDrive provider's API hooks and setup checks."""
2
3from __future__ import annotations
4
5from unittest.mock import AsyncMock, MagicMock
6
7import pytest
8from music_assistant_models.errors import (
9 LoginFailed,
10 ProviderUnavailableError,
11 SetupFailedError,
12)
13from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException
14from onedrive_personal_sdk.models.items import (
15 File,
16 Folder,
17 Hashes,
18 IdentitySet,
19 ItemParentReference,
20)
21
22from music_assistant.providers.filesystem_onedrive.provider import OneDriveFileSystemProvider
23
24_PARENT = ItemParentReference(drive_id="drive-1")
25
26
27def _folder(item_id: str, name: str) -> Folder:
28 """Build a real SDK Folder item (isinstance checks need the real class)."""
29 return Folder(id=item_id, name=name, parent_reference=_PARENT, created_by=IdentitySet())
30
31
32def _file(
33 item_id: str,
34 name: str,
35 size: int,
36 xor_hash: str | None = None,
37 sha256_hash: str | None = None,
38 sha1_hash: str | None = None,
39) -> File:
40 """Build a real SDK File item."""
41 return File(
42 id=item_id,
43 name=name,
44 parent_reference=_PARENT,
45 created_by=IdentitySet(),
46 size=size,
47 hashes=Hashes(quick_xor_hash=xor_hash, sha256_hash=sha256_hash, sha1_hash=sha1_hash),
48 )
49
50
51def _make_provider(
52 root_folder_id: str = "root",
53) -> tuple[OneDriveFileSystemProvider, MagicMock]:
54 """Return a bare provider plus the mock bundle behind its client/auth/mass."""
55 mocks = MagicMock()
56 mocks.config.instance_id = "onedrive--test"
57 provider = OneDriveFileSystemProvider.__new__(OneDriveFileSystemProvider)
58 provider.root_folder_id = root_folder_id
59 provider._root_folder_name = None
60 provider._unregister_stream_route = None
61 provider._dir_cache = {}
62 provider.logger = mocks.logger
63 provider.client = mocks.client
64 provider.auth = mocks.auth
65 provider.mass = mocks.mass
66 provider.config = mocks.config
67 return provider, mocks
68
69
70def _response_cm(response: MagicMock) -> MagicMock:
71 """Build a fake async context manager mimicking aiohttp's session.get()."""
72 cm = MagicMock()
73 cm.__aenter__ = AsyncMock(return_value=response)
74 cm.__aexit__ = AsyncMock(return_value=False)
75 return cm
76
77
78async def test_list_children_maps_sdk_items() -> None:
79 """SDK items map onto RawItem tuples; files without a hash fall back to size."""
80 provider, mocks = _make_provider()
81 mocks.client.list_drive_items = AsyncMock(
82 return_value=[
83 _folder("d1", "Albums"),
84 _file("f1", "track.mp3", 123, xor_hash="xor-1"),
85 _file("f2", "no-hash.mp3", 456),
86 ]
87 )
88
89 items = await provider._api_list_children("folder-id")
90
91 assert items[0] == ("d1", "Albums", True, "folder", None, None)
92 # the checksum (imported-media compatibility) uses only quickXorHash-or-size, unchanged
93 assert items[1] == ("f1", "track.mp3", False, "xor-1", 123, "xor-1")
94 assert items[2] == ("f2", "no-hash.mp3", False, "456", 456, None)
95
96
97async def test_list_children_checksum_ignores_stronger_hashes() -> None:
98 """
99 The imported-media checksum must stay quickXorHash-or-size, even with other hashes.
100
101 Using a stronger hash for checksum on accounts without quickXorHash would change every
102 existing mapping's checksum on upgrade, making the entire library look changed on the
103 next sync.
104 """
105 provider, mocks = _make_provider()
106 mocks.client.list_drive_items = AsyncMock(
107 return_value=[_file("f1", "track.mp3", 456, sha256_hash="sha256-1", sha1_hash="sha1-1")]
108 )
109
110 items = await provider._api_list_children("folder-id")
111
112 assert items[0][3] == "456" # checksum: falls back to size, not a hash
113
114
115async def test_list_children_metadata_token_prefers_quick_xor_hash() -> None:
116 """When quickXorHash is present, it is used for the metadata token too."""
117 provider, mocks = _make_provider()
118 mocks.client.list_drive_items = AsyncMock(
119 return_value=[
120 _file("f1", "art.jpg", 10, xor_hash="xor-1", sha256_hash="sha256-1", sha1_hash="sha1-1")
121 ]
122 )
123
124 items = await provider._api_list_children("folder-id")
125
126 assert items[0][5] == "xor-1" # metadata_token
127
128
129async def test_list_children_metadata_token_falls_back_to_sha256() -> None:
130 """Without quickXorHash, the metadata token prefers SHA-256 over SHA-1."""
131 provider, mocks = _make_provider()
132 mocks.client.list_drive_items = AsyncMock(
133 return_value=[_file("f1", "art.jpg", 10, sha256_hash="sha256-1", sha1_hash="sha1-1")]
134 )
135
136 items = await provider._api_list_children("folder-id")
137
138 assert items[0][3] == "10" # checksum unaffected, still size-based
139 assert items[0][5] == "sha256-1" # metadata_token
140
141
142async def test_list_children_metadata_token_falls_back_to_sha1() -> None:
143 """With only a SHA-1 hash, the metadata token falls back to it."""
144 provider, mocks = _make_provider()
145 mocks.client.list_drive_items = AsyncMock(
146 return_value=[_file("f1", "art.jpg", 10, sha1_hash="sha1-1")]
147 )
148
149 items = await provider._api_list_children("folder-id")
150
151 assert items[0][5] == "sha1-1" # metadata_token
152
153
154async def test_list_children_metadata_token_none_without_any_hash() -> None:
155 """With no hash at all, the metadata token is None (falls back to checksum itself)."""
156 provider, mocks = _make_provider()
157 mocks.client.list_drive_items = AsyncMock(return_value=[_file("f1", "art.jpg", 10)])
158
159 items = await provider._api_list_children("folder-id")
160
161 assert items[0][5] is None # metadata_token
162
163
164async def test_list_children_translates_errors() -> None:
165 """SDK errors surface as MA errors per the base's hook contract."""
166 provider, mocks = _make_provider()
167 mocks.client.list_drive_items = AsyncMock(side_effect=AuthenticationError(401, "expired"))
168 with pytest.raises(LoginFailed):
169 await provider._api_list_children("folder-id")
170
171 mocks.client.list_drive_items = AsyncMock(side_effect=OneDriveException("throttled"))
172 with pytest.raises(ProviderUnavailableError):
173 await provider._api_list_children("folder-id")
174
175
176async def test_download_response_forwards_range_header() -> None:
177 """The direct Graph download carries both the bearer token and the Range header."""
178 provider, mocks = _make_provider()
179 mocks.auth.async_get_access_token = AsyncMock(return_value="token-1")
180 mocks.mass.http_session.get = AsyncMock(return_value=MagicMock())
181
182 await provider._api_download_response("file-1", {"Range": "bytes=100-"})
183
184 call = mocks.mass.http_session.get.call_args
185 assert call.args[0].endswith("/me/drive/items/file-1/content")
186 assert call.kwargs["headers"]["Authorization"] == "Bearer token-1"
187 assert call.kwargs["headers"]["Range"] == "bytes=100-"
188
189
190async def test_resolve_root_folder_stores_id_and_name() -> None:
191 """A configured folder path resolves to its Graph item ID and display name."""
192 provider, mocks = _make_provider(root_folder_id="/My Music/")
193 mocks.auth.async_get_access_token = AsyncMock(return_value="token-1")
194 response = MagicMock()
195 response.status = 200
196 response.json = AsyncMock(return_value={"id": "id-1", "name": "My Music", "folder": {}})
197 mocks.mass.http_session.get = MagicMock(return_value=_response_cm(response))
198
199 await provider._resolve_root_folder()
200
201 assert provider.root_folder_id == "id-1"
202 assert provider.instance_name_postfix == "My Music"
203 # the path is addressed root-relative, stripped and url-quoted
204 assert mocks.mass.http_session.get.call_args.args[0].endswith("/me/drive/root:/My%20Music")
205
206
207async def test_resolve_root_folder_not_found() -> None:
208 """A path OneDrive doesn't know fails setup with a helpful message."""
209 provider, mocks = _make_provider(root_folder_id="Nope")
210 mocks.auth.async_get_access_token = AsyncMock(return_value="token-1")
211 response = MagicMock()
212 response.status = 404
213 mocks.mass.http_session.get = MagicMock(return_value=_response_cm(response))
214
215 with pytest.raises(SetupFailedError, match="not found"):
216 await provider._resolve_root_folder()
217
218
219async def test_resolve_root_folder_rejects_non_folder() -> None:
220 """A file path configured as the root folder fails setup."""
221 provider, mocks = _make_provider(root_folder_id="track.mp3")
222 mocks.auth.async_get_access_token = AsyncMock(return_value="token-1")
223 response = MagicMock()
224 response.status = 200
225 response.json = AsyncMock(return_value={"id": "id-1", "name": "track.mp3", "file": {}})
226 mocks.mass.http_session.get = MagicMock(return_value=_response_cm(response))
227
228 with pytest.raises(SetupFailedError, match="not a folder"):
229 await provider._resolve_root_folder()
230
231
232async def test_resolve_root_folder_auth_error() -> None:
233 """A 401 during folder resolution surfaces as LoginFailed."""
234 provider, mocks = _make_provider(root_folder_id="My Music")
235 mocks.auth.async_get_access_token = AsyncMock(return_value="token-1")
236 response = MagicMock()
237 response.status = 401
238 response.text = AsyncMock(return_value="token expired")
239 mocks.mass.http_session.get = MagicMock(return_value=_response_cm(response))
240
241 with pytest.raises(LoginFailed):
242 await provider._resolve_root_folder()
243
244
245async def test_async_init_with_whole_drive_root() -> None:
246 """The 'root' default validates auth with a single call and has no name postfix."""
247 provider, mocks = _make_provider(root_folder_id="root")
248 mocks.client.get_drive_item = AsyncMock(return_value=MagicMock())
249
250 await provider.handle_async_init()
251
252 mocks.client.get_drive_item.assert_called_once_with("root")
253 assert provider.instance_name_postfix is None
254 mocks.mass.streams.register_dynamic_route.assert_called_once()
255
256
257async def test_async_init_verifies_auth() -> None:
258 """Bad credentials fail setup with LoginFailed."""
259 provider, mocks = _make_provider(root_folder_id="root")
260 mocks.client.get_drive_item = AsyncMock(side_effect=AuthenticationError(401, "invalid_grant"))
261
262 with pytest.raises(LoginFailed):
263 await provider.handle_async_init()
264