Files
DOLPHIN/Observability/TUI/test_dolphin_tui_prefect_offline.py

292 lines
10 KiB
Python
Raw Normal View History

"""
test_dolphin_tui_prefect_offline.py
Verifies Prefect-offline behavior of DolphinDataFetcher and PrefectPanel.
Tests:
- test_fetch_prefect_returns_false_on_connection_error
- test_fetch_prefect_returns_false_on_timeout
- test_fetch_prefect_returns_false_on_non_200
- test_fetch_prefect_does_not_crash
- test_snapshot_prefect_offline_fields
- test_prefect_panel_shows_offline_text
All tests are self-contained and do NOT require a live Prefect instance.
"""
from __future__ import annotations
import asyncio
import sys
import os
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
import types
# ---------------------------------------------------------------------------
# Make sure the TUI module is importable from this directory
# ---------------------------------------------------------------------------
sys.path.insert(0, os.path.dirname(__file__))
# ---------------------------------------------------------------------------
# Stub hazelcast so the import succeeds without the package
# ---------------------------------------------------------------------------
_hz_stub = types.ModuleType("hazelcast")
_hz_stub.HazelcastClient = MagicMock()
sys.modules.setdefault("hazelcast", _hz_stub)
# ---------------------------------------------------------------------------
# Stub textual so dolphin_tui imports cleanly without a terminal
# ---------------------------------------------------------------------------
for _mod in [
"textual",
"textual.app",
"textual.containers",
"textual.widgets",
]:
if _mod not in sys.modules:
sys.modules[_mod] = types.ModuleType(_mod)
_textual_app = sys.modules["textual.app"]
_textual_app.App = object
_textual_app.ComposeResult = object
_textual_containers = sys.modules["textual.containers"]
_textual_containers.Horizontal = object
_textual_widgets = sys.modules["textual.widgets"]
_textual_widgets.Static = object
_textual_widgets.VerticalScroll = object
# ---------------------------------------------------------------------------
# Stub httpx — we will patch individual methods per test
# ---------------------------------------------------------------------------
if "httpx" not in sys.modules:
_httpx_stub = types.ModuleType("httpx")
_httpx_stub.AsyncClient = MagicMock()
_httpx_stub.ConnectError = type("ConnectError", (Exception,), {})
_httpx_stub.TimeoutException = type("TimeoutException", (Exception,), {})
sys.modules["httpx"] = _httpx_stub
# ---------------------------------------------------------------------------
# Now import the module under test
# ---------------------------------------------------------------------------
from dolphin_tui import ( # noqa: E402
DolphinDataFetcher,
DataSnapshot,
PrefectPanel,
)
import httpx # noqa: E402 (the stub or real module)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _run(coro):
"""Run a coroutine in the current event loop."""
return asyncio.get_event_loop().run_until_complete(coro)
def _make_fetcher() -> DolphinDataFetcher:
fetcher = DolphinDataFetcher(hz_host="localhost", hz_port=5701)
fetcher._start_reconnect = MagicMock() # prevent background reconnect tasks
return fetcher
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestFetchPrefectConnectionError(unittest.TestCase):
"""test_fetch_prefect_returns_false_on_connection_error
When httpx raises ConnectError, fetch_prefect() must return (False, []).
"""
def test_fetch_prefect_returns_false_on_connection_error(self):
fetcher = _make_fetcher()
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.get = AsyncMock(side_effect=httpx.ConnectError("refused"))
with patch.object(httpx, "AsyncClient", return_value=mock_client):
result = _run(fetcher.fetch_prefect())
self.assertEqual(result, (False, []))
class TestFetchPrefectTimeout(unittest.TestCase):
"""test_fetch_prefect_returns_false_on_timeout
When httpx raises TimeoutException, fetch_prefect() must return (False, []).
"""
def test_fetch_prefect_returns_false_on_timeout(self):
fetcher = _make_fetcher()
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("timed out"))
with patch.object(httpx, "AsyncClient", return_value=mock_client):
result = _run(fetcher.fetch_prefect())
self.assertEqual(result, (False, []))
class TestFetchPrefectNon200(unittest.TestCase):
"""test_fetch_prefect_returns_false_on_non_200
When /api/health returns HTTP 503, fetch_prefect() must return (False, []).
"""
def test_fetch_prefect_returns_false_on_non_200(self):
fetcher = _make_fetcher()
health_resp = MagicMock()
health_resp.status_code = 503
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.get = AsyncMock(return_value=health_resp)
with patch.object(httpx, "AsyncClient", return_value=mock_client):
healthy, flows = _run(fetcher.fetch_prefect())
self.assertFalse(healthy, "healthy must be False when /api/health returns 503")
# flows may be empty or populated depending on whether the flows call was made;
# the key requirement is that healthy is False
self.assertIsInstance(flows, list)
class TestFetchPrefectDoesNotCrash(unittest.TestCase):
"""test_fetch_prefect_does_not_crash
fetch_prefect() must never raise, even on unexpected exceptions.
"""
def test_fetch_prefect_does_not_crash_on_unexpected_exception(self):
fetcher = _make_fetcher()
# Simulate AsyncClient itself raising an unexpected error
with patch.object(httpx, "AsyncClient", side_effect=RuntimeError("unexpected")):
try:
result = _run(fetcher.fetch_prefect())
except Exception as exc:
self.fail(f"fetch_prefect() raised unexpectedly: {exc}")
self.assertEqual(result, (False, []))
def test_fetch_prefect_does_not_crash_on_os_error(self):
fetcher = _make_fetcher()
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.get = AsyncMock(side_effect=OSError("network unreachable"))
with patch.object(httpx, "AsyncClient", return_value=mock_client):
try:
result = _run(fetcher.fetch_prefect())
except Exception as exc:
self.fail(f"fetch_prefect() raised unexpectedly: {exc}")
self.assertEqual(result, (False, []))
class TestSnapshotPrefectOfflineFields(unittest.TestCase):
"""test_snapshot_prefect_offline_fields
When fetch_prefect() returns (False, []), the assembled DataSnapshot
must have prefect_healthy=False and prefect_flows=[].
"""
def test_snapshot_prefect_offline_fields(self):
snap = DataSnapshot(
prefect_healthy=False,
prefect_flows=[],
)
self.assertFalse(snap.prefect_healthy, "prefect_healthy must be False")
self.assertEqual(snap.prefect_flows, [], "prefect_flows must be empty list")
def test_snapshot_default_is_offline(self):
"""Default DataSnapshot should represent offline state."""
snap = DataSnapshot()
self.assertFalse(snap.prefect_healthy)
self.assertEqual(snap.prefect_flows, [])
class TestPrefectPanelShowsOfflineText(unittest.TestCase):
"""test_prefect_panel_shows_offline_text
When DataSnapshot.prefect_healthy=False, PrefectPanel._render_markup()
must contain the string "PREFECT OFFLINE".
"""
def test_prefect_panel_shows_offline_text_when_unhealthy(self):
panel = PrefectPanel()
snap = DataSnapshot(prefect_healthy=False, prefect_flows=[])
panel._snap = snap
markup = panel._render_markup()
self.assertIn(
"PREFECT OFFLINE",
markup,
f"Expected 'PREFECT OFFLINE' in panel markup, got:\n{markup}",
)
def test_prefect_panel_shows_offline_text_when_snap_is_none(self):
"""Panel must show PREFECT OFFLINE when no snapshot has been set."""
panel = PrefectPanel()
# _snap defaults to None
markup = panel._render_markup()
self.assertIn(
"PREFECT OFFLINE",
markup,
f"Expected 'PREFECT OFFLINE' when snap is None, got:\n{markup}",
)
def test_prefect_panel_does_not_show_offline_when_healthy(self):
"""Sanity check: healthy snapshot should NOT show PREFECT OFFLINE."""
panel = PrefectPanel()
snap = DataSnapshot(prefect_healthy=True, prefect_flows=[])
panel._snap = snap
markup = panel._render_markup()
self.assertNotIn(
"PREFECT OFFLINE",
markup,
f"'PREFECT OFFLINE' should not appear when prefect_healthy=True",
)
self.assertIn("PREFECT ✓", markup)
def test_update_data_does_not_crash_when_offline(self):
"""update_data() must not raise when called with an offline snapshot."""
panel = PrefectPanel()
# Patch the inherited update() method (from Static/object) so it's a no-op
panel.update = MagicMock()
snap = DataSnapshot(prefect_healthy=False, prefect_flows=[])
try:
panel.update_data(snap)
except Exception as exc:
self.fail(f"update_data() raised unexpectedly: {exc}")
panel.update.assert_called_once()
if __name__ == "__main__":
unittest.main()