Переглянути джерело

Fix API status parsing to handle 503 degraded responses

/status can return HTTP 503 with a valid degraded-status body instead
of 200. raise_for_status() was throwing before the body was parsed,
so the coordinator fell back to status="unknown" instead of surfacing
the real degraded state and per-service failure reasons.
ZiB 3 місяців тому
батько
коміт
af14f2145a
2 змінених файлів з 105 додано та 1 видалено
  1. 2 1
      custom_components/room_cal_ha/backends/api.py
  2. 103 0
      tests/test_api_backend.py

+ 2 - 1
custom_components/room_cal_ha/backends/api.py

@@ -92,7 +92,8 @@ class RoomCalApiBackend(ReservationBackend):
       async with session.get(f"{self._base_url()}/status", headers=self._headers()) as resp:
       async with session.get(f"{self._base_url()}/status", headers=self._headers()) as resp:
         if resp.status in (401, 403):
         if resp.status in (401, 403):
           raise BackendAuthError(f"API returned HTTP {resp.status}")
           raise BackendAuthError(f"API returned HTTP {resp.status}")
-        resp.raise_for_status()
+        if resp.status not in (200, 503):
+          resp.raise_for_status()
         status_data: dict = await resp.json()
         status_data: dict = await resp.json()
     except BackendAuthError:
     except BackendAuthError:
       raise
       raise

+ 103 - 0
tests/test_api_backend.py

@@ -40,6 +40,27 @@ def _mock_session(status: int, json_data=None):
   return session
   return session
 
 
 
 
+def _mock_multi_session(responses: dict):
+  """Return a mock session that dispatches by URL suffix to (status, json_data) pairs."""
+
+  @asynccontextmanager
+  async def _get(url, *args, **kwargs):
+    for suffix, (status, json_data) in responses.items():
+      if url.endswith(suffix):
+        resp = MagicMock()
+        resp.status = status
+        resp.json = AsyncMock(return_value=json_data)
+        if status >= 400 and status not in (401, 403, 503):
+          resp.raise_for_status.side_effect = Exception(f"HTTP {status}")
+        yield resp
+        return
+    raise AssertionError(f"Unexpected URL: {url}")
+
+  session = MagicMock()
+  session.get = _get
+  return session
+
+
 def _mock_session_raises(exc):
 def _mock_session_raises(exc):
   """Return a session whose .get() call raises immediately."""
   """Return a session whose .get() call raises immediately."""
   session = MagicMock()
   session = MagicMock()
@@ -281,3 +302,85 @@ async def test_fetch_500_raises_backend_connect_error(hass: HomeAssistant):
     pytest.raises(BackendConnectError),
     pytest.raises(BackendConnectError),
   ):
   ):
     await backend.async_fetch()
     await backend.async_fetch()
+
+
+# ---------------------------------------------------------------------------
+# async_fetch_status
+# ---------------------------------------------------------------------------
+
+async def test_fetch_status_ok(hass: HomeAssistant):
+  status_body = {
+    "status": "ok",
+    "checks": {"store_connected": {"ok": True}},
+    "checked_at": "2026-07-28T18:52:39.702615+00:00",
+  }
+  session = _mock_multi_session({
+    "/status": (200, status_body),
+    "/services": (200, []),
+  })
+  backend = RoomCalApiBackend(hass, _make_entry())
+
+  with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session):
+    result = await backend.async_fetch_status()
+
+  assert result.status == "ok"
+  assert result.stale is False
+
+
+async def test_fetch_status_503_returns_degraded(hass: HomeAssistant):
+  """A 503 with a degraded body must be parsed, not swallowed into a connect error.
+
+  This is the main signal the API Health sensor exists for: the backend service
+  reports a specific reason (e.g. stale Booking.com credentials) while still
+  returning HTTP 503, since some checks failing is considered a degraded-but-
+  reachable state rather than a connectivity failure.
+  """
+  status_body = {
+    "status": "degraded",
+    "checks": {
+      "store_connected": {"ok": True},
+      "credentials": {
+        "airbnb": {"ok": True},
+        "booking": {"ok": False, "reason": "Booking.com credentials are stale — run: refresh-creds"},
+      },
+    },
+    "checked_at": "2026-07-28T18:52:39.702615+00:00",
+  }
+  session = _mock_multi_session({
+    "/status": (503, status_body),
+    "/services": (200, []),
+  })
+  backend = RoomCalApiBackend(hass, _make_entry())
+
+  with patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session):
+    result = await backend.async_fetch_status()
+
+  assert result.status == "degraded"
+  assert result.checks["store_connected"]["ok"] is True
+  assert result.checks["credentials"]["booking"]["ok"] is False
+  assert result.checks["credentials"]["booking"]["reason"] == "Booking.com credentials are stale — run: refresh-creds"
+  from datetime import datetime, timezone
+  assert result.checked_at == datetime(2026, 7, 28, 18, 52, 39, 702615, tzinfo=timezone.utc)
+
+
+async def test_fetch_status_401_raises_backend_auth_error(hass: HomeAssistant):
+  session = _mock_multi_session({"/status": (401, {})})
+  backend = RoomCalApiBackend(hass, _make_entry())
+
+  with (
+    patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session),
+    pytest.raises(BackendAuthError),
+  ):
+    await backend.async_fetch_status()
+
+
+async def test_fetch_status_500_raises_backend_connect_error(hass: HomeAssistant):
+  """A genuine server error (not a degraded-status body) still surfaces as a connect error."""
+  session = _mock_multi_session({"/status": (500, {})})
+  backend = RoomCalApiBackend(hass, _make_entry())
+
+  with (
+    patch("custom_components.room_cal_ha.backends.api.async_get_clientsession", return_value=session),
+    pytest.raises(BackendConnectError),
+  ):
+    await backend.async_fetch_status()