/
/
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(item_id: str, name: str, size: int, xor_hash: str | None = None) -> File:
33 """Build a real SDK File item."""
34 return File(
35 id=item_id,
36 name=name,
37 parent_reference=_PARENT,
38 created_by=IdentitySet(),
39 size=size,
40 hashes=Hashes(quick_xor_hash=xor_hash),
41 )
42
43
44def _make_provider(
45 root_folder_id: str = "root",
46) -> tuple[OneDriveFileSystemProvider, MagicMock]:
47 """Return a bare provider plus the mock bundle behind its client/auth/mass."""
48 mocks = MagicMock()
49 mocks.config.instance_id = "onedrive--test"
50 provider = OneDriveFileSystemProvider.__new__(OneDriveFileSystemProvider)
51 provider.root_folder_id = root_folder_id
52 provider._root_folder_name = None
53 provider._unregister_stream_route = None
54 provider._dir_cache = {}
55 provider.logger = mocks.logger
56 provider.client = mocks.client
57 provider.auth = mocks.auth
58 provider.mass = mocks.mass
59 provider.config = mocks.config
60 return provider, mocks
61
62
63def _response_cm(response: MagicMock) -> MagicMock:
64 """Build a fake async context manager mimicking aiohttp's session.get()."""
65 cm = MagicMock()
66 cm.__aenter__ = AsyncMock(return_value=response)
67 cm.__aexit__ = AsyncMock(return_value=False)
68 return cm
69
70
71async def test_list_children_maps_sdk_items() -> None:
72 """SDK items map onto RawItem tuples; files without a hash fall back to size."""
73 provider, mocks = _make_provider()
74 mocks.client.list_drive_items = AsyncMock(
75 return_value=[
76 _folder("d1", "Albums"),
77 _file("f1", "track.mp3", 123, xor_hash="xor-1"),
78 _file("f2", "no-hash.mp3", 456),
79 ]
80 )
81
82 items = await provider._api_list_children("folder-id")
83
84 assert items[0] == ("d1", "Albums", True, "folder", None)
85 assert items[1] == ("f1", "track.mp3", False, "xor-1", 123)
86 assert items[2] == ("f2", "no-hash.mp3", False, "456", 456)
87
88
89async def test_list_children_translates_errors() -> None:
90 """SDK errors surface as MA errors per the base's hook contract."""
91 provider, mocks = _make_provider()
92 mocks.client.list_drive_items = AsyncMock(side_effect=AuthenticationError(401, "expired"))
93 with pytest.raises(LoginFailed):
94 await provider._api_list_children("folder-id")
95
96 mocks.client.list_drive_items = AsyncMock(side_effect=OneDriveException("throttled"))
97 with pytest.raises(ProviderUnavailableError):
98 await provider._api_list_children("folder-id")
99
100
101async def test_download_response_forwards_range_header() -> None:
102 """The direct Graph download carries both the bearer token and the Range header."""
103 provider, mocks = _make_provider()
104 mocks.auth.async_get_access_token = AsyncMock(return_value="token-1")
105 mocks.mass.http_session.get = AsyncMock(return_value=MagicMock())
106
107 await provider._api_download_response("file-1", {"Range": "bytes=100-"})
108
109 call = mocks.mass.http_session.get.call_args
110 assert call.args[0].endswith("/me/drive/items/file-1/content")
111 assert call.kwargs["headers"]["Authorization"] == "Bearer token-1"
112 assert call.kwargs["headers"]["Range"] == "bytes=100-"
113
114
115async def test_resolve_root_folder_stores_id_and_name() -> None:
116 """A configured folder path resolves to its Graph item ID and display name."""
117 provider, mocks = _make_provider(root_folder_id="/My Music/")
118 mocks.auth.async_get_access_token = AsyncMock(return_value="token-1")
119 response = MagicMock()
120 response.status = 200
121 response.json = AsyncMock(return_value={"id": "id-1", "name": "My Music", "folder": {}})
122 mocks.mass.http_session.get = MagicMock(return_value=_response_cm(response))
123
124 await provider._resolve_root_folder()
125
126 assert provider.root_folder_id == "id-1"
127 assert provider.instance_name_postfix == "My Music"
128 # the path is addressed root-relative, stripped and url-quoted
129 assert mocks.mass.http_session.get.call_args.args[0].endswith("/me/drive/root:/My%20Music")
130
131
132async def test_resolve_root_folder_not_found() -> None:
133 """A path OneDrive doesn't know fails setup with a helpful message."""
134 provider, mocks = _make_provider(root_folder_id="Nope")
135 mocks.auth.async_get_access_token = AsyncMock(return_value="token-1")
136 response = MagicMock()
137 response.status = 404
138 mocks.mass.http_session.get = MagicMock(return_value=_response_cm(response))
139
140 with pytest.raises(SetupFailedError, match="not found"):
141 await provider._resolve_root_folder()
142
143
144async def test_resolve_root_folder_rejects_non_folder() -> None:
145 """A file path configured as the root folder fails setup."""
146 provider, mocks = _make_provider(root_folder_id="track.mp3")
147 mocks.auth.async_get_access_token = AsyncMock(return_value="token-1")
148 response = MagicMock()
149 response.status = 200
150 response.json = AsyncMock(return_value={"id": "id-1", "name": "track.mp3", "file": {}})
151 mocks.mass.http_session.get = MagicMock(return_value=_response_cm(response))
152
153 with pytest.raises(SetupFailedError, match="not a folder"):
154 await provider._resolve_root_folder()
155
156
157async def test_resolve_root_folder_auth_error() -> None:
158 """A 401 during folder resolution surfaces as LoginFailed."""
159 provider, mocks = _make_provider(root_folder_id="My Music")
160 mocks.auth.async_get_access_token = AsyncMock(return_value="token-1")
161 response = MagicMock()
162 response.status = 401
163 response.text = AsyncMock(return_value="token expired")
164 mocks.mass.http_session.get = MagicMock(return_value=_response_cm(response))
165
166 with pytest.raises(LoginFailed):
167 await provider._resolve_root_folder()
168
169
170async def test_async_init_with_whole_drive_root() -> None:
171 """The 'root' default validates auth with a single call and has no name postfix."""
172 provider, mocks = _make_provider(root_folder_id="root")
173 mocks.client.get_drive_item = AsyncMock(return_value=MagicMock())
174
175 await provider.handle_async_init()
176
177 mocks.client.get_drive_item.assert_called_once_with("root")
178 assert provider.instance_name_postfix is None
179 mocks.mass.streams.register_dynamic_route.assert_called_once()
180
181
182async def test_async_init_verifies_auth() -> None:
183 """Bad credentials fail setup with LoginFailed."""
184 provider, mocks = _make_provider(root_folder_id="root")
185 mocks.client.get_drive_item = AsyncMock(side_effect=AuthenticationError(401, "invalid_grant"))
186
187 with pytest.raises(LoginFailed):
188 await provider.handle_async_init()
189