/
/
/
1"""Tests for the Google Drive provider's API hooks and setup checks."""
2
3from __future__ import annotations
4
5from unittest.mock import AsyncMock, MagicMock
6
7import pytest
8from aiohttp import ClientError
9from google_drive_api.exceptions import ApiException, AuthException
10from music_assistant_models.errors import (
11 LoginFailed,
12 MediaNotFoundError,
13 ProviderUnavailableError,
14 SetupFailedError,
15)
16
17from music_assistant.providers.filesystem_google_drive.constants import FOLDER_MIME_TYPE
18from music_assistant.providers.filesystem_google_drive.provider import (
19 GoogleDriveFileSystemProvider,
20 _translate_errors,
21)
22
23
24def _make_provider(
25 root_folder_id: str = "root",
26) -> tuple[GoogleDriveFileSystemProvider, MagicMock]:
27 """Return a bare provider plus the mock bundle behind its api/auth/mass."""
28 mocks = MagicMock()
29 mocks.config.instance_id = "gdrive--test"
30 provider = GoogleDriveFileSystemProvider.__new__(GoogleDriveFileSystemProvider)
31 provider.root_folder_id = root_folder_id
32 provider._root_folder_name = None
33 provider._unregister_stream_route = None
34 provider._dir_cache = {}
35 provider.logger = mocks.logger
36 provider.api = mocks.api
37 provider.auth = mocks.auth
38 provider.mass = mocks.mass
39 provider.config = mocks.config
40 return provider, mocks
41
42
43async def test_list_children_follows_pagination() -> None:
44 """All pages are fetched and the page token is passed along."""
45 provider, mocks = _make_provider()
46 mocks.api.list_files = AsyncMock(
47 side_effect=[
48 {
49 "files": [{"id": "f1", "name": "one.mp3", "modifiedTime": "t1", "size": "10"}],
50 "nextPageToken": "page2",
51 },
52 {"files": [{"id": "f2", "name": "two.mp3", "modifiedTime": "t2", "size": "20"}]},
53 ]
54 )
55
56 items = await provider._api_list_children("folder-id")
57
58 assert [item[0] for item in items] == ["f1", "f2"]
59 second_params = mocks.api.list_files.call_args_list[1].kwargs["params"]
60 assert second_params["pageToken"] == "page2"
61
62
63async def test_list_children_maps_drive_fields() -> None:
64 """Drive file dicts map onto RawItem tuples, with sane fallbacks."""
65 provider, mocks = _make_provider()
66 mocks.api.list_files = AsyncMock(
67 return_value={
68 "files": [
69 {"id": "d1", "name": "Albums", "mimeType": FOLDER_MIME_TYPE},
70 {"id": "f1", "name": "track.mp3", "modifiedTime": "2026-01-01", "size": "123"},
71 {"id": "f2", "name": "no-meta.mp3"},
72 ]
73 }
74 )
75
76 items = await provider._api_list_children("folder-id")
77
78 assert items[0] == ("d1", "Albums", True, "unknown", None, None)
79 assert items[1] == ("f1", "track.mp3", False, "2026-01-01", 123, None)
80 assert items[2] == ("f2", "no-meta.mp3", False, "unknown", None, None)
81
82
83async def test_list_children_translates_api_errors() -> None:
84 """Drive client errors surface as MA errors per the base's hook contract."""
85 provider, mocks = _make_provider()
86 mocks.api.list_files = AsyncMock(side_effect=ApiException("quota"))
87
88 with pytest.raises(ProviderUnavailableError):
89 await provider._api_list_children("folder-id")
90
91
92def test_translate_errors_mapping() -> None:
93 """Auth errors become LoginFailed; API/transport errors ProviderUnavailableError."""
94 with pytest.raises(LoginFailed), _translate_errors():
95 raise AuthException("expired")
96 with pytest.raises(ProviderUnavailableError), _translate_errors():
97 raise ApiException("boom")
98 with pytest.raises(ProviderUnavailableError), _translate_errors():
99 raise ClientError("connection reset")
100 # MA errors pass through untouched
101 with pytest.raises(MediaNotFoundError), _translate_errors():
102 raise MediaNotFoundError("missing")
103
104
105async def test_async_init_verifies_auth() -> None:
106 """Bad credentials fail setup with LoginFailed."""
107 provider, mocks = _make_provider()
108 mocks.api.get_user = AsyncMock(side_effect=AuthException("invalid_grant"))
109
110 with pytest.raises(LoginFailed):
111 await provider.handle_async_init()
112
113
114async def test_async_init_rejects_unknown_folder_id() -> None:
115 """A folder ID Drive doesn't know fails setup with a helpful message."""
116 provider, mocks = _make_provider(root_folder_id="not-a-real-id")
117 mocks.api.get_user = AsyncMock(return_value={})
118 mocks.auth.get_json = AsyncMock(side_effect=ApiException("404"))
119
120 with pytest.raises(SetupFailedError, match="not the folder name"):
121 await provider.handle_async_init()
122
123
124async def test_async_init_rejects_non_folder_id() -> None:
125 """A file ID configured as the root folder fails setup."""
126 provider, mocks = _make_provider(root_folder_id="file-id")
127 mocks.api.get_user = AsyncMock(return_value={})
128 mocks.auth.get_json = AsyncMock(return_value={"id": "file-id", "mimeType": "audio/mpeg"})
129
130 with pytest.raises(SetupFailedError, match="not a folder"):
131 await provider.handle_async_init()
132
133
134async def test_async_init_stores_folder_name_and_registers_route() -> None:
135 """A valid folder ID stores the display name and registers the stream route."""
136 provider, mocks = _make_provider(root_folder_id="folder-id")
137 mocks.api.get_user = AsyncMock(return_value={})
138 mocks.auth.get_json = AsyncMock(
139 return_value={"id": "folder-id", "name": "My Music", "mimeType": FOLDER_MIME_TYPE}
140 )
141
142 await provider.handle_async_init()
143
144 assert provider.instance_name_postfix == "My Music"
145 mocks.mass.streams.register_dynamic_route.assert_called_once()
146
147
148async def test_async_init_with_whole_drive_root() -> None:
149 """The 'root' alias skips the folder check and has no name postfix."""
150 provider, mocks = _make_provider(root_folder_id="root")
151 mocks.api.get_user = AsyncMock(return_value={})
152
153 await provider.handle_async_init()
154
155 mocks.auth.get_json.assert_not_called()
156 assert provider.instance_name_postfix is None
157