carrier-api 3.6.2__tar.gz → 3.7.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.
Files changed (34) hide show
  1. {carrier_api-3.6.2/src/carrier_api.egg-info → carrier_api-3.7.0}/PKG-INFO +4 -1
  2. {carrier_api-3.6.2 → carrier_api-3.7.0}/README.md +3 -0
  3. {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/api_connection_graphql.py +187 -54
  4. {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/const.py +1 -1
  5. {carrier_api-3.6.2 → carrier_api-3.7.0/src/carrier_api.egg-info}/PKG-INFO +4 -1
  6. {carrier_api-3.6.2 → carrier_api-3.7.0}/tests/test_api_connection_graphql.py +399 -255
  7. {carrier_api-3.6.2 → carrier_api-3.7.0}/LICENSE +0 -0
  8. {carrier_api-3.6.2 → carrier_api-3.7.0}/pyproject.toml +0 -0
  9. {carrier_api-3.6.2 → carrier_api-3.7.0}/setup.cfg +0 -0
  10. {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/__init__.py +0 -0
  11. {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/api_websocket.py +0 -0
  12. {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/api_websocket_data_updater.py +0 -0
  13. {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/config.py +0 -0
  14. {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/energy.py +0 -0
  15. {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/entry_level.py +0 -0
  16. {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/errors.py +0 -0
  17. {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/live_smoke_test.py +0 -0
  18. {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/profile.py +0 -0
  19. {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/py.typed +0 -0
  20. {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/status.py +0 -0
  21. {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/system.py +0 -0
  22. {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/util.py +0 -0
  23. {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api.egg-info/SOURCES.txt +0 -0
  24. {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api.egg-info/dependency_links.txt +0 -0
  25. {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api.egg-info/requires.txt +0 -0
  26. {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api.egg-info/top_level.txt +0 -0
  27. {carrier_api-3.6.2 → carrier_api-3.7.0}/tests/test_api_websocket.py +0 -0
  28. {carrier_api-3.6.2 → carrier_api-3.7.0}/tests/test_config.py +0 -0
  29. {carrier_api-3.6.2 → carrier_api-3.7.0}/tests/test_entry_level.py +0 -0
  30. {carrier_api-3.6.2 → carrier_api-3.7.0}/tests/test_fixture_contracts.py +0 -0
  31. {carrier_api-3.6.2 → carrier_api-3.7.0}/tests/test_live_smoke_test.py +0 -0
  32. {carrier_api-3.6.2 → carrier_api-3.7.0}/tests/test_models.py +0 -0
  33. {carrier_api-3.6.2 → carrier_api-3.7.0}/tests/test_websocket_data_updater.py +0 -0
  34. {carrier_api-3.6.2 → carrier_api-3.7.0}/tests/test_workflows.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: carrier_api
3
- Version: 3.6.2
3
+ Version: 3.7.0
4
4
  Summary: Carrier Api Wrapper
5
5
  Author-email: Bren Dahl <dahl.brendan@gmail.com>
6
6
  License-Expression: MIT
@@ -179,6 +179,9 @@ Available update helpers include:
179
179
  - `set_config_hold(...)`
180
180
  - `resume_schedule(...)`
181
181
  - `set_config_manual_activity(...)`
182
+ - `set_program_day(...)`
183
+
184
+ `set_program_day(...)` replaces one day of a zone's program (`updateInfinityProgramDay`). Send every period of the day, enabled or not, with the `etag` the systems query returns (`load_data()` now reads the program's and each day's `etag`). It has not yet been verified against a live system.
182
185
 
183
186
  These methods can change real HVAC settings. Validate the selected system, zone, mode, and set points before calling them from automation.
184
187
 
@@ -155,6 +155,9 @@ Available update helpers include:
155
155
  - `set_config_hold(...)`
156
156
  - `resume_schedule(...)`
157
157
  - `set_config_manual_activity(...)`
158
+ - `set_program_day(...)`
159
+
160
+ `set_program_day(...)` replaces one day of a zone's program (`updateInfinityProgramDay`). Send every period of the day, enabled or not, with the `etag` the systems query returns (`load_data()` now reads the program's and each day's `etag`). It has not yet been verified against a live system.
158
161
 
159
162
  These methods can change real HVAC settings. Validate the selected system, zone, mode, and set points before calling them from automation.
160
163
 
@@ -1,10 +1,11 @@
1
1
  """GraphQL client for Carrier authentication, queries, and config updates."""
2
2
 
3
+ from asyncio import Lock
3
4
  from datetime import UTC, datetime, timedelta
4
5
  from logging import getLogger
5
6
  from typing import Any, Literal
6
7
 
7
- from aiohttp import ClientError, ClientResponseError, ClientSession
8
+ from aiohttp import ClientError, ClientSession
8
9
  from gql import Client, GraphQLRequest, gql
9
10
  from gql.transport.aiohttp import AIOHTTPTransport
10
11
  from gql.transport.exceptions import (
@@ -34,6 +35,24 @@ GRAPHQL_EXECUTE_TIMEOUT_SECONDS = 60
34
35
 
35
36
  _CONNECTION_ERRORS = (GraphqlTransportError, ClientError, TimeoutError, OSError)
36
37
  _AUTH_HTTP_STATUSES = {401, 403}
38
+ GRAPHQL_NO_AUTH_URL = "https://dataservice.infinity.iot.carrier.com/graphql-no-auth"
39
+ FETCH_ACCESS_TOKEN_MUTATION = gql(
40
+ """
41
+ mutation fetchAccessToken($input: FetchAccessTokenInput!) {
42
+ fetchAccessToken(input: $input) {
43
+ success
44
+ errorMessage
45
+ data {
46
+ token_type
47
+ expires_in
48
+ access_token
49
+ scope
50
+ refresh_token
51
+ }
52
+ }
53
+ }
54
+ """
55
+ )
37
56
 
38
57
 
39
58
  def _is_auth_transport_error(error: BaseException) -> bool:
@@ -49,25 +68,6 @@ def _is_auth_transport_error(error: BaseException) -> bool:
49
68
  return isinstance(error, TransportServerError) and error.code in _AUTH_HTTP_STATUSES
50
69
 
51
70
 
52
- async def _response_json_object(response: Any) -> dict[str, Any]:
53
- """Read a JSON object response body when available.
54
-
55
- Args:
56
- response: aiohttp-like response object with an async ``json`` method.
57
-
58
- Returns:
59
- The decoded JSON object, or an empty object when the body is malformed
60
- or is not a JSON object.
61
- """
62
- try:
63
- data = await response.json()
64
- except ClientError, TimeoutError, OSError, TypeError, ValueError:
65
- return {}
66
- if not isinstance(data, dict):
67
- return {}
68
- return data
69
-
70
-
71
71
  class ApiConnectionGraphql:
72
72
  """Async Carrier GraphQL API connection with token and websocket support."""
73
73
 
@@ -93,6 +93,7 @@ class ApiConnectionGraphql:
93
93
  """
94
94
  self.username = username
95
95
  self.password = password
96
+ self._auth_lock = Lock()
96
97
  if client_session is None:
97
98
  self.api_session = ClientSession(raise_for_status=False)
98
99
  else:
@@ -112,9 +113,7 @@ class ApiConnectionGraphql:
112
113
  CarrierApiAuthError: If the assisted login mutation reports an unsuccessful
113
114
  authentication result.
114
115
  """
115
- transport = AIOHTTPTransport(
116
- url="https://dataservice.infinity.iot.carrier.com/graphql-no-auth", ssl=True
117
- )
116
+ transport = AIOHTTPTransport(url=GRAPHQL_NO_AUTH_URL, ssl=True)
118
117
  try:
119
118
  async with Client(
120
119
  transport=transport,
@@ -154,6 +153,10 @@ class ApiConnectionGraphql:
154
153
  self.token_type = result["assistedLogin"]["data"]["token_type"]
155
154
  self.access_token = result["assistedLogin"]["data"]["access_token"]
156
155
  self.refresh_token = result["assistedLogin"]["data"]["refresh_token"]
156
+ _LOGGER.debug(
157
+ "Carrier login succeeded; access token expires at %s",
158
+ self.expires_at.isoformat(timespec="seconds"),
159
+ )
157
160
  if self.api_websocket is None:
158
161
  self.api_websocket = ApiWebsocket(self)
159
162
  else:
@@ -171,49 +174,90 @@ class ApiConnectionGraphql:
171
174
  raise CarrierApiGraphqlError("Carrier authentication GraphQL request failed") from error
172
175
 
173
176
  async def check_auth_expiration(self) -> None:
174
- """Ensure the connection has a valid access token before API use."""
175
- if self.refresh_token is None:
176
- await self.login()
177
- if self.expires_at < datetime.now(UTC):
178
- await self.refresh_auth_token()
177
+ """Ensure the connection has a valid access token before API use.
178
+
179
+ Checks are serialized so concurrent callers never send the same
180
+ refresh token twice. When Carrier rejects the refresh token, the
181
+ connection falls back to one password login. Transient refresh
182
+ failures keep the stored token for the next attempt.
183
+
184
+ Raises:
185
+ CarrierApiAuthError: If the fallback password login is rejected.
186
+ CarrierApiTokenRefreshError: If token refresh fails transiently.
187
+ """
188
+ async with self._auth_lock:
189
+ if self.refresh_token is None:
190
+ await self.login()
191
+ if self.expires_at < datetime.now(UTC):
192
+ try:
193
+ await self.refresh_auth_token()
194
+ except CarrierApiAuthError:
195
+ _LOGGER.info("Carrier refresh token was rejected; logging in again")
196
+ self.refresh_token = None
197
+ await self.login()
179
198
 
180
199
  async def refresh_auth_token(self) -> None:
181
- """Refresh the OAuth access token using the stored refresh token.
200
+ """Refresh the access token through Carrier's ``fetchAccessToken`` mutation.
201
+
202
+ Carrier accounts may get tokens from Okta or from Microsoft Entra
203
+ External ID. Carrier's GraphQL service forwards the refresh to
204
+ whichever identity provider issued the account's tokens, so the
205
+ library does not need to know which one an account uses.
182
206
 
183
207
  Raises:
184
- CarrierApiAuthError: If Carrier rejects the refresh token as invalid
185
- or unauthorized.
186
- CarrierApiTokenRefreshError: If token refresh fails before Carrier
187
- returns a valid OAuth response.
208
+ CarrierApiAuthError: If Carrier rejects the refresh token.
209
+ CarrierApiTokenRefreshError: If the refresh request fails or
210
+ returns a malformed token payload.
188
211
  """
189
- url = "https://sso.carrier.com/oauth2/default/v1/token"
190
- json_body = {
191
- "client_id": "0oa1ce7hwjuZbfOMB4x7",
192
- "grant_type": "refresh_token",
193
- "refresh_token": self.refresh_token,
194
- "scope": "offline_access",
195
- }
196
- response: Any | None = None
212
+ transport = AIOHTTPTransport(url=GRAPHQL_NO_AUTH_URL, ssl=True)
197
213
  try:
198
- response = await self.api_session.post(url=url, data=json_body)
199
- response.raise_for_status()
200
- data = await response.json()
201
- except ClientResponseError as error:
202
- data = {} if response is None else await _response_json_object(response)
203
- if error.status in {401, 403} or (
204
- error.status == 400 and data.get("error") == "invalid_grant"
205
- ):
214
+ async with Client(
215
+ transport=transport,
216
+ fetch_schema_from_transport=False,
217
+ ) as session:
218
+ result = await session.execute(
219
+ FETCH_ACCESS_TOKEN_MUTATION,
220
+ variable_values={
221
+ "input": {"username": self.username, "refreshToken": self.refresh_token}
222
+ },
223
+ operation_name="fetchAccessToken",
224
+ )
225
+ except TransportQueryError as error:
226
+ raise CarrierApiTokenRefreshError("Carrier token refresh failed") from error
227
+ except _CONNECTION_ERRORS as error:
228
+ if _is_auth_transport_error(error):
206
229
  raise CarrierApiAuthError("Carrier token refresh was rejected") from error
207
230
  raise CarrierApiTokenRefreshError("Carrier token refresh failed") from error
208
- except (ClientError, TimeoutError, OSError, TypeError, ValueError) as error:
231
+ except GraphQLError as error:
209
232
  raise CarrierApiTokenRefreshError("Carrier token refresh failed") from error
233
+
234
+ response = result.get("fetchAccessToken") if isinstance(result, dict) else None
235
+ if not isinstance(response, dict):
236
+ raise CarrierApiTokenRefreshError("Carrier token refresh failed")
237
+ if not response.get("success"):
238
+ _LOGGER.debug("Carrier token refresh was rejected: %s", response.get("errorMessage"))
239
+ raise CarrierApiAuthError("Carrier token refresh was rejected", payload=response)
240
+
241
+ data = response.get("data")
242
+ if not isinstance(data, dict):
243
+ raise CarrierApiTokenRefreshError("Carrier token refresh failed")
244
+ previous_refresh_token = self.refresh_token
210
245
  try:
211
- self.expires_at = datetime.now(UTC) + timedelta(seconds=data["expires_in"])
212
- self.token_type = data["token_type"]
213
- self.access_token = data["access_token"]
214
- self.refresh_token = data["refresh_token"]
246
+ expires_at = datetime.now(UTC) + timedelta(seconds=data["expires_in"])
247
+ token_type = data["token_type"]
248
+ access_token = data["access_token"]
215
249
  except (KeyError, TypeError) as error:
216
250
  raise CarrierApiTokenRefreshError("Carrier token refresh failed") from error
251
+ self.expires_at = expires_at
252
+ self.token_type = token_type
253
+ self.access_token = access_token
254
+ # Okta keeps the refresh token; Entra rotates it.
255
+ self.refresh_token = data.get("refresh_token") or previous_refresh_token
256
+ _LOGGER.debug(
257
+ "Carrier token refresh succeeded; access token expires at %s, refresh token %s",
258
+ self.expires_at.isoformat(timespec="seconds"),
259
+ "unchanged" if self.refresh_token == previous_refresh_token else "rotated",
260
+ )
217
261
 
218
262
  async def authed_query(
219
263
  self, operation_name: str, query: GraphQLRequest, variable_values: dict[str, Any]
@@ -434,8 +478,10 @@ class ApiConnectionGraphql:
434
478
  occEnabled
435
479
  program {
436
480
  id
481
+ etag
437
482
  day {
438
483
  id
484
+ etag
439
485
  zoneId
440
486
  period {
441
487
  id
@@ -811,6 +857,42 @@ class ApiConnectionGraphql:
811
857
  _LOGGER.warning("No API websocket connection")
812
858
  return response
813
859
 
860
+ async def _update_infinity_program_day(self, variables: dict[str, Any]) -> dict[str, Any]:
861
+ """Run the Carrier zone program day mutation.
862
+
863
+ Args:
864
+ variables: GraphQL variables containing an ``InfinityProgramDayInput``.
865
+
866
+ Returns:
867
+ The decoded mutation response.
868
+ """
869
+ query = gql(
870
+ """
871
+ mutation updateInfinityProgramDay($input: InfinityProgramDayInput!) {
872
+ updateInfinityProgramDay(input: $input) {
873
+ id
874
+ etag
875
+ zoneId
876
+ period {
877
+ id
878
+ activity
879
+ time
880
+ enabled
881
+ }
882
+ }
883
+ }
884
+ """
885
+ )
886
+ _LOGGER.debug("updateInfinityProgramDay: %s", variables)
887
+ response = await self.authed_query(
888
+ operation_name="updateInfinityProgramDay", query=query, variable_values=variables
889
+ )
890
+ if self.api_websocket is not None:
891
+ await self.api_websocket.send_reconcile()
892
+ else:
893
+ _LOGGER.warning("No API websocket connection")
894
+ return response
895
+
814
896
  async def set_config_mode(self, system_serial: str, mode: SystemModes) -> dict[str, Any]:
815
897
  """Update a Carrier system's operating mode.
816
898
 
@@ -1084,3 +1166,54 @@ class ApiConnectionGraphql:
1084
1166
  cool_set_point=cool_set_point,
1085
1167
  fan_mode=fan_mode,
1086
1168
  )
1169
+
1170
+ async def set_program_day(
1171
+ self,
1172
+ system_serial: str,
1173
+ zone_id: str,
1174
+ day_id: str,
1175
+ periods: list[dict[str, str]],
1176
+ etag: str,
1177
+ ) -> dict[str, Any]:
1178
+ """Replace one day of a zone's program.
1179
+
1180
+ The Carrier replaces the day with the periods sent, so pass every period
1181
+ of the day, enabled or not, as the systems query returns them.
1182
+
1183
+ Args:
1184
+ system_serial: Serial number of the system to update.
1185
+ zone_id: Carrier zone identifier.
1186
+ day_id: Carrier day identifier as the program returns it (``"0"`` to
1187
+ ``"6"``).
1188
+ periods: The day's periods, each with ``id`` and ``activity`` and
1189
+ optionally ``time`` (``HH:MM``) and ``enabled`` (``on``/``off``).
1190
+ etag: Etag read from the systems query, which the mutation requires.
1191
+
1192
+ Returns:
1193
+ The decoded mutation response.
1194
+
1195
+ Raises:
1196
+ ValueError: If a period is missing ``id`` or ``activity``, has an
1197
+ unknown key, or names an activity that is not a valid
1198
+ ``ActivityTypes`` value.
1199
+ """
1200
+ activities = {activity.value for activity in ActivityTypes}
1201
+ allowed_keys = {"id", "activity", "time", "enabled"}
1202
+ for period in periods:
1203
+ unknown_keys = set(period) - allowed_keys
1204
+ if unknown_keys:
1205
+ raise ValueError(f"unknown program period keys: {sorted(unknown_keys)}")
1206
+ if not period.get("id"):
1207
+ raise ValueError("program period is missing its id")
1208
+ if period.get("activity") not in activities:
1209
+ raise ValueError(f"{period.get('activity')} is not a valid activity type")
1210
+ variables = {
1211
+ "input": {
1212
+ "serial": system_serial,
1213
+ "etag": etag,
1214
+ "zoneId": zone_id,
1215
+ "dayId": day_id,
1216
+ "period": [dict(period) for period in periods],
1217
+ }
1218
+ }
1219
+ return await self._update_infinity_program_day(variables=variables)
@@ -2,7 +2,7 @@
2
2
 
3
3
  from enum import Enum
4
4
 
5
- VERSION: str = "3.6.2"
5
+ VERSION: str = "3.7.0"
6
6
 
7
7
 
8
8
  class SystemModes(Enum):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: carrier_api
3
- Version: 3.6.2
3
+ Version: 3.7.0
4
4
  Summary: Carrier Api Wrapper
5
5
  Author-email: Bren Dahl <dahl.brendan@gmail.com>
6
6
  License-Expression: MIT
@@ -179,6 +179,9 @@ Available update helpers include:
179
179
  - `set_config_hold(...)`
180
180
  - `resume_schedule(...)`
181
181
  - `set_config_manual_activity(...)`
182
+ - `set_program_day(...)`
183
+
184
+ `set_program_day(...)` replaces one day of a zone's program (`updateInfinityProgramDay`). Send every period of the day, enabled or not, with the `etag` the systems query returns (`load_data()` now reads the program's and each day's `etag`). It has not yet been verified against a live system.
182
185
 
183
186
  These methods can change real HVAC settings. Validate the selected system, zone, mode, and set points before calling them from automation.
184
187
 
@@ -1,9 +1,10 @@
1
1
  """Tests for Carrier GraphQL API connection helpers."""
2
2
 
3
+ import asyncio
3
4
  from datetime import UTC, datetime, timedelta
4
- from typing import Any, Self, cast
5
+ from typing import Any, ClassVar, Self, cast
5
6
 
6
- from aiohttp import ClientConnectionError, ClientError, ClientResponseError, ClientSession
7
+ from aiohttp import ClientConnectionError, ClientError, ClientSession
7
8
  from gql import GraphQLRequest, gql
8
9
  from gql.transport.exceptions import TransportQueryError, TransportServerError
9
10
  import pytest
@@ -15,86 +16,17 @@ from carrier_api.const import ActivityTypes, FanModes, HeatSourceTypes, SystemMo
15
16
  from carrier_api.system import System
16
17
 
17
18
 
18
- class FakeResponse:
19
- """Minimal aiohttp response double for token refresh tests."""
20
-
21
- def __init__(
22
- self,
23
- payload: object,
24
- status_error: ClientResponseError | None = None,
25
- json_error: BaseException | None = None,
26
- ) -> None:
27
- """Initialize the fake response with JSON payload data.
28
-
29
- Args:
30
- payload: Data returned from ``json``.
31
- status_error: Optional error raised by ``raise_for_status``.
32
- json_error: Optional error raised by ``json``.
33
- """
34
- self.payload = payload
35
- self.status_error = status_error
36
- self.json_error = json_error
37
- self.raise_for_status_called = False
38
-
39
- def raise_for_status(self) -> None:
40
- """Record status validation and raise the configured status error.
41
-
42
- Raises:
43
- ClientResponseError: When ``status_error`` is configured.
44
- """
45
- self.raise_for_status_called = True
46
- if self.status_error is not None:
47
- raise self.status_error
48
-
49
- async def json(self) -> object:
50
- """Return the configured JSON payload.
51
-
52
- Returns:
53
- The fake response payload.
54
-
55
- Raises:
56
- BaseException: When ``json_error`` is configured.
57
- """
58
- if self.json_error is not None:
59
- raise self.json_error
60
- return self.payload
61
-
62
-
63
19
  class FakeSession:
64
20
  """Minimal session double used by connection tests."""
65
21
 
66
22
  def __init__(self) -> None:
67
23
  """Initialize captured session state."""
68
24
  self.closed = False
69
- self.post_url: str | None = None
70
- self.post_data: dict[str, Any] | None = None
71
- self.response = FakeResponse(
72
- {
73
- "expires_in": 3600,
74
- "token_type": "Bearer",
75
- "access_token": "new-access",
76
- "refresh_token": "new-refresh",
77
- }
78
- )
79
25
 
80
26
  async def close(self) -> None:
81
27
  """Record that the session was closed."""
82
28
  self.closed = True
83
29
 
84
- async def post(self, url: str, data: dict[str, Any]) -> FakeResponse:
85
- """Capture a token refresh request.
86
-
87
- Args:
88
- url: Requested URL.
89
- data: Submitted form data.
90
-
91
- Returns:
92
- The fake token refresh response.
93
- """
94
- self.post_url = url
95
- self.post_data = data
96
- return self.response
97
-
98
30
 
99
31
  class FakeGraphQLClient:
100
32
  """Minimal gql client double that captures constructor arguments."""
@@ -436,254 +368,256 @@ async def test_cleanup_wraps_session_close_errors(
436
368
  assert error.value.__cause__ is cleanup_error
437
369
 
438
370
 
439
- @pytest.mark.asyncio
440
- async def test_refresh_auth_token_updates_token_state() -> None:
441
- """Refresh auth tokens from the OAuth response payload."""
442
- session = FakeSession()
443
- connection = ApiConnectionGraphql(
444
- username="user@example.com",
445
- password="password",
446
- client_session=cast("ClientSession", session),
447
- )
448
- connection.refresh_token = "old-refresh"
371
+ class FetchAccessTokenClient:
372
+ """GraphQL client double for ``fetchAccessToken`` refresh calls."""
449
373
 
450
- await connection.refresh_auth_token()
374
+ calls: ClassVar[list[dict[str, Any]]] = []
375
+ result: ClassVar[object] = None
376
+ error: ClassVar[BaseException | None] = None
451
377
 
452
- assert session.response.raise_for_status_called
453
- assert session.post_url == "https://sso.carrier.com/oauth2/default/v1/token"
454
- assert session.post_data == {
455
- "client_id": "0oa1ce7hwjuZbfOMB4x7",
456
- "grant_type": "refresh_token",
457
- "refresh_token": "old-refresh",
458
- "scope": "offline_access",
459
- }
460
- assert connection.token_type == "Bearer"
461
- assert connection.access_token == "new-access"
462
- assert connection.refresh_token == "new-refresh"
463
- assert connection.expires_at > datetime.now(UTC)
378
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
379
+ """Accept GraphQL client construction arguments."""
464
380
 
381
+ async def __aenter__(self) -> Self:
382
+ """Return the fake session.
465
383
 
466
- @pytest.mark.asyncio
467
- @pytest.mark.parametrize(
468
- "refresh_error",
469
- [
470
- ClientConnectionError("token refresh connection failed"),
471
- TimeoutError("token refresh timed out"),
472
- OSError("token refresh socket failed"),
473
- ],
474
- )
475
- async def test_refresh_auth_token_wraps_refresh_failures(
476
- refresh_error: ClientError | TimeoutError | OSError,
477
- ) -> None:
478
- """Raise Carrier token refresh errors instead of raw HTTP exceptions."""
384
+ Returns:
385
+ The fake GraphQL session.
386
+ """
387
+ return self
479
388
 
480
- class FailingSession(FakeSession):
481
- """Session double that fails token refresh requests."""
389
+ async def __aexit__(self, *args: object) -> None:
390
+ """Exit the fake session context.
482
391
 
483
- async def post(self, url: str, data: dict[str, Any]) -> FakeResponse:
484
- """Raise the configured refresh error.
392
+ Args:
393
+ args: Context manager exception details.
394
+ """
485
395
 
486
- Args:
487
- url: Requested URL.
488
- data: Submitted form data.
396
+ async def execute(
397
+ self,
398
+ query: GraphQLRequest,
399
+ *,
400
+ variable_values: dict[str, Any],
401
+ operation_name: str,
402
+ ) -> object:
403
+ """Record the refresh call and return or raise the configured outcome.
489
404
 
490
- Raises:
491
- ClientError | TimeoutError | OSError: Always raised for this
492
- failing session.
493
- """
494
- raise refresh_error
405
+ Args:
406
+ query: GraphQL request.
407
+ variable_values: Variables supplied to the mutation.
408
+ operation_name: GraphQL operation name.
495
409
 
496
- connection = ApiConnectionGraphql(
497
- username="user@example.com",
498
- password="password",
499
- client_session=cast("ClientSession", FailingSession()),
500
- )
501
- connection.refresh_token = "old-refresh"
410
+ Returns:
411
+ The configured GraphQL result.
502
412
 
503
- with pytest.raises(errors.CarrierApiTokenRefreshError) as error:
504
- await connection.refresh_auth_token()
413
+ Raises:
414
+ BaseException: When ``error`` is configured.
415
+ """
416
+ FetchAccessTokenClient.calls.append(
417
+ {"operation_name": operation_name, "variables": variable_values}
418
+ )
419
+ if FetchAccessTokenClient.error is not None:
420
+ raise FetchAccessTokenClient.error
421
+ return FetchAccessTokenClient.result
505
422
 
506
- assert error.value.__cause__ is refresh_error
507
423
 
424
+ @pytest.fixture
425
+ def fetch_access_token(monkeypatch: pytest.MonkeyPatch) -> type[FetchAccessTokenClient]:
426
+ """Route GraphQL clients to the ``fetchAccessToken`` double.
508
427
 
509
- @pytest.mark.asyncio
510
- @pytest.mark.parametrize(
511
- ("status", "payload"),
512
- [(401, {}), (400, {"error": "invalid_grant"})],
513
- )
514
- async def test_refresh_auth_token_treats_auth_rejections_as_auth_error(
515
- status: int,
516
- payload: object,
517
- ) -> None:
518
- """Raise auth errors for rejected token refresh responses.
428
+ Args:
429
+ monkeypatch: Pytest helper for replacing the GraphQL client.
430
+
431
+ Returns:
432
+ The configured client double class.
433
+ """
434
+ FetchAccessTokenClient.calls = []
435
+ FetchAccessTokenClient.result = None
436
+ FetchAccessTokenClient.error = None
437
+ monkeypatch.setattr("carrier_api.api_connection_graphql.Client", FetchAccessTokenClient)
438
+ return FetchAccessTokenClient
439
+
440
+
441
+ def _fetch_result(**data: object) -> dict[str, Any]:
442
+ """Build a successful ``fetchAccessToken`` result.
519
443
 
520
444
  Args:
521
- status: HTTP status returned by the fake token endpoint.
522
- payload: JSON payload returned by the fake token endpoint.
445
+ data: Token fields overriding the defaults.
446
+
447
+ Returns:
448
+ A GraphQL result payload.
523
449
  """
524
- refresh_error = ClientResponseError(
525
- request_info=None, # type: ignore[arg-type]
526
- history=(),
527
- status=status,
528
- message="token rejected",
529
- )
450
+ token_data: dict[str, object] = {
451
+ "token_type": "Bearer",
452
+ "expires_in": 3600,
453
+ "access_token": "new-access",
454
+ "scope": "login",
455
+ "refresh_token": "new-refresh",
456
+ **data,
457
+ }
458
+ return {"fetchAccessToken": {"success": True, "errorMessage": None, "data": token_data}}
530
459
 
531
- session = FakeSession()
532
- session.response = FakeResponse(payload, status_error=refresh_error)
460
+
461
+ def _refresh_connection() -> ApiConnectionGraphql:
462
+ """Build a connection holding a stored refresh token.
463
+
464
+ Returns:
465
+ A connection ready for ``refresh_auth_token``.
466
+ """
533
467
  connection = ApiConnectionGraphql(
534
468
  username="user@example.com",
535
469
  password="password",
536
- client_session=cast("ClientSession", session),
470
+ client_session=cast("ClientSession", FakeSession()),
537
471
  )
538
472
  connection.refresh_token = "old-refresh"
473
+ return connection
539
474
 
540
- with pytest.raises(errors.CarrierApiAuthError) as error:
541
- await connection.refresh_auth_token()
542
475
 
543
- assert error.value.__cause__ is refresh_error
476
+ @pytest.mark.asyncio
477
+ async def test_refresh_auth_token_uses_fetch_access_token(
478
+ fetch_access_token: type[FetchAccessTokenClient],
479
+ caplog: pytest.LogCaptureFixture,
480
+ ) -> None:
481
+ """Refresh through Carrier's mutation and store the rotated token state.
482
+
483
+ Args:
484
+ fetch_access_token: GraphQL client double for the refresh mutation.
485
+ caplog: Pytest log capture fixture.
486
+ """
487
+ caplog.set_level("DEBUG", logger="carrier_api.api_connection_graphql")
488
+ fetch_access_token.result = _fetch_result()
489
+ connection = _refresh_connection()
490
+
491
+ await connection.refresh_auth_token()
492
+
493
+ assert fetch_access_token.calls == [
494
+ {
495
+ "operation_name": "fetchAccessToken",
496
+ "variables": {"input": {"username": "user@example.com", "refreshToken": "old-refresh"}},
497
+ }
498
+ ]
499
+ assert connection.token_type == "Bearer"
500
+ assert connection.access_token == "new-access"
501
+ assert connection.refresh_token == "new-refresh"
502
+ assert connection.expires_at > datetime.now(UTC)
503
+ assert "refresh token rotated" in caplog.text
504
+ assert "new-refresh" not in caplog.text
505
+ assert "new-access" not in caplog.text
544
506
 
545
507
 
546
508
  @pytest.mark.asyncio
547
- @pytest.mark.parametrize(
548
- "json_error",
549
- [
550
- TimeoutError("refresh error body timed out"),
551
- OSError("refresh error body socket failed"),
552
- ],
553
- )
554
- async def test_refresh_auth_token_normalizes_unreadable_error_payloads(
555
- json_error: TimeoutError | OSError,
509
+ @pytest.mark.parametrize("refresh_token", ["old-refresh", None])
510
+ async def test_refresh_auth_token_keeps_unrotated_refresh_token(
511
+ fetch_access_token: type[FetchAccessTokenClient],
512
+ caplog: pytest.LogCaptureFixture,
513
+ refresh_token: str | None,
556
514
  ) -> None:
557
- """Raise Carrier errors when refresh error payload reads fail.
515
+ """Keep the stored refresh token when the provider does not rotate it.
558
516
 
559
517
  Args:
560
- json_error: Error raised while reading the refresh error response body.
518
+ fetch_access_token: GraphQL client double for the refresh mutation.
519
+ caplog: Pytest log capture fixture.
520
+ refresh_token: Refresh token returned by the mutation.
561
521
  """
562
- refresh_error = ClientResponseError(
563
- request_info=None, # type: ignore[arg-type]
564
- history=(),
565
- status=401,
566
- message="unauthorized",
567
- )
568
-
569
- session = FakeSession()
570
- session.response = FakeResponse(
571
- {},
572
- status_error=refresh_error,
573
- json_error=json_error,
574
- )
575
- connection = ApiConnectionGraphql(
576
- username="user@example.com",
577
- password="password",
578
- client_session=cast("ClientSession", session),
579
- )
580
- connection.refresh_token = "old-refresh"
522
+ caplog.set_level("DEBUG", logger="carrier_api.api_connection_graphql")
523
+ fetch_access_token.result = _fetch_result(refresh_token=refresh_token)
524
+ connection = _refresh_connection()
581
525
 
582
- with pytest.raises(errors.CarrierApiAuthError) as error:
583
- await connection.refresh_auth_token()
526
+ await connection.refresh_auth_token()
584
527
 
585
- assert error.value.__cause__ is refresh_error
528
+ assert connection.refresh_token == "old-refresh"
529
+ assert connection.access_token == "new-access"
530
+ assert "refresh token unchanged" in caplog.text
586
531
 
587
532
 
588
533
  @pytest.mark.asyncio
589
- @pytest.mark.parametrize("payload", [None, ["invalid_grant"], "invalid_grant"])
590
- async def test_refresh_auth_token_ignores_non_object_error_payloads(
591
- payload: object,
534
+ async def test_refresh_auth_token_treats_unsuccessful_result_as_auth_error(
535
+ fetch_access_token: type[FetchAccessTokenClient],
592
536
  ) -> None:
593
- """Keep non-object refresh error payloads in the refresh-failure bucket.
537
+ """Raise auth errors when Carrier reports the refresh as unsuccessful.
594
538
 
595
539
  Args:
596
- payload: Non-object JSON payload returned by the fake response.
540
+ fetch_access_token: GraphQL client double for the refresh mutation.
597
541
  """
598
- refresh_error = ClientResponseError(
599
- request_info=None, # type: ignore[arg-type]
600
- history=(),
601
- status=400,
602
- message="bad request",
603
- )
604
-
605
- session = FakeSession()
606
- session.response = FakeResponse(payload, status_error=refresh_error)
607
- connection = ApiConnectionGraphql(
608
- username="user@example.com",
609
- password="password",
610
- client_session=cast("ClientSession", session),
611
- )
612
- connection.refresh_token = "old-refresh"
542
+ rejection = {
543
+ "success": False,
544
+ "errorMessage": "AADSTS9002313: Invalid request.",
545
+ "data": None,
546
+ }
547
+ fetch_access_token.result = {"fetchAccessToken": rejection}
548
+ connection = _refresh_connection()
613
549
 
614
- with pytest.raises(errors.CarrierApiTokenRefreshError) as error:
550
+ with pytest.raises(errors.CarrierApiAuthError) as error:
615
551
  await connection.refresh_auth_token()
616
552
 
617
- assert error.value.__cause__ is refresh_error
553
+ assert error.value.payload == rejection
554
+ assert connection.refresh_token == "old-refresh"
618
555
 
619
556
 
620
557
  @pytest.mark.asyncio
621
- async def test_refresh_auth_token_normalizes_malformed_error_payload() -> None:
622
- """Keep malformed refresh error payloads normalized as Carrier errors."""
623
- refresh_error = ClientResponseError(
624
- request_info=None, # type: ignore[arg-type]
625
- history=(),
626
- status=400,
627
- message="bad request",
628
- )
558
+ @pytest.mark.parametrize(
559
+ ("refresh_error", "expected"),
560
+ [
561
+ (TransportQueryError("refresh failed"), errors.CarrierApiTokenRefreshError),
562
+ (ClientConnectionError("connection failed"), errors.CarrierApiTokenRefreshError),
563
+ (TimeoutError("timed out"), errors.CarrierApiTokenRefreshError),
564
+ (OSError("socket failed"), errors.CarrierApiTokenRefreshError),
565
+ (TransportServerError("server error", 500), errors.CarrierApiTokenRefreshError),
566
+ (TransportServerError("unauthorized", 401), errors.CarrierApiAuthError),
567
+ ],
568
+ )
569
+ async def test_refresh_auth_token_normalizes_request_failures(
570
+ fetch_access_token: type[FetchAccessTokenClient],
571
+ refresh_error: BaseException,
572
+ expected: type[errors.CarrierApiError],
573
+ ) -> None:
574
+ """Map refresh request failures to Carrier errors.
629
575
 
630
- session = FakeSession()
631
- session.response = FakeResponse(
632
- {},
633
- status_error=refresh_error,
634
- json_error=ValueError("invalid json"),
635
- )
636
- connection = ApiConnectionGraphql(
637
- username="user@example.com",
638
- password="password",
639
- client_session=cast("ClientSession", session),
640
- )
641
- connection.refresh_token = "old-refresh"
576
+ Args:
577
+ fetch_access_token: GraphQL client double for the refresh mutation.
578
+ refresh_error: Error raised by the GraphQL client.
579
+ expected: Carrier error expected from the refresh.
580
+ """
581
+ fetch_access_token.error = refresh_error
582
+ connection = _refresh_connection()
642
583
 
643
- with pytest.raises(errors.CarrierApiTokenRefreshError) as error:
584
+ with pytest.raises(expected) as error:
644
585
  await connection.refresh_auth_token()
645
586
 
587
+ assert type(error.value) is expected
646
588
  assert error.value.__cause__ is refresh_error
647
589
 
648
590
 
649
591
  @pytest.mark.asyncio
650
592
  @pytest.mark.parametrize(
651
- ("payload", "cause_type"),
593
+ "result",
652
594
  [
653
- (
654
- {
655
- "expires_in": 3600,
656
- "token_type": "Bearer",
657
- "access_token": "new-access",
658
- },
659
- KeyError,
660
- ),
661
- (None, TypeError),
595
+ None,
596
+ {},
597
+ {"fetchAccessToken": None},
598
+ {"fetchAccessToken": {"success": True, "errorMessage": None, "data": None}},
599
+ {"fetchAccessToken": {"success": True, "data": {}}},
600
+ {"fetchAccessToken": {"success": True, "data": {"token_type": "Bearer"}}},
662
601
  ],
663
602
  )
664
- async def test_refresh_auth_token_normalizes_malformed_success_payloads(
665
- payload: object,
666
- cause_type: type[BaseException],
603
+ async def test_refresh_auth_token_rejects_malformed_results(
604
+ fetch_access_token: type[FetchAccessTokenClient],
605
+ result: object,
667
606
  ) -> None:
668
- """Raise token refresh errors for malformed successful OAuth responses.
607
+ """Raise token refresh errors for malformed mutation results.
669
608
 
670
609
  Args:
671
- payload: Malformed successful OAuth payload returned by the fake response.
672
- cause_type: Expected original exception type preserved as the cause.
610
+ fetch_access_token: GraphQL client double for the refresh mutation.
611
+ result: Malformed GraphQL result.
673
612
  """
674
- session = FakeSession()
675
- session.response = FakeResponse(payload)
676
- connection = ApiConnectionGraphql(
677
- username="user@example.com",
678
- password="password",
679
- client_session=cast("ClientSession", session),
680
- )
681
- connection.refresh_token = "old-refresh"
613
+ fetch_access_token.result = result
614
+ connection = _refresh_connection()
682
615
 
683
- with pytest.raises(errors.CarrierApiTokenRefreshError) as error:
616
+ with pytest.raises(errors.CarrierApiTokenRefreshError):
684
617
  await connection.refresh_auth_token()
685
618
 
686
- assert isinstance(error.value.__cause__, cause_type)
619
+ assert connection.refresh_token == "old-refresh"
620
+ assert connection.access_token is None
687
621
 
688
622
 
689
623
  @pytest.mark.asyncio
@@ -704,6 +638,107 @@ async def test_check_auth_expiration_logs_in_then_refreshes_when_expired(
704
638
  assert connection.refresh_count == 1
705
639
 
706
640
 
641
+ class RefreshSpyConnection(ApiConnectionGraphql):
642
+ """Connection double whose token refresh outcome is configurable."""
643
+
644
+ def __init__(self, refresh_error: BaseException | None = None) -> None:
645
+ """Initialize an expired connection with a stored refresh token.
646
+
647
+ Args:
648
+ refresh_error: Optional error raised by every token refresh.
649
+ """
650
+ super().__init__(
651
+ username="user@example.com",
652
+ password="password",
653
+ client_session=cast("ClientSession", FakeSession()),
654
+ )
655
+ self.refresh_error = refresh_error
656
+ self.login_error: BaseException | None = None
657
+ self.refresh_token = "stored-refresh"
658
+ self.expires_at = datetime.now(UTC) - timedelta(seconds=1)
659
+ self.login_count = 0
660
+ self.refreshed_tokens: list[str | None] = []
661
+
662
+ async def login(self) -> None:
663
+ """Record login calls and seed fresh token state.
664
+
665
+ Raises:
666
+ BaseException: When ``login_error`` is configured.
667
+ """
668
+ self.login_count += 1
669
+ if self.login_error is not None:
670
+ raise self.login_error
671
+ self.refresh_token = "login-refresh"
672
+ self.expires_at = datetime.now(UTC) + timedelta(hours=1)
673
+
674
+ async def refresh_auth_token(self) -> None:
675
+ """Record the refresh token used and rotate it like Okta.
676
+
677
+ Raises:
678
+ BaseException: When ``refresh_error`` is configured.
679
+ """
680
+ self.refreshed_tokens.append(self.refresh_token)
681
+ await asyncio.sleep(0)
682
+ if self.refresh_error is not None:
683
+ raise self.refresh_error
684
+ self.refresh_token = f"rotated-{len(self.refreshed_tokens)}"
685
+ self.expires_at = datetime.now(UTC) + timedelta(hours=1)
686
+
687
+
688
+ @pytest.mark.asyncio
689
+ async def test_check_auth_expiration_logs_in_when_refresh_is_rejected() -> None:
690
+ """Fall back to one password login when Carrier rejects the refresh token."""
691
+ connection = RefreshSpyConnection(errors.CarrierApiAuthError("rejected"))
692
+
693
+ await connection.check_auth_expiration()
694
+
695
+ assert connection.refreshed_tokens == ["stored-refresh"]
696
+ assert connection.login_count == 1
697
+ assert connection.refresh_token == "login-refresh"
698
+ assert connection.expires_at > datetime.now(UTC)
699
+
700
+
701
+ @pytest.mark.asyncio
702
+ async def test_check_auth_expiration_raises_when_fallback_login_is_rejected() -> None:
703
+ """Surface auth errors when the fallback password login is also rejected."""
704
+ connection = RefreshSpyConnection(errors.CarrierApiAuthError("rejected"))
705
+ login_error = errors.CarrierApiAuthError("bad password")
706
+ connection.login_error = login_error
707
+
708
+ with pytest.raises(errors.CarrierApiAuthError) as error:
709
+ await connection.check_auth_expiration()
710
+
711
+ assert error.value is login_error
712
+ assert connection.refresh_token is None
713
+
714
+
715
+ @pytest.mark.asyncio
716
+ async def test_check_auth_expiration_keeps_token_on_transient_refresh_failure() -> None:
717
+ """Do not log in again when token refresh fails for transient reasons."""
718
+ connection = RefreshSpyConnection(errors.CarrierApiTokenRefreshError("network"))
719
+
720
+ with pytest.raises(errors.CarrierApiTokenRefreshError):
721
+ await connection.check_auth_expiration()
722
+
723
+ assert connection.login_count == 0
724
+ assert connection.refresh_token == "stored-refresh"
725
+
726
+
727
+ @pytest.mark.asyncio
728
+ async def test_check_auth_expiration_serializes_concurrent_refreshes() -> None:
729
+ """Send a single-use refresh token only once when callers race after expiry."""
730
+ connection = RefreshSpyConnection()
731
+
732
+ await asyncio.gather(
733
+ connection.check_auth_expiration(),
734
+ connection.check_auth_expiration(),
735
+ connection.check_auth_expiration(),
736
+ )
737
+
738
+ assert connection.refreshed_tokens == ["stored-refresh"]
739
+ assert connection.refresh_token == "rotated-1"
740
+
741
+
707
742
  @pytest.mark.asyncio
708
743
  async def test_login_wraps_graphql_query_errors(monkeypatch: pytest.MonkeyPatch) -> None:
709
744
  """Raise Carrier GraphQL errors instead of raw GraphQL query exceptions.
@@ -1189,6 +1224,7 @@ async def test_update_methods_send_reconcile_when_websocket_exists() -> None:
1189
1224
  config_variables: dict[str, Any] = {"input": {"serial": "SERIAL"}}
1190
1225
  zone_activity_variables: dict[str, Any] = {"input": {"serial": "SERIAL"}}
1191
1226
  zone_config_variables: dict[str, Any] = {"input": {"serial": "SERIAL"}}
1227
+ program_day_variables: dict[str, Any] = {"input": {"serial": "SERIAL"}}
1192
1228
 
1193
1229
  assert await connection._update_infinity_config(config_variables) == {
1194
1230
  "operation": "updateInfinityConfig",
@@ -1202,7 +1238,11 @@ async def test_update_methods_send_reconcile_when_websocket_exists() -> None:
1202
1238
  "operation": "updateInfinityZoneConfig",
1203
1239
  "variables": zone_config_variables,
1204
1240
  }
1205
- assert websocket.calls == 3
1241
+ assert await connection._update_infinity_program_day(program_day_variables) == {
1242
+ "operation": "updateInfinityProgramDay",
1243
+ "variables": program_day_variables,
1244
+ }
1245
+ assert websocket.calls == 4
1206
1246
 
1207
1247
 
1208
1248
  @pytest.mark.asyncio
@@ -1275,3 +1315,107 @@ async def test_load_entry_level_data_parses_systems(
1275
1315
  assert len(systems) == 1
1276
1316
  assert systems[0].serial == "SERIALXXX"
1277
1317
  assert systems[0].zones[0].mode == "cool"
1318
+
1319
+
1320
+ PROGRAM_DAY_PERIODS: list[dict[str, str]] = [
1321
+ {"id": "1", "activity": "wake", "time": "06:00", "enabled": "on"},
1322
+ {"id": "2", "activity": "away", "time": "08:00", "enabled": "on"},
1323
+ {"id": "3", "activity": "home", "time": "18:00", "enabled": "on"},
1324
+ {"id": "4", "activity": "sleep", "time": "22:00", "enabled": "on"},
1325
+ {"id": "5", "activity": "home", "time": "00:00", "enabled": "off"},
1326
+ ]
1327
+
1328
+
1329
+ @pytest.mark.asyncio
1330
+ async def test_set_program_day_sends_the_day_with_its_etag(connection: SpyConnection) -> None:
1331
+ """Send every period of the day unchanged with the caller's etag.
1332
+
1333
+ Args:
1334
+ connection: Spy connection under test.
1335
+ """
1336
+ await connection.set_program_day(
1337
+ system_serial="SERIAL",
1338
+ zone_id="1",
1339
+ day_id="3",
1340
+ periods=PROGRAM_DAY_PERIODS,
1341
+ etag="ETAG",
1342
+ )
1343
+
1344
+ assert connection.authed_calls == [
1345
+ (
1346
+ "updateInfinityProgramDay",
1347
+ {
1348
+ "input": {
1349
+ "serial": "SERIAL",
1350
+ "etag": "ETAG",
1351
+ "zoneId": "1",
1352
+ "dayId": "3",
1353
+ "period": PROGRAM_DAY_PERIODS,
1354
+ }
1355
+ },
1356
+ )
1357
+ ]
1358
+
1359
+
1360
+ @pytest.mark.asyncio
1361
+ async def test_set_program_day_omits_optional_period_fields(connection: SpyConnection) -> None:
1362
+ """Send only the period fields the caller supplied.
1363
+
1364
+ Args:
1365
+ connection: Spy connection under test.
1366
+ """
1367
+ await connection.set_program_day("SERIAL", "1", "0", [{"id": "1", "activity": "home"}], "E")
1368
+
1369
+ assert connection.authed_calls[0][1]["input"]["period"] == [{"id": "1", "activity": "home"}]
1370
+
1371
+
1372
+ @pytest.mark.asyncio
1373
+ @pytest.mark.parametrize(
1374
+ "period",
1375
+ [
1376
+ {"activity": "home", "time": "06:00"},
1377
+ {"id": "1", "time": "06:00"},
1378
+ {"id": "1", "activity": "nap", "time": "06:00"},
1379
+ {"id": "1", "activity": "home", "colour": "blue"},
1380
+ ],
1381
+ )
1382
+ async def test_set_program_day_rejects_invalid_periods(
1383
+ connection: SpyConnection, period: dict[str, str]
1384
+ ) -> None:
1385
+ """Reject periods the Carrier schema would refuse before sending anything.
1386
+
1387
+ Args:
1388
+ connection: Spy connection under test.
1389
+ period: Invalid period payload.
1390
+ """
1391
+ with pytest.raises(ValueError):
1392
+ await connection.set_program_day("SERIAL", "1", "0", [period], "ETAG")
1393
+
1394
+ assert connection.authed_calls == []
1395
+
1396
+
1397
+ @pytest.mark.asyncio
1398
+ async def test_set_program_day_surfaces_a_stale_etag(monkeypatch: pytest.MonkeyPatch) -> None:
1399
+ """Raise a Carrier GraphQL error when the server refuses the day.
1400
+
1401
+ Args:
1402
+ monkeypatch: Pytest helper for replacing the GraphQL client.
1403
+ """
1404
+ transport_error = TransportQueryError("etag mismatch")
1405
+ monkeypatch.setattr(
1406
+ "carrier_api.api_connection_graphql.Client", graphql_client_double(error=transport_error)
1407
+ )
1408
+ connection = ApiConnectionGraphql(
1409
+ username="user@example.com",
1410
+ password="password",
1411
+ client_session=cast("ClientSession", FakeSession()),
1412
+ )
1413
+ connection.refresh_token = "refresh"
1414
+ connection.token_type = "Bearer"
1415
+ connection.access_token = "access"
1416
+ connection.expires_at = datetime.now(UTC) + timedelta(hours=1)
1417
+
1418
+ with pytest.raises(errors.CarrierApiGraphqlError) as error:
1419
+ await connection.set_program_day("SERIAL", "1", "0", PROGRAM_DAY_PERIODS, "STALE")
1420
+
1421
+ assert error.value.__cause__ is transport_error
File without changes
File without changes
File without changes