292 lines
10 KiB
Python
292 lines
10 KiB
Python
|
|
"""
|
||
|
|
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()
|