/
/
/
1"""Tests for the SMB filesystem provider mount error handling."""
2
3from __future__ import annotations
4
5from unittest.mock import AsyncMock, MagicMock, patch
6
7import pytest
8from music_assistant_models.config_entries import ProviderConfig
9from music_assistant_models.enums import ProviderStatus, ProviderType
10from music_assistant_models.errors import LoginFailed, SetupFailedError, UnsupportedSystemError
11
12from music_assistant.controllers.config.helpers import _provider_status
13from music_assistant.mass import _provider_error_from_exc
14from music_assistant.providers.filesystem_smb import SMBFileSystemProvider
15
16INSTANCE_ID = "filesystem_smb--test"
17SETUP_VALUES = {
18 "host": "nas.local",
19 "share": "music",
20 "subfolder": "",
21 "username": "user",
22 "password": "secret",
23 "smb_version": "",
24}
25
26
27def _make_provider() -> SMBFileSystemProvider:
28 provider = SMBFileSystemProvider.__new__(SMBFileSystemProvider)
29 provider.base_path = f"/tmp/{INSTANCE_ID}" # noqa: S108
30 provider.logger = MagicMock()
31 provider.config = MagicMock()
32 provider.config.instance_id = INSTANCE_ID
33 provider.get_setup_value = MagicMock( # type: ignore[method-assign]
34 side_effect=lambda key, default=None: SETUP_VALUES.get(key, default)
35 )
36 return provider
37
38
39async def _mount_with_output(output: str, system: str = "Linux") -> BaseException:
40 """Run a mount that fails with the given tool output and return the raised error."""
41 provider = _make_provider()
42 with (
43 patch(
44 "music_assistant.providers.filesystem_smb.check_output",
45 AsyncMock(return_value=(1, output.encode())),
46 ),
47 patch("music_assistant.providers.filesystem_smb.platform.system", return_value=system),
48 pytest.raises(Exception) as exc_info, # noqa: PT011
49 ):
50 await provider.mount()
51 return exc_info.value
52
53
54def _status_for(err: BaseException) -> ProviderStatus:
55 """Derive the provider status the UI would show for a failed load."""
56 conf = ProviderConfig(
57 values={},
58 type=ProviderType.MUSIC,
59 domain="filesystem_smb",
60 instance_id=INSTANCE_ID,
61 last_error=_provider_error_from_exc(err),
62 )
63 return _provider_status(conf, is_loaded=False)
64
65
66async def test_busy_mountpoint_is_not_an_auth_error() -> None:
67 """A busy mountpoint surfaces as a plain setup error, not as invalid credentials."""
68 err = await _mount_with_output("mount error(16): Device or resource busy")
69 assert isinstance(err, SetupFailedError)
70 assert not isinstance(err, LoginFailed)
71 assert _status_for(err) == ProviderStatus.ERROR
72
73
74async def test_busy_mountpoint_keeps_the_tool_output() -> None:
75 """A non-auth mount failure shows the summary line but keeps the full output for support."""
76 summary = "mount error(16): Device or resource busy"
77 pointer = "Refer to the mount.cifs(8) manual page (e.g. man mount.cifs)"
78 err = await _mount_with_output(f"{summary}\n{pointer}")
79 assert isinstance(err, SetupFailedError)
80 assert err.translation_key == "mount_failed"
81 assert err.translation_args == [summary]
82 assert pointer in str(err)
83
84
85async def test_permission_denied_is_an_auth_error() -> None:
86 """A rejected credential (mount.cifs) surfaces as a login failure."""
87 err = await _mount_with_output("mount error(13): Permission denied")
88 assert isinstance(err, LoginFailed)
89
90
91async def test_nt_status_logon_failure_is_an_auth_error() -> None:
92 """A rejected credential reported as an NT status code surfaces as a login failure."""
93 err = await _mount_with_output("Unable to find suitable address.NT_STATUS_LOGON_FAILURE")
94 assert isinstance(err, LoginFailed)
95
96
97async def test_unsupported_platform() -> None:
98 """A platform without SMB mount support is reported as permanently incompatible."""
99 provider = _make_provider()
100 with (
101 patch("music_assistant.providers.filesystem_smb.platform.system", return_value="Windows"),
102 pytest.raises(UnsupportedSystemError),
103 ):
104 await provider.mount()
105