unifi-core 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,88 @@
1
+ # Environment variables
2
+ .env
3
+
4
+ # Python
5
+ __pycache__/
6
+ _version.py
7
+ *.py[cod]
8
+ *$py.class
9
+ *.so
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ *.egg-info/
24
+ .installed.cfg
25
+ *.egg
26
+
27
+ # Virtual environments
28
+ venv/
29
+ env/
30
+ ENV/
31
+
32
+ # IDEs and editors
33
+ .idea/
34
+ .vscode/
35
+ *.swp
36
+ *.swo
37
+ *.swn
38
+ *~
39
+
40
+ # Logs
41
+ *.log
42
+ logs/
43
+
44
+ # Docker
45
+ .dockerignore
46
+
47
+ # Cache
48
+ .cache/
49
+
50
+ # Runtime data
51
+ pids/
52
+ *.pid
53
+ *.seed
54
+ *.pid.lock
55
+
56
+ # Backup files
57
+ *.bak
58
+ *.backup
59
+
60
+ # Testing
61
+ .coverage
62
+
63
+ # Playwright MCP artifacts
64
+ .playwright-mcp/
65
+
66
+ .DS_Store
67
+
68
+ aiounifi/*
69
+ aiounifi
70
+
71
+ .env.template
72
+ claude_config.json
73
+
74
+ # open-agent-kit: Codebase Intelligence
75
+ .oak/ci/
76
+ .oak/config.*.yaml
77
+
78
+ # open-agent-kit: Issue raw JSON (local debugging only)
79
+ oak/issue/**/context.json
80
+
81
+ # open-agent-kit: CI hook configs (local-only, regenerated by oak ci start)
82
+ .claude/settings.local.json
83
+
84
+ # Specs and plans — captured in Myco vault, not the repo
85
+ docs/superpowers/
86
+ docs/plans/
87
+ docs/specs/
88
+ .superpowers/
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: unifi-core
3
+ Version: 0.1.0
4
+ Summary: UniFi controller connectivity: auth, detection, retry, exceptions
5
+ Requires-Python: >=3.13
6
+ Requires-Dist: aiohttp>=3.8.5
7
+ Requires-Dist: pyyaml>=6.0
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "unifi-core"
3
+ dynamic = ["version"]
4
+ description = "UniFi controller connectivity: auth, detection, retry, exceptions"
5
+ requires-python = ">=3.13"
6
+ dependencies = [
7
+ "aiohttp>=3.8.5",
8
+ "pyyaml>=6.0",
9
+ ]
10
+
11
+ [dependency-groups]
12
+ dev = [
13
+ "pytest>=7.0.0",
14
+ "pytest-asyncio>=0.21.0",
15
+ "aioresponses>=0.7.0",
16
+ ]
17
+
18
+ [build-system]
19
+ requires = ["hatchling", "hatch-vcs"]
20
+ build-backend = "hatchling.build"
21
+
22
+ [tool.hatch.version]
23
+ source = "vcs"
24
+ raw-options.root = "../.."
25
+ raw-options.tag_regex = "^core/v(?P<version>\\d+(?:\\.\\d+)*)(?:\\S*)$"
26
+ raw-options.git_describe_command = ["git", "describe", "--dirty", "--tags", "--long", "--match", "core/v*"]
27
+ raw-options.fallback_version = "0.0.0"
28
+
29
+ [tool.hatch.build.hooks.vcs]
30
+ version-file = "src/unifi_core/_version.py"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/unifi_core"]
34
+
35
+ [tool.pytest.ini_options]
36
+ asyncio_mode = "auto"
@@ -0,0 +1,35 @@
1
+ """UniFi controller connectivity: auth, detection, retry, exceptions."""
2
+
3
+ from unifi_core.auth import AuthMethod, LocalAuthProvider, UniFiAuth
4
+ from unifi_core.connection import ConnectionConfig
5
+ from unifi_core.detection import ControllerType, detect_controller_type_by_api_probe, detect_controller_type_pre_login
6
+ from unifi_core.exceptions import (
7
+ UniFiAuthError,
8
+ UniFiConnectionError,
9
+ UniFiError,
10
+ UniFiPermissionError,
11
+ UniFiRateLimitError,
12
+ )
13
+ from unifi_core.retry import RetryPolicy, retry_with_backoff
14
+
15
+ __all__ = [
16
+ # auth
17
+ "AuthMethod",
18
+ "LocalAuthProvider",
19
+ "UniFiAuth",
20
+ # connection
21
+ "ConnectionConfig",
22
+ # detection
23
+ "ControllerType",
24
+ "detect_controller_type_by_api_probe",
25
+ "detect_controller_type_pre_login",
26
+ # exceptions
27
+ "UniFiAuthError",
28
+ "UniFiConnectionError",
29
+ "UniFiError",
30
+ "UniFiPermissionError",
31
+ "UniFiRateLimitError",
32
+ # retry
33
+ "RetryPolicy",
34
+ "retry_with_backoff",
35
+ ]
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.1.0'
32
+ __version_tuple__ = version_tuple = (0, 1, 0)
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1,73 @@
1
+ """Dual authentication strategy for UniFi controllers."""
2
+
3
+ import enum
4
+ import logging
5
+ from typing import Protocol
6
+
7
+ import aiohttp
8
+
9
+ from unifi_core.exceptions import UniFiAuthError
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class AuthMethod(enum.Enum):
15
+ LOCAL_ONLY = "local_only"
16
+ API_KEY_ONLY = "api_key_only"
17
+ EITHER = "either"
18
+
19
+ @classmethod
20
+ def from_string(cls, value: str | None) -> "AuthMethod":
21
+ if value is None:
22
+ return cls.LOCAL_ONLY
23
+ try:
24
+ return cls(value)
25
+ except ValueError:
26
+ logger.warning("[auth] Unknown auth method '%s', defaulting to local_only", value)
27
+ return cls.LOCAL_ONLY
28
+
29
+
30
+ class LocalAuthProvider(Protocol):
31
+ """Contract that each app fulfills for local auth."""
32
+
33
+ async def get_session(self) -> aiohttp.ClientSession: ...
34
+
35
+
36
+ class UniFiAuth:
37
+ """Dual auth: API key and/or local auth provider."""
38
+
39
+ def __init__(self, api_key: str | None = None, local_provider: LocalAuthProvider | None = None):
40
+ self._api_key = api_key
41
+ self._local_provider = local_provider
42
+
43
+ @property
44
+ def has_api_key(self) -> bool:
45
+ return self._api_key is not None and self._api_key != ""
46
+
47
+ @property
48
+ def has_local(self) -> bool:
49
+ return self._local_provider is not None
50
+
51
+ def set_local_provider(self, provider: LocalAuthProvider) -> None:
52
+ self._local_provider = provider
53
+
54
+ async def get_api_key_session(self) -> aiohttp.ClientSession:
55
+ if not self.has_api_key:
56
+ raise UniFiAuthError("API key authentication not configured. Set UNIFI_API_KEY environment variable.")
57
+ return aiohttp.ClientSession(headers={"X-API-Key": self._api_key})
58
+
59
+ async def get_local_session(self) -> aiohttp.ClientSession:
60
+ if not self.has_local:
61
+ raise UniFiAuthError("Local authentication not configured. Set UNIFI_USERNAME and UNIFI_PASSWORD.")
62
+ return await self._local_provider.get_session()
63
+
64
+ async def get_session(self, method: AuthMethod) -> aiohttp.ClientSession:
65
+ if method == AuthMethod.API_KEY_ONLY:
66
+ return await self.get_api_key_session()
67
+ elif method == AuthMethod.LOCAL_ONLY:
68
+ return await self.get_local_session()
69
+ elif method == AuthMethod.EITHER:
70
+ if self.has_api_key:
71
+ return await self.get_api_key_session()
72
+ return await self.get_local_session()
73
+ raise UniFiAuthError(f"Unknown auth method: {method}")
@@ -0,0 +1,23 @@
1
+ """Base async connection primitives for UniFi controllers."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import aiohttp # noqa: F401
6
+
7
+
8
+ @dataclass
9
+ class ConnectionConfig:
10
+ host: str
11
+ port: int = 443
12
+ verify_ssl: bool = False
13
+ timeout: float = 30.0
14
+
15
+ @property
16
+ def url_base(self) -> str:
17
+ return f"https://{self.host}:{self.port}"
18
+
19
+ @property
20
+ def ssl_context(self):
21
+ if self.verify_ssl:
22
+ return None
23
+ return False
@@ -0,0 +1,87 @@
1
+ """UniFi controller type detection.
2
+
3
+ Determines whether the controller is a UniFi OS appliance (UDM, UDR, etc.)
4
+ or a standalone/self-hosted Network Application. This affects API path
5
+ routing: UniFi OS uses /proxy/network/... while standalone uses /api/...
6
+ """
7
+
8
+ import enum
9
+ import logging
10
+
11
+ import aiohttp
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class ControllerType(enum.Enum):
17
+ UNIFI_OS = "proxy"
18
+ STANDALONE = "direct"
19
+ AUTO = "auto"
20
+
21
+ @classmethod
22
+ def from_config(cls, value: str) -> "ControllerType":
23
+ mapping = {"proxy": cls.UNIFI_OS, "direct": cls.STANDALONE, "auto": cls.AUTO}
24
+ result = mapping.get(value.lower(), cls.AUTO)
25
+ if value.lower() not in mapping:
26
+ logger.warning("[detection] Unknown controller type '%s', falling back to auto", value)
27
+ return result
28
+
29
+
30
+ async def detect_controller_type_pre_login(
31
+ host: str, port: int, verify_ssl: bool = False, timeout: float = 10.0
32
+ ) -> ControllerType | None:
33
+ """Probe the controller before login to detect type.
34
+
35
+ Strategy:
36
+ - GET the base URL without following redirects
37
+ - UniFi OS returns 200 with x-csrf-token header or HTML
38
+ - Standalone controllers redirect (301/302) to /manage
39
+
40
+ Returns:
41
+ ControllerType.UNIFI_OS or ControllerType.STANDALONE if detected, None otherwise.
42
+ """
43
+ url = f"https://{host}:{port}"
44
+ ssl_context = None if verify_ssl else False
45
+ client_timeout = aiohttp.ClientTimeout(total=timeout)
46
+
47
+ try:
48
+ async with aiohttp.ClientSession(timeout=client_timeout) as session:
49
+ async with session.get(url, ssl=ssl_context, allow_redirects=False) as resp:
50
+ headers = resp.headers
51
+ if "x-csrf-token" in headers or resp.status == 200:
52
+ return ControllerType.UNIFI_OS
53
+ if resp.status in (302, 301):
54
+ return ControllerType.STANDALONE
55
+ except Exception as e:
56
+ logger.debug("[detection] Pre-login probe failed: %s", e)
57
+
58
+ return None
59
+
60
+
61
+ async def detect_controller_type_by_api_probe(
62
+ session: aiohttp.ClientSession, host: str, port: int, verify_ssl: bool = False
63
+ ) -> ControllerType | None:
64
+ """Probe API endpoints to detect controller type (requires authenticated session).
65
+
66
+ Tries both UniFi OS and standalone API paths for /api/self/sites.
67
+ Returns the type corresponding to the first path that returns HTTP 200.
68
+
69
+ Returns:
70
+ ControllerType.UNIFI_OS or ControllerType.STANDALONE if detected, None otherwise.
71
+ """
72
+ url_base = f"https://{host}:{port}"
73
+ ssl_context = None if verify_ssl else False
74
+
75
+ for path, expected_type in [
76
+ ("/proxy/network/api/self/sites", ControllerType.UNIFI_OS),
77
+ ("/api/self/sites", ControllerType.STANDALONE),
78
+ ]:
79
+ try:
80
+ async with session.get(f"{url_base}{path}", ssl=ssl_context) as resp:
81
+ if resp.status == 200:
82
+ logger.info("[detection] Detected %s via %s", expected_type.name, path)
83
+ return expected_type
84
+ except Exception:
85
+ continue
86
+
87
+ return None
@@ -0,0 +1,21 @@
1
+ """Shared exception hierarchy for UniFi MCP servers."""
2
+
3
+
4
+ class UniFiError(Exception):
5
+ """Base exception for all UniFi errors."""
6
+
7
+
8
+ class UniFiAuthError(UniFiError):
9
+ """Authentication failed."""
10
+
11
+
12
+ class UniFiConnectionError(UniFiError):
13
+ """Connection to controller failed."""
14
+
15
+
16
+ class UniFiRateLimitError(UniFiError):
17
+ """Rate limit exceeded."""
18
+
19
+
20
+ class UniFiPermissionError(UniFiError):
21
+ """Insufficient permissions for operation."""
@@ -0,0 +1,43 @@
1
+ """Retry logic with exponential backoff."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from dataclasses import dataclass
6
+
7
+ from unifi_core.exceptions import UniFiError
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ @dataclass
13
+ class RetryPolicy:
14
+ max_retries: int = 3
15
+ base_delay: float = 1.0
16
+ max_delay: float = 30.0
17
+ backoff_factor: float = 2.0
18
+ retryable_exceptions: tuple = (UniFiError,)
19
+
20
+
21
+ async def retry_with_backoff(operation, policy: RetryPolicy | None = None):
22
+ """Execute operation with exponential backoff retry."""
23
+ if policy is None:
24
+ policy = RetryPolicy()
25
+
26
+ last_error = None
27
+ for attempt in range(policy.max_retries + 1):
28
+ try:
29
+ return await operation()
30
+ except policy.retryable_exceptions as e:
31
+ last_error = e
32
+ if attempt < policy.max_retries:
33
+ delay = min(policy.base_delay * (policy.backoff_factor ** attempt), policy.max_delay)
34
+ logger.warning(
35
+ "[retry] Attempt %d/%d failed: %s. Retrying in %.1fs",
36
+ attempt + 1,
37
+ policy.max_retries,
38
+ e,
39
+ delay,
40
+ )
41
+ await asyncio.sleep(delay)
42
+
43
+ raise last_error
File without changes
@@ -0,0 +1,136 @@
1
+ import pytest
2
+ import aiohttp
3
+ from unittest.mock import AsyncMock
4
+ from unifi_core.auth import AuthMethod, UniFiAuth
5
+ from unifi_core.exceptions import UniFiAuthError
6
+
7
+
8
+ class TestAuthMethod:
9
+ def test_enum_values(self):
10
+ assert AuthMethod.LOCAL_ONLY.value == "local_only"
11
+ assert AuthMethod.API_KEY_ONLY.value == "api_key_only"
12
+ assert AuthMethod.EITHER.value == "either"
13
+
14
+ def test_from_string_valid(self):
15
+ assert AuthMethod.from_string("local_only") == AuthMethod.LOCAL_ONLY
16
+ assert AuthMethod.from_string("api_key_only") == AuthMethod.API_KEY_ONLY
17
+ assert AuthMethod.from_string("either") == AuthMethod.EITHER
18
+
19
+ def test_from_string_none_defaults_to_local(self):
20
+ assert AuthMethod.from_string(None) == AuthMethod.LOCAL_ONLY
21
+
22
+ def test_from_string_unknown_defaults_to_local(self):
23
+ assert AuthMethod.from_string("unknown") == AuthMethod.LOCAL_ONLY
24
+ assert AuthMethod.from_string("") == AuthMethod.LOCAL_ONLY
25
+
26
+
27
+ class TestUniFiAuthProperties:
28
+ def test_has_api_key_true(self):
29
+ auth = UniFiAuth(api_key="test-key")
30
+ assert auth.has_api_key is True
31
+
32
+ def test_has_api_key_false_when_none(self):
33
+ auth = UniFiAuth(api_key=None)
34
+ assert auth.has_api_key is False
35
+
36
+ def test_has_api_key_false_when_empty(self):
37
+ auth = UniFiAuth(api_key="")
38
+ assert auth.has_api_key is False
39
+
40
+ def test_has_local_true(self):
41
+ provider = AsyncMock()
42
+ auth = UniFiAuth(local_provider=provider)
43
+ assert auth.has_local is True
44
+
45
+ def test_has_local_false_when_none(self):
46
+ auth = UniFiAuth()
47
+ assert auth.has_local is False
48
+
49
+ def test_set_local_provider(self):
50
+ auth = UniFiAuth()
51
+ assert auth.has_local is False
52
+ provider = AsyncMock()
53
+ auth.set_local_provider(provider)
54
+ assert auth.has_local is True
55
+
56
+
57
+ class TestUniFiAuthApiKeySession:
58
+ @pytest.mark.asyncio
59
+ async def test_get_api_key_session_creates_session_with_header(self):
60
+ auth = UniFiAuth(api_key="my-api-key")
61
+ session = await auth.get_api_key_session()
62
+ try:
63
+ assert isinstance(session, aiohttp.ClientSession)
64
+ # Check that the default headers contain the API key
65
+ assert session.headers.get("X-API-Key") == "my-api-key"
66
+ finally:
67
+ await session.close()
68
+
69
+ @pytest.mark.asyncio
70
+ async def test_get_api_key_session_raises_when_not_configured(self):
71
+ auth = UniFiAuth()
72
+ with pytest.raises(UniFiAuthError, match="API key authentication not configured"):
73
+ await auth.get_api_key_session()
74
+
75
+
76
+ class TestUniFiAuthLocalSession:
77
+ @pytest.mark.asyncio
78
+ async def test_get_local_session_delegates_to_provider(self):
79
+ mock_session = AsyncMock(spec=aiohttp.ClientSession)
80
+ provider = AsyncMock()
81
+ provider.get_session = AsyncMock(return_value=mock_session)
82
+ auth = UniFiAuth(local_provider=provider)
83
+ session = await auth.get_local_session()
84
+ assert session is mock_session
85
+ provider.get_session.assert_awaited_once()
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_get_local_session_raises_when_not_configured(self):
89
+ auth = UniFiAuth()
90
+ with pytest.raises(UniFiAuthError, match="Local authentication not configured"):
91
+ await auth.get_local_session()
92
+
93
+
94
+ class TestUniFiAuthGetSession:
95
+ @pytest.mark.asyncio
96
+ async def test_get_session_api_key_only(self):
97
+ auth = UniFiAuth(api_key="test-key")
98
+ session = await auth.get_session(AuthMethod.API_KEY_ONLY)
99
+ try:
100
+ assert session.headers.get("X-API-Key") == "test-key"
101
+ finally:
102
+ await session.close()
103
+
104
+ @pytest.mark.asyncio
105
+ async def test_get_session_local_only(self):
106
+ mock_session = AsyncMock(spec=aiohttp.ClientSession)
107
+ provider = AsyncMock()
108
+ provider.get_session = AsyncMock(return_value=mock_session)
109
+ auth = UniFiAuth(local_provider=provider)
110
+ session = await auth.get_session(AuthMethod.LOCAL_ONLY)
111
+ assert session is mock_session
112
+
113
+ @pytest.mark.asyncio
114
+ async def test_get_session_either_prefers_api_key(self):
115
+ mock_session = AsyncMock(spec=aiohttp.ClientSession)
116
+ provider = AsyncMock()
117
+ provider.get_session = AsyncMock(return_value=mock_session)
118
+ auth = UniFiAuth(api_key="test-key", local_provider=provider)
119
+ session = await auth.get_session(AuthMethod.EITHER)
120
+ try:
121
+ # Should prefer API key when both are available
122
+ assert isinstance(session, aiohttp.ClientSession)
123
+ assert session.headers.get("X-API-Key") == "test-key"
124
+ provider.get_session.assert_not_awaited()
125
+ finally:
126
+ await session.close()
127
+
128
+ @pytest.mark.asyncio
129
+ async def test_get_session_either_falls_back_to_local(self):
130
+ mock_session = AsyncMock(spec=aiohttp.ClientSession)
131
+ provider = AsyncMock()
132
+ provider.get_session = AsyncMock(return_value=mock_session)
133
+ auth = UniFiAuth(local_provider=provider)
134
+ session = await auth.get_session(AuthMethod.EITHER)
135
+ assert session is mock_session
136
+ provider.get_session.assert_awaited_once()
@@ -0,0 +1,33 @@
1
+ from unifi_core.connection import ConnectionConfig
2
+
3
+
4
+ class TestConnectionConfig:
5
+ def test_defaults(self):
6
+ cfg = ConnectionConfig(host="192.168.1.1")
7
+ assert cfg.host == "192.168.1.1"
8
+ assert cfg.port == 443
9
+ assert cfg.verify_ssl is False
10
+ assert cfg.timeout == 30.0
11
+
12
+ def test_custom_values(self):
13
+ cfg = ConnectionConfig(host="10.0.0.1", port=8443, verify_ssl=True, timeout=60.0)
14
+ assert cfg.host == "10.0.0.1"
15
+ assert cfg.port == 8443
16
+ assert cfg.verify_ssl is True
17
+ assert cfg.timeout == 60.0
18
+
19
+ def test_url_base(self):
20
+ cfg = ConnectionConfig(host="192.168.1.1")
21
+ assert cfg.url_base == "https://192.168.1.1:443"
22
+
23
+ def test_url_base_custom_port(self):
24
+ cfg = ConnectionConfig(host="10.0.0.1", port=8443)
25
+ assert cfg.url_base == "https://10.0.0.1:8443"
26
+
27
+ def test_ssl_context_when_verify_false(self):
28
+ cfg = ConnectionConfig(host="192.168.1.1", verify_ssl=False)
29
+ assert cfg.ssl_context is False
30
+
31
+ def test_ssl_context_when_verify_true(self):
32
+ cfg = ConnectionConfig(host="192.168.1.1", verify_ssl=True)
33
+ assert cfg.ssl_context is None
@@ -0,0 +1,31 @@
1
+ import pytest
2
+ from unifi_core.detection import ControllerType
3
+
4
+
5
+ class TestControllerType:
6
+ def test_enum_values(self):
7
+ assert ControllerType.UNIFI_OS.value == "proxy"
8
+ assert ControllerType.STANDALONE.value == "direct"
9
+ assert ControllerType.AUTO.value == "auto"
10
+
11
+ def test_from_config_proxy(self):
12
+ assert ControllerType.from_config("proxy") == ControllerType.UNIFI_OS
13
+
14
+ def test_from_config_direct(self):
15
+ assert ControllerType.from_config("direct") == ControllerType.STANDALONE
16
+
17
+ def test_from_config_auto(self):
18
+ assert ControllerType.from_config("auto") == ControllerType.AUTO
19
+
20
+ def test_from_config_case_insensitive(self):
21
+ assert ControllerType.from_config("PROXY") == ControllerType.UNIFI_OS
22
+ assert ControllerType.from_config("Direct") == ControllerType.STANDALONE
23
+ assert ControllerType.from_config("AUTO") == ControllerType.AUTO
24
+
25
+ def test_from_config_unknown_falls_back_to_auto(self):
26
+ assert ControllerType.from_config("unknown") == ControllerType.AUTO
27
+ assert ControllerType.from_config("") == ControllerType.AUTO
28
+
29
+ def test_enum_members_complete(self):
30
+ members = set(ControllerType)
31
+ assert members == {ControllerType.UNIFI_OS, ControllerType.STANDALONE, ControllerType.AUTO}
@@ -0,0 +1,13 @@
1
+ from unifi_core.exceptions import UniFiError, UniFiAuthError, UniFiConnectionError, UniFiRateLimitError, UniFiPermissionError
2
+
3
+
4
+ def test_exception_hierarchy():
5
+ assert issubclass(UniFiAuthError, UniFiError)
6
+ assert issubclass(UniFiConnectionError, UniFiError)
7
+ assert issubclass(UniFiRateLimitError, UniFiError)
8
+ assert issubclass(UniFiPermissionError, UniFiError)
9
+
10
+
11
+ def test_exception_message():
12
+ err = UniFiAuthError("Invalid credentials")
13
+ assert str(err) == "Invalid credentials"
@@ -0,0 +1,58 @@
1
+ import pytest
2
+ from unifi_core.retry import RetryPolicy, retry_with_backoff
3
+ from unifi_core.exceptions import UniFiConnectionError
4
+
5
+
6
+ @pytest.mark.asyncio
7
+ async def test_retry_succeeds_after_failures():
8
+ call_count = 0
9
+
10
+ async def flaky_operation():
11
+ nonlocal call_count
12
+ call_count += 1
13
+ if call_count < 3:
14
+ raise UniFiConnectionError("Connection failed")
15
+ return "success"
16
+
17
+ policy = RetryPolicy(max_retries=3, base_delay=0.01)
18
+ result = await retry_with_backoff(flaky_operation, policy)
19
+ assert result == "success"
20
+ assert call_count == 3
21
+
22
+
23
+ @pytest.mark.asyncio
24
+ async def test_retry_exhausted_raises():
25
+ async def always_fails():
26
+ raise UniFiConnectionError("Connection failed")
27
+
28
+ policy = RetryPolicy(max_retries=2, base_delay=0.01)
29
+ with pytest.raises(UniFiConnectionError):
30
+ await retry_with_backoff(always_fails, policy)
31
+
32
+
33
+ @pytest.mark.asyncio
34
+ async def test_retry_succeeds_on_first_try():
35
+ async def succeeds():
36
+ return "ok"
37
+
38
+ policy = RetryPolicy(max_retries=3, base_delay=0.01)
39
+ result = await retry_with_backoff(succeeds, policy)
40
+ assert result == "ok"
41
+
42
+
43
+ @pytest.mark.asyncio
44
+ async def test_retry_non_retryable_exception_not_caught():
45
+ async def raises_value_error():
46
+ raise ValueError("not retryable")
47
+
48
+ policy = RetryPolicy(max_retries=3, base_delay=0.01)
49
+ with pytest.raises(ValueError, match="not retryable"):
50
+ await retry_with_backoff(raises_value_error, policy)
51
+
52
+
53
+ def test_retry_policy_defaults():
54
+ policy = RetryPolicy()
55
+ assert policy.max_retries == 3
56
+ assert policy.base_delay == 1.0
57
+ assert policy.max_delay == 30.0
58
+ assert policy.backoff_factor == 2.0