hyxi-cloud-api 1.2.0__tar.gz → 1.2.2__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 (48) hide show
  1. {hyxi_cloud_api-1.2.0/src/hyxi_cloud_api.egg-info → hyxi_cloud_api-1.2.2}/PKG-INFO +1 -1
  2. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/pyproject.toml +1 -1
  3. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/src/hyxi_cloud_api/__init__.py +1 -1
  4. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/src/hyxi_cloud_api/api.py +89 -23
  5. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2/src/hyxi_cloud_api.egg-info}/PKG-INFO +1 -1
  6. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_api.py +141 -27
  7. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_caching.py +65 -0
  8. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_compute_derived_metrics.py +63 -0
  9. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_device_control.py +49 -1
  10. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_extract_device_info_metadata.py +23 -0
  11. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_metrics_errors.py +9 -9
  12. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_parser.py +38 -0
  13. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_process_devices_for_plant.py +15 -0
  14. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_token_handling.py +12 -0
  15. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/LICENSE +0 -0
  16. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/README.md +0 -0
  17. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/setup.cfg +0 -0
  18. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/src/hyxi_cloud_api.egg-info/SOURCES.txt +0 -0
  19. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/src/hyxi_cloud_api.egg-info/dependency_links.txt +0 -0
  20. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/src/hyxi_cloud_api.egg-info/requires.txt +0 -0
  21. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/src/hyxi_cloud_api.egg-info/top_level.txt +0 -0
  22. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_all_in_one.py +0 -0
  23. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_build_plant_tasks.py +0 -0
  24. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_device_entry.py +0 -0
  25. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_devices_errors.py +0 -0
  26. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_discovery.py +0 -0
  27. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_execute_device_tasks.py +0 -0
  28. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_execute_metric_tasks.py +0 -0
  29. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_execute_metrics_and_map_alarms.py +0 -0
  30. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_extract_battery_info.py +0 -0
  31. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_fetch_and_process_alarms.py +0 -0
  32. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_fetch_device_list_for_plant.py +0 -0
  33. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_fetch_plants.py +0 -0
  34. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_fetch_state.py +0 -0
  35. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_fetch_sub_device_list.py +0 -0
  36. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_filter_metrics.py +0 -0
  37. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_fuzz_parser.py +0 -0
  38. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_get_f.py +0 -0
  39. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_handle_back_discovery_alarm.py +0 -0
  40. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_info_errors.py +0 -0
  41. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_mask_id.py +0 -0
  42. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_parse_data_list.py +0 -0
  43. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_parse_ems_kv.py +0 -0
  44. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_process_alarms_and_back_discovery.py +0 -0
  45. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_sanitize_dict.py +0 -0
  46. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_sanitize_list.py +0 -0
  47. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_security_fix.py +0 -0
  48. {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_token_errors.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyxi-cloud-api
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: An async API client for HYXi Cloud.
5
5
  Author-email: Veldkornet <Veldkornet@users.noreply.github.com>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hyxi-cloud-api"
7
- version = "1.2.0"
7
+ version = "1.2.2"
8
8
  authors = [
9
9
  { name="Veldkornet", email="Veldkornet@users.noreply.github.com" },
10
10
  ]
@@ -2,5 +2,5 @@
2
2
 
3
3
  from .api import HyxiApiClient
4
4
 
5
- __version__ = "1.2.0"
5
+ __version__ = "1.2.2"
6
6
  __all__ = ["HyxiApiClient"]
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import asyncio
11
11
  import base64
12
+ import functools
12
13
  import hashlib
13
14
  import hmac
14
15
  import logging
@@ -42,7 +43,15 @@ class FetchState:
42
43
 
43
44
 
44
45
  _LOGGER = logging.getLogger(__name__)
45
- _battery_device_types = ("INVERTER", "ESS", "HALO", "1", "15")
46
+ _battery_device_types = (
47
+ "INVERTER",
48
+ "ESS",
49
+ "HALO",
50
+ "1",
51
+ "15",
52
+ "16",
53
+ "MICRO_STORAGE_ALL_IN_ONE",
54
+ )
46
55
  _BATTERY_DEVICE_REGEX = re.compile("|".join(_battery_device_types))
47
56
  _parent_device_types = ("COLLECTOR", "DMU", "INVERTER", "ALL_IN_ONE")
48
57
  _PARENT_DEVICE_REGEX = re.compile("|".join(_parent_device_types))
@@ -501,6 +510,7 @@ DEVICE_TYPE_MAP = {
501
510
  "ENERGY_STORAGE_BATTERY": "Battery",
502
511
  "ALL_IN_ONE": "all-in-one machine",
503
512
  "AC_BATTERY": "AC Battery",
513
+ "MICRO_STORAGE_ALL_IN_ONE": "Micro ESS",
504
514
  # Official Numeric IDs (as seen in getSubDevicePage)
505
515
  "1": "Hybrid Inverter",
506
516
  "2": "Grid-Connected Inverter",
@@ -621,9 +631,19 @@ def _compute_derived_metrics(m_raw: dict, device_type: str = "") -> dict:
621
631
  ppv_total = _get_f("ppv", m_raw)
622
632
  derived["pv1p"] = round(max(ppv_total - derived["pv2p"], 0), 2)
623
633
 
634
+ # 5. Fallback mappings for Micro ESS
635
+ # Derive standard Solar Power (ppv) from Micro ESS pvPower if ppv is missing
636
+ if "pvPower" in m_raw and "ppv" not in m_raw:
637
+ derived["ppv"] = _get_f("pvPower", m_raw)
638
+
639
+ # Derive standard Grid Frequency (f) from Micro ESS gridF if f is missing
640
+ if "gridF" in m_raw and "f" not in m_raw:
641
+ derived["f"] = _get_f("gridF", m_raw)
642
+
624
643
  return derived
625
644
 
626
645
 
646
+ @functools.lru_cache(maxsize=1024)
627
647
  def _mask_id(value: str) -> str:
628
648
  """Mask an identifier (SN, plant ID, etc.) for logs.
629
649
 
@@ -884,7 +904,13 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
884
904
  else:
885
905
  entry["metrics"].update(m_raw)
886
906
 
887
- if "gridP" in m_raw or "pbat" in m_raw or "batP" in m_raw:
907
+ if (
908
+ "gridP" in m_raw
909
+ or "pbat" in m_raw
910
+ or "batP" in m_raw
911
+ or "pvPower" in m_raw
912
+ or "gridF" in m_raw
913
+ ):
888
914
  entry["metrics"].update(
889
915
  _compute_derived_metrics(
890
916
  m_raw, entry.get("device_type_code", "")
@@ -899,17 +925,6 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
899
925
  except Exception as e:
900
926
  _LOGGER.error("Error fetching metrics for %s: %s", _mask_id(sn), e)
901
927
 
902
- async def _fetch_ems_basic_data(self, ems_sn, entry):
903
- """Helper to fetch and merge EMS-specific basic details."""
904
- _LOGGER.debug("HYXI Probing EMS telemetry for %s...", _mask_id(ems_sn))
905
- m_raw = await self.query_ems_basic_details(ems_sn)
906
- if m_raw:
907
- entry["metrics"].update(m_raw)
908
- else:
909
- _LOGGER.debug(
910
- "HYXI EMS telemetry probe returned no data for %s", _mask_id(ems_sn)
911
- )
912
-
913
928
  async def query_ems_basic_details(self, ems_sn):
914
929
  """Acquire basic data for Energy Storage Systems (ESS)."""
915
930
  path = "/api/ems/v1/queryBasicDetails"
@@ -958,10 +973,11 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
958
973
  "_sw_ver_sys": sw_ver,
959
974
  "signalIntensity": i_raw.get("signalIntensity"),
960
975
  "signalVal": i_raw.get("signalVal"),
961
- "wifiVer": i_raw.get("wifiVer"),
976
+ "wifiVer": i_raw.get("wifiVer") or i_raw.get("swVerWifi"),
962
977
  "comMode": i_raw.get("comMode"),
963
978
  "swVerMaster": i_raw.get("swVerMaster"),
964
979
  "swVerSlave": i_raw.get("swVerSlave"),
980
+ "ratedFrequency": i_raw.get("ratedFrequency"),
965
981
  }
966
982
 
967
983
  device_type_code = entry.get("device_type_code", "").upper()
@@ -1011,14 +1027,25 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1011
1027
  tasks = [asyncio.create_task(self._fetch_device_info(sn, entry))]
1012
1028
  is_comm_unit = dev_type in ("COLLECTOR", "DMU", "3")
1013
1029
 
1030
+ ems_task = None
1014
1031
  if not is_comm_unit:
1015
1032
  tasks.append(asyncio.create_task(self._fetch_device_metrics(sn, entry)))
1016
- tasks.append(asyncio.create_task(self._fetch_ems_basic_data(sn, entry)))
1033
+ ems_task = asyncio.create_task(self.query_ems_basic_details(sn))
1034
+ tasks.append(ems_task)
1017
1035
 
1018
1036
  # Wait for them to finish
1019
1037
  if tasks:
1020
1038
  await asyncio.gather(*tasks)
1021
1039
 
1040
+ if ems_task:
1041
+ m_raw = ems_task.result()
1042
+ if m_raw:
1043
+ entry["metrics"].update(m_raw)
1044
+ else:
1045
+ _LOGGER.debug(
1046
+ "HYXI EMS telemetry probe returned no data for %s", _mask_id(sn)
1047
+ )
1048
+
1022
1049
  return sn, entry
1023
1050
 
1024
1051
  async def _fetch_device_list_for_plant(self, plant_id: str) -> list[dict] | None:
@@ -1049,9 +1076,9 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1049
1076
 
1050
1077
  if _LOGGER.isEnabledFor(logging.DEBUG):
1051
1078
  _LOGGER.debug(
1052
- "HYXI Discovered Devices for Plant %s: %s",
1079
+ "HYXI Discovered Devices for Plant %s: [%s]",
1053
1080
  _mask_id(plant_id),
1054
- [_mask_id(d.get("deviceSn", "UNKNOWN")) for d in devices],
1081
+ ", ".join(_mask_id(d.get("deviceSn", "UNKNOWN")) for d in devices),
1055
1082
  )
1056
1083
  return devices
1057
1084
 
@@ -1131,10 +1158,10 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1131
1158
 
1132
1159
  if _LOGGER.isEnabledFor(logging.DEBUG):
1133
1160
  _LOGGER.debug(
1134
- "HYXI Found %s sub-devices under %s: %s",
1161
+ "HYXI Found %s sub-devices under %s: [%s]",
1135
1162
  len(children),
1136
1163
  _mask_id(parent_sn),
1137
- [_mask_id(c.get("deviceSn", "UNKNOWN")) for c in children],
1164
+ ", ".join(_mask_id(c.get("deviceSn", "UNKNOWN")) for c in children),
1138
1165
  )
1139
1166
 
1140
1167
  for c in children:
@@ -1197,6 +1224,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1197
1224
  """Fetches data with built-in retry logic and returns attempt count."""
1198
1225
 
1199
1226
  for attempt in range(1, MAX_RETRIES + 1):
1227
+ err: aiohttp.ClientError | TimeoutError | None = None
1200
1228
  try:
1201
1229
  data = await self._execute_fetch_all(
1202
1230
  allow_back_discovery=allow_back_discovery,
@@ -1209,9 +1237,12 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1209
1237
  return {"data": data, "attempts": attempt}
1210
1238
 
1211
1239
  # If we get here, data was None (soft failure). Trigger a retry manually.
1212
- raise aiohttp.ClientError("Fetch returned None, triggering retry.")
1240
+ err = aiohttp.ClientError("Fetch returned None, triggering retry.")
1213
1241
 
1214
- except (aiohttp.ClientError, TimeoutError) as err:
1242
+ except (aiohttp.ClientError, TimeoutError) as e:
1243
+ err = e
1244
+
1245
+ if err is not None:
1215
1246
  if attempt < MAX_RETRIES:
1216
1247
  wait_time = attempt * RETRY_DELAY
1217
1248
  _LOGGER.debug(
@@ -1258,8 +1289,8 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1258
1289
  # 👇 Log the discovered plants
1259
1290
  if _LOGGER.isEnabledFor(logging.DEBUG):
1260
1291
  _LOGGER.debug(
1261
- "HYXI Discovered Plants: %s",
1262
- [_mask_id(p.get("plantId", "UNKNOWN")) for p in plants],
1292
+ "HYXI Discovered Plants: [%s]",
1293
+ ", ".join(_mask_id(p.get("plantId", "UNKNOWN")) for p in plants),
1263
1294
  )
1264
1295
 
1265
1296
  return plants
@@ -1650,3 +1681,38 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1650
1681
  For **MICRO_INVERTER** devices.
1651
1682
  """
1652
1683
  return await self.set_device_control(device_sn, {3013: "1"})
1684
+
1685
+ # ── Alarm Controls ───────────────────────────────────────────────────
1686
+
1687
+ async def alter_alarm(self, alarm_ids: list[int]) -> dict:
1688
+ """Process/Acknowledge alarm information.
1689
+
1690
+ Endpoint: POST /api/alarm/v1/alterAlarm
1691
+ Body: {"ids": [id1, id2, ...], "state": 1}
1692
+ """
1693
+ token_status = await self._refresh_token()
1694
+ if token_status == "auth_failed":
1695
+ raise self.ControlError("Authentication failed")
1696
+ if not token_status:
1697
+ raise self.ControlError("Could not obtain API token")
1698
+
1699
+ path = "/api/alarm/v1/alterAlarm"
1700
+ body = {
1701
+ "ids": alarm_ids,
1702
+ "state": 1,
1703
+ }
1704
+ _LOGGER.debug(
1705
+ "HYXI ALTER_ALARM request for ids %s: %s",
1706
+ alarm_ids,
1707
+ body,
1708
+ )
1709
+ _, res = await self._request("POST", path, json=body)
1710
+ if res is None or not res.get("success"):
1711
+ code = res.get("code", "unknown") if res else "no_response"
1712
+ msg = res.get("msg", "") if res else ""
1713
+ raise self.ControlError(f"alarm alteration failed (code={code}): {msg}")
1714
+ _LOGGER.debug(
1715
+ "HYXI ALTER_ALARM response: success=%s",
1716
+ res.get("success"),
1717
+ )
1718
+ return res
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyxi-cloud-api
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: An async API client for HYXi Cloud.
5
5
  Author-email: Veldkornet <Veldkornet@users.noreply.github.com>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -1,5 +1,6 @@
1
1
  import sys
2
- from unittest.mock import MagicMock
2
+ import time
3
+ from unittest.mock import AsyncMock, MagicMock
3
4
 
4
5
  if "aiohttp" not in sys.modules or not hasattr(sys.modules["aiohttp"], "ClientError"):
5
6
  m = MagicMock()
@@ -19,7 +20,7 @@ mock_aiohttp = sys.modules["aiohttp"]
19
20
  """Tests for the HYXI Cloud API client."""
20
21
 
21
22
  import logging
22
- from unittest.mock import AsyncMock, MagicMock
23
+ from unittest.mock import MagicMock
23
24
 
24
25
  import aiohttp
25
26
  import pytest
@@ -49,6 +50,20 @@ def test_api_initialization():
49
50
  assert api.token is None
50
51
 
51
52
 
53
+ @pytest.mark.asyncio
54
+ async def test_request_unsupported_method():
55
+ """Test that _request raises ValueError for unsupported HTTP methods."""
56
+ fake_session = MagicMock()
57
+ api = HyxiApiClient(
58
+ access_key="fake_access_key",
59
+ secret_key="fake_secret_key",
60
+ base_url="https://fake-hyxi-url.com",
61
+ session=fake_session,
62
+ )
63
+ with pytest.raises(ValueError, match="Unsupported HTTP method: PUT"):
64
+ await api._request("PUT", "/some/path")
65
+
66
+
52
67
  # --- TEST 2: The Retry Logic Wrapper ---
53
68
  @pytest.mark.asyncio
54
69
  async def test_get_all_device_data_success():
@@ -234,17 +249,19 @@ async def test_query_ems_basic_details_success():
234
249
 
235
250
  @pytest.mark.asyncio
236
251
  async def test_fetch_ems_basic_data_success(caplog):
237
- """Test _fetch_ems_basic_data when basic details are returned."""
252
+ """Test _fetch_all_for_device when basic details are returned."""
238
253
  caplog.set_level(logging.DEBUG)
239
254
 
240
255
  mock_session = MagicMock()
241
256
  api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
242
257
  api.query_ems_basic_details = AsyncMock(return_value={"new_metric": "new_value"})
258
+ api._fetch_device_info = AsyncMock()
259
+ api._fetch_device_metrics = AsyncMock()
243
260
 
244
261
  ems_sn = "10602251600016"
245
262
  entry = {"device_type_code": "EMS", "metrics": {"existing_metric": "value"}}
246
263
 
247
- await api._fetch_ems_basic_data(ems_sn, entry)
264
+ await api._fetch_all_for_device(ems_sn, entry, "INVERTER")
248
265
 
249
266
  # Assert query_ems_basic_details was called
250
267
  api.query_ems_basic_details.assert_called_once_with(ems_sn)
@@ -255,17 +272,19 @@ async def test_fetch_ems_basic_data_success(caplog):
255
272
 
256
273
  @pytest.mark.asyncio
257
274
  async def test_fetch_ems_basic_data_no_data(caplog):
258
- """Test _fetch_ems_basic_data when no basic details are returned."""
275
+ """Test _fetch_all_for_device when no basic details are returned."""
259
276
  caplog.set_level(logging.DEBUG)
260
277
 
261
278
  mock_session = MagicMock()
262
279
  api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
263
280
  api.query_ems_basic_details = AsyncMock(return_value={})
281
+ api._fetch_device_info = AsyncMock()
282
+ api._fetch_device_metrics = AsyncMock()
264
283
 
265
284
  ems_sn = "EMS123"
266
285
  entry = {"device_type_code": "EMS", "metrics": {"existing_metric": "value"}}
267
286
 
268
- await api._fetch_ems_basic_data(ems_sn, entry)
287
+ await api._fetch_all_for_device(ems_sn, entry, "INVERTER")
269
288
 
270
289
  # Assert query_ems_basic_details was called
271
290
  api.query_ems_basic_details.assert_called_once_with("EMS123")
@@ -427,18 +446,15 @@ async def test_fetch_alarms_for_plant_sanitization(caplog):
427
446
 
428
447
  @pytest.mark.asyncio
429
448
  async def test_fetch_all_for_device_collector():
430
- """Test _fetch_all_for_device when dev_type is COLLECTOR."""
431
- api = HyxiApiClient("key", "secret", "url", session=MagicMock())
449
+ """Test _fetch_all_for_device when dev_type is COLLECTOR.
432
450
 
433
- async def dummy_info(*args, **kwargs):
434
- pass
435
-
436
- async def dummy_metrics(*args, **kwargs):
437
- pass
451
+ Collector/DMU units skip metrics and EMS probing — only device info is fetched.
452
+ """
453
+ api = HyxiApiClient("key", "secret", "url", session=MagicMock())
438
454
 
439
- api._fetch_device_info = MagicMock(side_effect=dummy_info)
440
- api._fetch_device_metrics = MagicMock(side_effect=dummy_metrics)
441
- api._fetch_ems_basic_data = AsyncMock()
455
+ api._fetch_device_info = AsyncMock()
456
+ api._fetch_device_metrics = AsyncMock()
457
+ api.query_ems_basic_details = AsyncMock()
442
458
 
443
459
  sn = "SN_123"
444
460
  entry = {"initial": "state"}
@@ -451,25 +467,27 @@ async def test_fetch_all_for_device_collector():
451
467
 
452
468
  api._fetch_device_info.assert_called_once_with(sn, entry)
453
469
  api._fetch_device_metrics.assert_not_called()
470
+ api.query_ems_basic_details.assert_not_called()
454
471
 
455
472
 
456
473
  @pytest.mark.asyncio
457
474
  async def test_fetch_all_for_device_non_collector():
458
- """Test _fetch_all_for_device when dev_type is not COLLECTOR."""
459
- api = HyxiApiClient("key", "secret", "url", session=MagicMock())
475
+ """Test _fetch_all_for_device when dev_type is not COLLECTOR.
460
476
 
461
- async def dummy_info(*args, **kwargs):
462
- pass
463
-
464
- async def dummy_metrics(*args, **kwargs):
465
- pass
477
+ Non-collector devices (e.g. INVERTER) trigger device info, metrics, and EMS
478
+ telemetry probing concurrently. query_ems_basic_details must be mocked to
479
+ prevent unawaited coroutine RuntimeWarnings from the real HTTP path.
480
+ """
481
+ api = HyxiApiClient("key", "secret", "url", session=MagicMock())
466
482
 
467
- api._fetch_device_info = MagicMock(side_effect=dummy_info)
468
- api._fetch_device_metrics = MagicMock(side_effect=dummy_metrics)
469
- api._fetch_ems_basic_data = AsyncMock()
483
+ api._fetch_device_info = AsyncMock()
484
+ api._fetch_device_metrics = AsyncMock()
485
+ # Must mock query_ems_basic_details: _fetch_all_for_device fires it as an
486
+ # asyncio.create_task. Without this mock the real coroutine leaks unawaited.
487
+ api.query_ems_basic_details = AsyncMock(return_value={})
470
488
 
471
489
  sn = "SN_456"
472
- entry = {"initial": "state2"}
490
+ entry = {"metrics": {}, "initial": "state2"}
473
491
  dev_type = "INVERTER"
474
492
 
475
493
  result_sn, result_entry = await api._fetch_all_for_device(sn, entry, dev_type)
@@ -479,6 +497,7 @@ async def test_fetch_all_for_device_non_collector():
479
497
 
480
498
  api._fetch_device_info.assert_called_once_with(sn, entry)
481
499
  api._fetch_device_metrics.assert_called_once_with(sn, entry)
500
+ api.query_ems_basic_details.assert_called_once_with(sn)
482
501
 
483
502
 
484
503
  # --- TEST 6: Empty Data Response (The "Halo ESS" Scenario) ---
@@ -551,3 +570,98 @@ async def test_fetch_alarms_for_plant_error(caplog):
551
570
 
552
571
  log_text = caplog.text
553
572
  assert "Error fetching alarms for plant ef797c81: Connection reset" in log_text
573
+
574
+
575
+ @pytest.mark.asyncio
576
+ async def test_execute_fetch_all_force_discovery():
577
+ """Verify that _execute_fetch_all respects the force_discovery flag to bypass cache."""
578
+ api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
579
+
580
+ # Bypass token validation
581
+ api._refresh_token = AsyncMock(return_value=True)
582
+
583
+ # Mock cache state to be valid
584
+ api._discovery_cache["plants"] = [{"plantId": "plant_1"}]
585
+ api._discovery_cache_time = time.time()
586
+ api._discovery_cache_ttl = 3600
587
+
588
+ # Mock internal methods
589
+ api._execute_fetch_cached = AsyncMock(return_value="cached_result")
590
+ api._execute_fetch_full_discovery = AsyncMock(return_value="full_discovery_result")
591
+
592
+ # Test 1: force_discovery=True should bypass cache and call full discovery
593
+ result = await api._execute_fetch_all(force_discovery=True)
594
+
595
+ assert result == "full_discovery_result"
596
+ api._execute_fetch_full_discovery.assert_called_once()
597
+ api._execute_fetch_cached.assert_not_called()
598
+
599
+ # Reset mocks
600
+ api._execute_fetch_full_discovery.reset_mock()
601
+ api._execute_fetch_cached.reset_mock()
602
+
603
+ # Test 2: force_discovery=False should use cache
604
+ result = await api._execute_fetch_all(force_discovery=False)
605
+
606
+ assert result == "cached_result"
607
+ api._execute_fetch_cached.assert_called_once()
608
+ api._execute_fetch_full_discovery.assert_not_called()
609
+
610
+
611
+ @pytest.mark.asyncio
612
+ async def test_execute_fetch_all_force_discovery_integration():
613
+ """Verify that force_discovery=True bypasses cache and integrates with full discovery."""
614
+ from unittest.mock import patch # pylint: disable=import-outside-toplevel
615
+
616
+ api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
617
+
618
+ # Bypass token validation
619
+ api._refresh_token = AsyncMock(return_value=True)
620
+
621
+ # Mock cache state to be valid to test bypass
622
+ api._discovery_cache["plants"] = [{"plantId": "plant_1"}]
623
+ api._discovery_cache_time = time.time()
624
+ api._discovery_cache_ttl = 3600
625
+
626
+ # Mock the HTTP layer to return valid JSON responses for a full discovery flow
627
+ plant_resp = {"success": True, "data": {"list": [{"plantId": "plant_1"}]}}
628
+ device_resp = {
629
+ "success": True,
630
+ "data": {"deviceList": [{"deviceSn": "device_1", "deviceType": "INVERTER"}]},
631
+ }
632
+ sub_dev_resp = {"success": True, "data": {"childDevice": []}}
633
+ alarms_resp = {"success": True, "data": {"pageData": []}}
634
+ info_resp = {"success": True, "data": {"swVerSys": "v1.0", "hwVer": "v1.0"}}
635
+ metrics_resp = {"success": True, "data": [{"dataKey": "gridP", "dataValue": "100"}]}
636
+
637
+ with patch.object(api, "_request") as mock_req:
638
+ mock_req.side_effect = [
639
+ (200, plant_resp),
640
+ (200, device_resp),
641
+ (200, sub_dev_resp),
642
+ (200, alarms_resp),
643
+ (200, info_resp),
644
+ (200, metrics_resp),
645
+ (200, {}), # EMS Probe
646
+ ]
647
+
648
+ # Use wraps to spy on internal methods while preserving their original logic
649
+ with (
650
+ patch.object(
651
+ api,
652
+ "_execute_fetch_full_discovery",
653
+ wraps=api._execute_fetch_full_discovery,
654
+ ) as mock_full,
655
+ patch.object(
656
+ api, "_execute_fetch_cached", wraps=api._execute_fetch_cached
657
+ ) as mock_cached,
658
+ ):
659
+ result = await api._execute_fetch_all(force_discovery=True)
660
+
661
+ # Assert that the full discovery method was called and cached was not
662
+ mock_full.assert_called_once()
663
+ mock_cached.assert_not_called()
664
+
665
+ # Ensure we successfully parsed the data, meaning full discovery worked
666
+ assert "device_1" in result
667
+ assert result["device_1"]["sw_version"] == "v1.0"
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch
6
6
  import pytest
7
7
 
8
8
  from hyxi_cloud_api import HyxiApiClient
9
+ from hyxi_cloud_api.api import FetchState
9
10
 
10
11
 
11
12
  @pytest.mark.asyncio
@@ -91,3 +92,67 @@ async def test_discovery_caching_logic():
91
92
  ]
92
93
  await client.get_all_device_data(force_discovery=True)
93
94
  assert mock_req.call_count == 7
95
+
96
+
97
+ @pytest.mark.asyncio
98
+ async def test_execute_fetch_cached_no_device_info():
99
+ """Verify _execute_fetch_cached handles missing device_info without errors."""
100
+ session = AsyncMock()
101
+ client = HyxiApiClient("key", "secret", "http://api.com", session)
102
+
103
+ # Empty cache
104
+ client._discovery_cache = {
105
+ "plants": [{"plantId": "P1"}],
106
+ "device_info": None,
107
+ }
108
+
109
+ state = FetchState(now="2023-01-01T00:00:00Z")
110
+
111
+ with (
112
+ patch.object(client, "_build_plant_tasks", return_value=([], [])) as mock_build,
113
+ patch.object(
114
+ client, "_fetch_and_process_alarms", return_value={}
115
+ ) as mock_alarms,
116
+ patch.object(
117
+ client, "_execute_metric_tasks", new_callable=AsyncMock
118
+ ) as mock_exec,
119
+ ):
120
+ results = await client._execute_fetch_cached(state, allow_back_discovery=True)
121
+
122
+ # Verify it runs without error and executes the next steps
123
+ assert results == {}
124
+ assert len(state.metric_tasks) == 0
125
+ mock_build.assert_called_once()
126
+ mock_alarms.assert_called_once()
127
+ mock_exec.assert_called_once()
128
+
129
+
130
+ @pytest.mark.asyncio
131
+ async def test_execute_fetch_cached_empty_cache():
132
+ """Verify _execute_fetch_cached handles fully empty cache without errors."""
133
+ session = AsyncMock()
134
+ client = HyxiApiClient("key", "secret", "http://api.com", session)
135
+
136
+ # Fully empty cache
137
+ client._discovery_cache = {}
138
+
139
+ state = FetchState(now="2023-01-01T00:00:00Z")
140
+
141
+ with (
142
+ patch.object(client, "_build_plant_tasks", return_value=([], [])) as mock_build,
143
+ patch.object(
144
+ client, "_fetch_and_process_alarms", return_value={}
145
+ ) as mock_alarms,
146
+ patch.object(
147
+ client, "_execute_metric_tasks", new_callable=AsyncMock
148
+ ) as mock_exec,
149
+ ):
150
+ results = await client._execute_fetch_cached(state, allow_back_discovery=True)
151
+
152
+ # Verify it runs without error and executes the next steps
153
+ assert results == {}
154
+ assert not state.plants
155
+ assert len(state.metric_tasks) == 0
156
+ mock_build.assert_called_once()
157
+ mock_alarms.assert_called_once()
158
+ mock_exec.assert_called_once()
@@ -1,5 +1,7 @@
1
1
  """Tests for the _compute_derived_metrics helper function in api.py."""
2
2
 
3
+ # pylint: disable=too-many-public-methods
4
+
3
5
  from src.hyxi_cloud_api.api import _compute_derived_metrics
4
6
 
5
7
 
@@ -89,6 +91,55 @@ class TestComputeDerivedMetrics:
89
91
  result = _compute_derived_metrics({})
90
92
  assert not result
91
93
 
94
+ def test_missing_pv_power_derived(self):
95
+ """Test PV power is derived when missing but voltage and current are present."""
96
+ data = {"pv1v": 100.0, "pv1i": 5.0}
97
+ result = _compute_derived_metrics(data)
98
+ assert "pv1p" in result
99
+ assert result["pv1p"] == 500.0
100
+
101
+ def test_provided_pv_power_takes_precedence(self):
102
+ """Test provided PV power takes precedence over derived power."""
103
+ data = {"pv1v": 100.0, "pv1i": 5.0, "pv1p": 600.0}
104
+ result = _compute_derived_metrics(data)
105
+ assert "pv1p" in result
106
+ assert result["pv1p"] == 600.0
107
+
108
+ def test_partial_pv_data(self):
109
+ """Test PV power derivation with partial data (e.g. only voltage)."""
110
+ data = {"pv1v": 100.0}
111
+ result = _compute_derived_metrics(data)
112
+ assert "pv1p" in result
113
+ assert result["pv1p"] == 0.0
114
+
115
+ def test_pv_power_recalculated_when_zero(self):
116
+ """Test PV power recalculation if provided value is falsy (e.g. 0.0) but voltage/current exist."""
117
+ data = {"pv1v": 100.0, "pv1i": 5.0, "pv1p": 0.0}
118
+ result = _compute_derived_metrics(data)
119
+ assert "pv1p" in result
120
+ assert result["pv1p"] == 500.0
121
+
122
+ def test_pv_power_calculation_rounding(self):
123
+ """Test the rounding logic when calculating PV power from voltage and current."""
124
+ data = {"pv1v": 100.33, "pv1i": 5.12}
125
+ result = _compute_derived_metrics(data)
126
+ assert "pv1p" in result
127
+ assert result["pv1p"] == 513.69 # 100.33 * 5.12 = 513.6896, rounded to 513.69
128
+
129
+ def test_pv1p_derived_from_ppv_and_pv2p(self):
130
+ """Test the ALL_IN_ONE fallback logic where pv1p is derived from ppv - pv2p."""
131
+ data = {"ppv": 1500.0, "pv2v": 100.0, "pv2i": 5.0} # pv2p will be 500.0
132
+ result = _compute_derived_metrics(data)
133
+ assert "pv1p" in result
134
+ assert result["pv1p"] == 1000.0
135
+
136
+ def test_pv1p_derived_from_ppv_and_pv2p_zero_floor(self):
137
+ """Test that the derived pv1p does not go below zero."""
138
+ data = {"ppv": 400.0, "pv2v": 100.0, "pv2i": 5.0} # pv2p will be 500.0
139
+ result = _compute_derived_metrics(data)
140
+ assert "pv1p" in result
141
+ assert result["pv1p"] == 0.0
142
+
92
143
  def test_selective_pv_strings(self):
93
144
  """Test that only PV strings present in input are present in output."""
94
145
  data = {"pv1v": 100, "pv1i": 5} # PV1 only
@@ -107,3 +158,15 @@ class TestComputeDerivedMetrics:
107
158
  result = _compute_derived_metrics({"ph1Loadp": 100.0})
108
159
  assert "home_load" in result
109
160
  assert result["home_load"] == 100.0
161
+
162
+ def test_micro_ess_pv_power_fallback(self):
163
+ """Test that ppv is derived from pvPower when ppv is missing (Micro ESS fallback)."""
164
+ data = {"pvPower": 1200.5}
165
+ result = _compute_derived_metrics(data)
166
+ assert result["ppv"] == 1200.5
167
+
168
+ def test_micro_ess_grid_frequency_fallback(self):
169
+ """Test that f is derived from gridF when f is missing (Micro ESS fallback)."""
170
+ data = {"gridF": 49.98}
171
+ result = _compute_derived_metrics(data)
172
+ assert result["f"] == 49.98
@@ -235,10 +235,58 @@ async def test_restart_device():
235
235
  """Test restart_device sends controlId 3013 with value '1'."""
236
236
  api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
237
237
  api._refresh_token = AsyncMock(return_value=True)
238
- api._request = AsyncMock(return_value=(200, {"success": True}))
238
+ api._request = MagicMock()
239
+ # Handle async response for _request
240
+ async_mock = AsyncMock(return_value=(200, {"success": True}))
241
+ api._request.side_effect = async_mock
239
242
 
240
243
  await api.restart_device("SN123")
241
244
 
245
+ api._request.assert_called_once()
242
246
  call_kwargs = api._request.call_args
243
247
  body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
244
248
  assert body["deviceControlMap"]["SN123"]["3013"] == "1"
249
+
250
+
251
+ @pytest.mark.asyncio
252
+ async def test_alter_alarm():
253
+ """Test alter_alarm sends correct POST request payload."""
254
+ api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
255
+ api._refresh_token = AsyncMock(return_value=True)
256
+ api._request = AsyncMock(return_value=(200, {"success": True}))
257
+
258
+ result = await api.alter_alarm([10086, 10087])
259
+
260
+ assert result["success"] is True
261
+ api._request.assert_called_once()
262
+ call_args, call_kwargs = api._request.call_args
263
+ assert call_args[0] == "POST"
264
+ assert call_args[1] == "/api/alarm/v1/alterAlarm"
265
+ body = call_kwargs.get("json")
266
+ assert body == {"ids": [10086, 10087], "state": 1}
267
+
268
+
269
+ @pytest.mark.asyncio
270
+ async def test_alter_alarm_auth_failed():
271
+ """Test alter_alarm raises ControlError when authentication fails."""
272
+ api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
273
+ api._refresh_token = AsyncMock(return_value="auth_failed")
274
+
275
+ with pytest.raises(api.ControlError, match="Authentication failed"):
276
+ await api.alter_alarm([10086])
277
+
278
+
279
+ @pytest.mark.asyncio
280
+ async def test_alter_alarm_api_failure():
281
+ """Test alter_alarm raises ControlError when API returns success=False."""
282
+ api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
283
+ api._refresh_token = AsyncMock(return_value=True)
284
+ api._request = AsyncMock(
285
+ return_value=(
286
+ 200,
287
+ {"success": False, "code": "C000002", "msg": "Internal error"},
288
+ )
289
+ )
290
+
291
+ with pytest.raises(api.ControlError, match="alarm alteration failed"):
292
+ await api.alter_alarm([10086])
@@ -87,3 +87,26 @@ def test_extract_device_info_metadata_battery_fallbacks():
87
87
  assert base_info["packNum"] == 1
88
88
  assert base_info["maxChargePower"] == 30.0
89
89
  assert base_info["maxDischargePower"] == 30.0
90
+
91
+
92
+ def test_extract_device_info_metadata_micro_ess():
93
+ """Test extraction of battery-specific metrics and fallbacks for Micro ESS (type 16) devices."""
94
+ entry = {"metrics": {}, "device_type_code": "16"}
95
+ i_raw = {
96
+ "batCap": "15.0",
97
+ "packNum": "1",
98
+ "maxChargePower": "700",
99
+ "maxDischargePower": "700",
100
+ "swVerWifi": "V01.00.00.01",
101
+ "ratedFrequency": "50",
102
+ }
103
+
104
+ base_info = HyxiApiClient._extract_device_info_metadata(entry, i_raw)
105
+
106
+ assert base_info["batCap"] == 15.0
107
+ assert base_info["packNum"] == 1
108
+ assert base_info["maxChargePower"] == 700.0
109
+ assert base_info["maxDischargePower"] == 700.0
110
+ assert base_info["wifiVer"] == "V01.00.00.01"
111
+ assert base_info["ratedFrequency"] == "50"
112
+ assert "batCap" in entry["metrics"]
@@ -125,39 +125,39 @@ async def test_fetch_device_metrics_api_error(caplog):
125
125
 
126
126
  @pytest.mark.asyncio
127
127
  async def test_fetch_ems_basic_data_no_data(caplog):
128
- """Test that _fetch_ems_basic_data handles empty response gracefully."""
128
+ """Test that _fetch_all_for_device handles empty EMS response gracefully."""
129
129
  caplog.set_level(logging.DEBUG)
130
130
  mock_session = MagicMock()
131
131
  api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
132
132
 
133
133
  # Mock query_ems_basic_details to return {} (no data — the actual return contract)
134
134
  api.query_ems_basic_details = AsyncMock(return_value={})
135
+ api._fetch_device_info = AsyncMock()
136
+ api._fetch_device_metrics = AsyncMock()
135
137
 
136
138
  entry = {"metrics": {}, "device_type_code": "EMS"}
137
- await api._fetch_ems_basic_data("10602251600016", entry)
139
+ await api._fetch_all_for_device("10602251600016", entry, "INVERTER")
138
140
 
139
141
  assert "HYXI EMS telemetry probe returned no data for fefbfd75" in caplog.text
140
- # Ensure entry was not updated with metrics
142
+ # Ensure entry was not updated with EMS metrics
141
143
  assert not entry["metrics"]
142
144
 
143
145
 
144
146
  @pytest.mark.asyncio
145
147
  async def test_fetch_ems_basic_data_error(caplog):
146
- """Test that _fetch_ems_basic_data handles errors from query_ems_basic_details."""
148
+ """Test that _fetch_all_for_device handles errors from query_ems_basic_details."""
147
149
  caplog.set_level(logging.DEBUG)
148
150
  mock_session = MagicMock()
149
151
  api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
150
152
 
151
- # Mock _request to raise an Exception to ensure query_ems_basic_details returns {}
152
153
  api._request = AsyncMock(side_effect=Exception("EMS data fetch failed"))
154
+ api._fetch_device_info = AsyncMock()
155
+ api._fetch_device_metrics = AsyncMock()
153
156
 
154
157
  entry = {"metrics": {}, "device_type_code": "EMS"}
155
- await api._fetch_ems_basic_data("10602251600016", entry)
158
+ await api._fetch_all_for_device("10602251600016", entry, "INVERTER")
156
159
 
157
- # Assert entry['metrics'] is unchanged
158
160
  assert not entry["metrics"]
159
-
160
- # Assert the correct debug log was emitted from _fetch_ems_basic_data due to empty return
161
161
  assert "HYXI EMS telemetry probe returned no data for fefbfd75" in caplog.text
162
162
 
163
163
 
@@ -284,3 +284,41 @@ def test_compute_derived_metrics_zero_edges():
284
284
  assert res_neg["grid_export"] == 0.0
285
285
  assert res_neg["bat_charging"] == 0.01
286
286
  assert res_neg["bat_discharging"] == 0.0
287
+
288
+
289
+ @pytest.mark.asyncio
290
+ async def test_api_parsing_micro_ess_fallbacks():
291
+ """Verify that Micro ESS specific keys (pvPower, gridF) are parsed and correctly trigger derived fallbacks."""
292
+ fake_json = {
293
+ "success": True,
294
+ "data": [
295
+ {"dataKey": "pvPower", "dataValue": "850.5"},
296
+ {"dataKey": "gridF", "dataValue": "50.02"},
297
+ ],
298
+ }
299
+
300
+ mock_response = MagicMock()
301
+ mock_response.json = AsyncMock(return_value=fake_json)
302
+ mock_response.raise_for_status = MagicMock()
303
+
304
+ mock_session = MagicMock()
305
+ mock_session.get.return_value.__aenter__.return_value = mock_response
306
+
307
+ api = HyxiApiClient(
308
+ access_key="test_ak",
309
+ secret_key="test_sk",
310
+ base_url="https://test.com",
311
+ session=mock_session,
312
+ )
313
+
314
+ entry = {"metrics": {}, "device_type_code": "15"} # Micro ESS
315
+
316
+ await api._fetch_device_metrics("SN123", entry)
317
+
318
+ # Raw metrics must be present
319
+ assert entry["metrics"]["pvPower"] == "850.5"
320
+ assert entry["metrics"]["gridF"] == "50.02"
321
+
322
+ # Derived fallback metrics must be computed and present
323
+ assert entry["metrics"]["ppv"] == 850.5
324
+ assert entry["metrics"]["f"] == 50.02
@@ -34,6 +34,21 @@ def mock_state():
34
34
  return FetchState(now="2024-01-01T00:00:00Z")
35
35
 
36
36
 
37
+ @pytest.mark.asyncio
38
+ async def test_process_devices_for_plant_missing_sn_edge_cases(mock_api, mock_state):
39
+ """Test with devices having missing, None, or empty string serial numbers."""
40
+ devices = [
41
+ {"deviceType": "INVERTER"},
42
+ {"deviceSn": None, "deviceType": "INVERTER"},
43
+ {"deviceSn": "", "deviceType": "INVERTER"},
44
+ ]
45
+ with patch("asyncio.gather", new_callable=AsyncMock) as mock_gather:
46
+ await mock_api._process_devices_for_plant(devices, mock_state)
47
+ mock_gather.assert_not_called()
48
+ assert len(mock_state.discovered_sns) == 0
49
+ assert len(mock_state.metric_tasks) == 0
50
+
51
+
37
52
  @pytest.mark.asyncio
38
53
  async def test_process_devices_for_plant_empty(mock_api, mock_state):
39
54
  """Test with an empty list of devices."""
@@ -8,6 +8,7 @@ from unittest.mock import MagicMock
8
8
  # Mock aiohttp before importing the client to avoid ModuleNotFoundError in environments without it
9
9
  sys.modules["aiohttp"] = MagicMock()
10
10
 
11
+ import time
11
12
  from unittest.mock import patch
12
13
 
13
14
  import pytest
@@ -167,6 +168,17 @@ def test_calculate_token_expiration_expiresIn(mock_time, api_client):
167
168
  assert expires_at == 1003300.0
168
169
 
169
170
 
171
+ def test_calculate_token_expiration_missing_expires_in(api_client):
172
+ """Verify missing expires_in calculates default buffer relative to current time."""
173
+ # Rationale: Calling `_calculate_token_expiration({})` and ensuring the result is close
174
+ # to `time.time() + 6600 - 300` would verify this boundary.
175
+ expected = time.time() + 6600 - 300
176
+ expires_at = api_client._calculate_token_expiration({})
177
+
178
+ # Check if the result is within 1 second of expected to account for execution time
179
+ assert expires_at == pytest.approx(expected, abs=1.0)
180
+
181
+
170
182
  @patch("time.time")
171
183
  def test_calculate_token_expiration_expires_in(mock_time, api_client):
172
184
  """Verify 'expires_in' is used correctly."""
File without changes
File without changes
File without changes