Skip to content

Commit fe72570

Browse files
Add tests for error_handling and director modules (#55)
Tests were written first against the 1.x API to verify correctness on the existing codebase, then adjusted for the 2.0 snake_case renames and sync error_handling refactor so that this commit applies cleanly on top of the 2.x-refactor branch. test_error_handling.py — covers all branches of check_response_for_error: - JSON and XML happy paths (no error keys) - C4ErrorResponse format: BadCredentials, Unauthorized, NotFound, unknown code fallback to C4Exception - Flat JSON code format: NotFound, BadCredentials (details priority) - Director error format: BadToken, Unauthorized, InvalidCategory, unknown error fallback to C4Exception - XML C4ErrorResponse parsing - Exception hierarchy and message preservation test_director.py — covers get_item_variable_value edge cases: - Return types: int, bool, string, zero (not confused with None) - Undefined normalization to None - JSON null passthrough as None - Empty response and invalid format raise ValueError - List/tuple var_name joining - get_all_item_variable_value mixed Undefined normalization - get_all_item_info returns parsed JSON (2.0 behavior) Also adds tests/conftest.py with shared director fixture and removes the duplicate fixture from test_undefined_handling.py. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 15a9819 commit fe72570

4 files changed

Lines changed: 343 additions & 7 deletions

File tree

tests/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import pytest
2+
3+
from pyControl4.director import C4Director
4+
5+
6+
@pytest.fixture
7+
def director():
8+
"""Create a C4Director with no real session (for mocked tests)."""
9+
return C4Director("192.168.1.1", "test-token")

tests/test_director.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Tests for C4Director — get_item_variable_value, get_all_item_variable_value,
2+
and basic request wrappers.
3+
"""
4+
5+
import json
6+
from unittest.mock import AsyncMock, patch
7+
8+
import pytest
9+
10+
11+
@pytest.mark.asyncio
12+
async def test_get_item_variable_value_int(director):
13+
"""Normal integer value is returned as-is."""
14+
response = json.dumps([{"id": 100, "varName": "LIGHT_LEVEL", "value": 75}])
15+
with patch.object(
16+
director, "send_get_request", new=AsyncMock(return_value=response)
17+
):
18+
result = await director.get_item_variable_value(100, "LIGHT_LEVEL")
19+
assert result == 75
20+
21+
22+
@pytest.mark.asyncio
23+
async def test_get_item_variable_value_zero(director):
24+
"""Zero is returned as 0, not confused with None or falsy."""
25+
response = json.dumps([{"id": 100, "varName": "LIGHT_LEVEL", "value": 0}])
26+
with patch.object(
27+
director, "send_get_request", new=AsyncMock(return_value=response)
28+
):
29+
result = await director.get_item_variable_value(100, "LIGHT_LEVEL")
30+
assert result == 0
31+
assert result is not None
32+
33+
34+
@pytest.mark.asyncio
35+
async def test_get_item_variable_value_bool(director):
36+
"""Boolean value is returned as a Python bool."""
37+
response = json.dumps([{"id": 100, "varName": "IS_ON", "value": True}])
38+
with patch.object(
39+
director, "send_get_request", new=AsyncMock(return_value=response)
40+
):
41+
result = await director.get_item_variable_value(100, "IS_ON")
42+
assert result is True
43+
44+
45+
@pytest.mark.asyncio
46+
async def test_get_item_variable_value_string(director):
47+
"""String value is returned as-is."""
48+
response = json.dumps(
49+
[{"id": 100, "varName": "PARTITION_STATE", "value": "ARMED_AWAY"}]
50+
)
51+
with patch.object(
52+
director, "send_get_request", new=AsyncMock(return_value=response)
53+
):
54+
result = await director.get_item_variable_value(100, "PARTITION_STATE")
55+
assert result == "ARMED_AWAY"
56+
57+
58+
@pytest.mark.asyncio
59+
async def test_get_item_variable_value_null(director):
60+
"""JSON null value passes through as None (distinct from 'Undefined')."""
61+
response = json.dumps([{"id": 100, "varName": "OPTIONAL_VAR", "value": None}])
62+
with patch.object(
63+
director, "send_get_request", new=AsyncMock(return_value=response)
64+
):
65+
result = await director.get_item_variable_value(100, "OPTIONAL_VAR")
66+
assert result is None
67+
68+
69+
@pytest.mark.asyncio
70+
async def test_get_item_variable_value_empty_response(director):
71+
"""Empty list response raises ValueError."""
72+
with patch.object(director, "send_get_request", new=AsyncMock(return_value="[]")):
73+
with pytest.raises(ValueError):
74+
await director.get_item_variable_value(100, "NONEXISTENT")
75+
76+
77+
@pytest.mark.asyncio
78+
async def test_get_item_variable_value_invalid_format(director):
79+
"""Non-list JSON response raises ValueError (2.0 guard)."""
80+
with patch.object(
81+
director, "send_get_request", new=AsyncMock(return_value='{"value": 1}')
82+
):
83+
with pytest.raises(ValueError):
84+
await director.get_item_variable_value(100, "TEST")
85+
86+
87+
@pytest.mark.asyncio
88+
async def test_get_item_variable_value_list_var_name(director):
89+
"""List of var_names is joined with comma in the request URI."""
90+
response = json.dumps([{"id": 100, "varName": "A", "value": 1}])
91+
mock = AsyncMock(return_value=response)
92+
with patch.object(director, "send_get_request", new=mock):
93+
await director.get_item_variable_value(100, ["A", "B"])
94+
uri = mock.call_args[0][0]
95+
assert "varnames=A,B" in uri
96+
97+
98+
@pytest.mark.asyncio
99+
async def test_get_item_variable_value_tuple_var_name(director):
100+
"""Tuple of var_names is joined with comma in the request URI."""
101+
response = json.dumps([{"id": 100, "varName": "X", "value": 42}])
102+
mock = AsyncMock(return_value=response)
103+
with patch.object(director, "send_get_request", new=mock):
104+
await director.get_item_variable_value(100, ("X", "Y"))
105+
uri = mock.call_args[0][0]
106+
assert "varnames=X,Y" in uri
107+
108+
109+
@pytest.mark.asyncio
110+
async def test_get_all_item_variable_value_mixed(director):
111+
"""get_all_item_variable_value normalizes Undefined values in-place."""
112+
response = json.dumps(
113+
[
114+
{"id": 1, "varName": "HUMIDITY", "value": "Undefined"},
115+
{"id": 2, "varName": "HUMIDITY", "value": 45},
116+
{"id": 3, "varName": "HUMIDITY", "value": 0},
117+
]
118+
)
119+
with patch.object(
120+
director, "send_get_request", new=AsyncMock(return_value=response)
121+
):
122+
result = await director.get_all_item_variable_value("HUMIDITY")
123+
assert result[0]["value"] is None
124+
assert result[1]["value"] == 45
125+
assert result[2]["value"] == 0
126+
127+
128+
@pytest.mark.asyncio
129+
async def test_get_all_item_variable_value_empty(director):
130+
"""Empty list response raises ValueError."""
131+
with patch.object(director, "send_get_request", new=AsyncMock(return_value="[]")):
132+
with pytest.raises(ValueError):
133+
await director.get_all_item_variable_value("NONEXISTENT")
134+
135+
136+
@pytest.mark.asyncio
137+
async def test_get_all_item_info_returns_parsed(director):
138+
"""get_all_item_info returns parsed list (2.0 returns parsed JSON)."""
139+
raw = '[{"id": 1, "name": "Light"}]'
140+
with patch.object(director, "send_get_request", new=AsyncMock(return_value=raw)):
141+
result = await director.get_all_item_info()
142+
assert isinstance(result, list)
143+
assert result[0]["id"] == 1

tests/test_error_handling.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
"""Tests for error_handling.py — all branches of check_response_for_error."""
2+
3+
import json
4+
5+
import pytest
6+
7+
from pyControl4.error_handling import (
8+
BadCredentials,
9+
BadToken,
10+
C4Exception,
11+
InvalidCategory,
12+
NotFound,
13+
Unauthorized,
14+
check_response_for_error,
15+
)
16+
17+
# --- Happy paths (no exception raised) ---
18+
19+
20+
def test_json_no_error_keys():
21+
"""JSON response without error keys should not raise."""
22+
check_response_for_error(json.dumps({"result": "ok"}))
23+
24+
25+
def test_xml_no_error_keys():
26+
"""XML response without error keys should not raise."""
27+
check_response_for_error("<result>ok</result>")
28+
29+
30+
# --- C4ErrorResponse format ---
31+
32+
33+
def test_c4error_bad_credentials():
34+
"""C4ErrorResponse with matching details raises BadCredentials."""
35+
payload = json.dumps(
36+
{
37+
"C4ErrorResponse": {
38+
"code": 401,
39+
"details": "Permission denied Bad credentials",
40+
"message": "Permission denied",
41+
}
42+
}
43+
)
44+
with pytest.raises(BadCredentials):
45+
check_response_for_error(payload)
46+
47+
48+
def test_c4error_401_no_matching_details():
49+
"""C4ErrorResponse with code 401 but non-matching details raises Unauthorized."""
50+
payload = json.dumps(
51+
{
52+
"C4ErrorResponse": {
53+
"code": 401,
54+
"details": "",
55+
"message": "Permission denied",
56+
}
57+
}
58+
)
59+
with pytest.raises(Unauthorized):
60+
check_response_for_error(payload)
61+
62+
63+
def test_c4error_404():
64+
"""C4ErrorResponse with code 404 raises NotFound."""
65+
payload = json.dumps(
66+
{
67+
"C4ErrorResponse": {
68+
"code": 404,
69+
"message": "Not found",
70+
}
71+
}
72+
)
73+
with pytest.raises(NotFound):
74+
check_response_for_error(payload)
75+
76+
77+
def test_c4error_unknown_code_falls_back_to_base():
78+
"""C4ErrorResponse with unrecognized code raises exactly C4Exception (not a subclass)."""
79+
payload = json.dumps(
80+
{
81+
"C4ErrorResponse": {
82+
"code": 999,
83+
"message": "Unknown error",
84+
}
85+
}
86+
)
87+
with pytest.raises(C4Exception) as exc_info:
88+
check_response_for_error(payload)
89+
assert type(exc_info.value) is C4Exception
90+
91+
92+
# --- Flat JSON code format ---
93+
94+
95+
def test_flat_json_404():
96+
"""Flat JSON with code 404 raises NotFound."""
97+
payload = json.dumps({"code": 404, "message": "Account not found"})
98+
with pytest.raises(NotFound):
99+
check_response_for_error(payload)
100+
101+
102+
def test_flat_json_bad_credentials():
103+
"""Flat JSON with matching details raises BadCredentials (details take priority)."""
104+
payload = json.dumps(
105+
{
106+
"code": 401,
107+
"details": "Permission denied Bad credentials",
108+
"message": "Permission denied",
109+
}
110+
)
111+
with pytest.raises(BadCredentials):
112+
check_response_for_error(payload)
113+
114+
115+
# --- Director error format ---
116+
117+
118+
def test_director_error_bad_token():
119+
"""Director error with matching details raises BadToken."""
120+
payload = json.dumps(
121+
{"error": "Unauthorized", "details": "Expired or invalid token"}
122+
)
123+
with pytest.raises(BadToken):
124+
check_response_for_error(payload)
125+
126+
127+
def test_director_error_unauthorized_no_matching_details():
128+
"""Director 'Unauthorized' without matching details raises Unauthorized."""
129+
payload = json.dumps({"error": "Unauthorized"})
130+
with pytest.raises(Unauthorized):
131+
check_response_for_error(payload)
132+
133+
134+
def test_director_error_invalid_category():
135+
"""Director 'Invalid category' raises InvalidCategory."""
136+
payload = json.dumps({"error": "Invalid category"})
137+
with pytest.raises(InvalidCategory):
138+
check_response_for_error(payload)
139+
140+
141+
def test_director_error_unknown_falls_back_to_base():
142+
"""Director error with unrecognized string raises exactly C4Exception (not a subclass)."""
143+
payload = json.dumps({"error": "Something else"})
144+
with pytest.raises(C4Exception) as exc_info:
145+
check_response_for_error(payload)
146+
assert type(exc_info.value) is C4Exception
147+
148+
149+
# --- XML C4ErrorResponse ---
150+
151+
152+
def test_xml_c4error_401():
153+
"""XML C4ErrorResponse with code 401 raises Unauthorized."""
154+
xml = (
155+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
156+
"<C4ErrorResponse>"
157+
"<code>401</code>"
158+
"<details></details>"
159+
"<message>Permission denied</message>"
160+
"<subCode>0</subCode>"
161+
"</C4ErrorResponse>"
162+
)
163+
with pytest.raises(Unauthorized):
164+
check_response_for_error(xml)
165+
166+
167+
# --- Exception hierarchy and behavior ---
168+
169+
170+
def test_exception_hierarchy():
171+
"""Verify the exception inheritance chain."""
172+
assert issubclass(BadCredentials, Unauthorized)
173+
assert issubclass(BadToken, Unauthorized)
174+
assert issubclass(Unauthorized, C4Exception)
175+
assert issubclass(NotFound, C4Exception)
176+
assert issubclass(InvalidCategory, C4Exception)
177+
assert issubclass(C4Exception, Exception)
178+
179+
180+
def test_exception_stores_message():
181+
"""C4Exception stores the response text as .message."""
182+
exc = C4Exception("some response text")
183+
assert exc.message == "some response text"
184+
185+
186+
def test_raised_exception_preserves_response_text():
187+
"""Exception raised by check_response_for_error carries the original response."""
188+
payload = json.dumps({"error": "Invalid category"})
189+
with pytest.raises(InvalidCategory) as exc_info:
190+
check_response_for_error(payload)
191+
assert exc_info.value.message == payload

tests/test_undefined_handling.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,10 @@
55

66
import pytest
77

8-
from pyControl4.director import C4Director
98
from pyControl4.light import C4Light
109
from pyControl4.blind import C4Blind
1110

1211

13-
@pytest.fixture
14-
def director():
15-
"""Create a C4Director with a mocked session."""
16-
return C4Director("192.168.1.1", "test-token")
17-
18-
1912
@pytest.mark.asyncio
2013
async def test_get_item_variable_value_undefined(director):
2114
"""Test that get_item_variable_value normalizes 'Undefined' to None."""

0 commit comments

Comments
 (0)