WebSocket endpoints now accept then close with real codes (4001 auth, 4003 permission) so clients detect auth failure via event.code instead of an opaque 403 handshake, ending the infinite reconnect storm. _authenticate_websocket logs the actual JWT/API-key failure reason. Frontend WS consumers stop on auth rejection and use capped exponential backoff for transient failures. async_network.request() routes proxy 502/503/504 (HTML) responses to NetworkError so migration/maintenance surface a clean 503 instead of a 500 JSON-parse traceback.
429 lines
21 KiB
Python
429 lines
21 KiB
Python
# #region Test.AppModule.WsEndpoint [C:3] [TYPE Module] [SEMANTICS test,app,ws,endpoint,logs]
|
|
# @BRIEF Tests for app.py — websocket_endpoint: full flow, filters, disconnect, AWAITING_INPUT, terminal log, exceptions.
|
|
# @RELATION BINDS_TO -> [App.AppModule]
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from starlette.websockets import WebSocketDisconnect
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────
|
|
|
|
# #region _make_mock_task [C:1] [TYPE Function]
|
|
def _make_mock_task(task_id="task-1", status="RUNNING", plugin_id="test"):
|
|
from datetime import datetime, timezone
|
|
task = MagicMock()
|
|
task.id = task_id; task.plugin_id = plugin_id; task.status = status
|
|
task.started_at = datetime(2025, 1, 15, 10, 0, 0, tzinfo=timezone.utc)
|
|
task.finished_at = None; task.user_id = "user-1"
|
|
task.result = None; task.input_required = False; task.input_request = None
|
|
task.logs = []
|
|
return task
|
|
# #endregion _make_mock_task
|
|
|
|
# #region _make_mock_log_entry [C:1] [TYPE Function]
|
|
def _make_mock_log_entry(msg="Test log", source="plugin", level="INFO"):
|
|
from datetime import datetime, timezone
|
|
ts = datetime(2025, 1, 15, 10, 0, 0, tzinfo=timezone.utc)
|
|
entry = MagicMock()
|
|
entry.source = source; entry.level = level; entry.message = msg; entry.timestamp = ts
|
|
entry.model_dump = MagicMock(return_value={
|
|
"timestamp": ts, "level": level, "message": msg, "source": source,
|
|
})
|
|
return entry
|
|
# #endregion _make_mock_log_entry
|
|
|
|
# #region _make_task_manager_mock [C:1] [TYPE Function]
|
|
def _make_task_manager_mock(task=None, logs=None):
|
|
tm = MagicMock()
|
|
tm.get_task = MagicMock(return_value=task)
|
|
tm.get_task_logs = MagicMock(return_value=logs or [])
|
|
tm.unsubscribe_logs = MagicMock()
|
|
tm.unsubscribe_status = MagicMock()
|
|
tm.unsubscribe_task_events = MagicMock()
|
|
tm.unsubscribe_maintenance_events = MagicMock()
|
|
tm.unsubscribe_dataset_events = MagicMock()
|
|
return tm
|
|
# #endregion _make_task_manager_mock
|
|
|
|
|
|
class TestWebSocketEndpointFull:
|
|
"""websocket_endpoint — full flow."""
|
|
|
|
# #region Test.AppModule.TestAuthRejectedCloses [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_auth_rejected_closes(self):
|
|
from src.app import websocket_endpoint
|
|
ws = MagicMock(); ws.query_params = {}; ws.close = AsyncMock(); ws.accept = AsyncMock()
|
|
with patch("src.app._authenticate_websocket", return_value=False):
|
|
await websocket_endpoint(ws, "task-1")
|
|
# Auth rejection accepts the handshake then closes with 4001 so the client can
|
|
# read event.code instead of an opaque HTTP 403 handshake rejection.
|
|
ws.accept.assert_called_once()
|
|
ws.close.assert_called_once_with(code=4001, reason="Authentication required")
|
|
# #endregion Test.AppModule.TestAuthRejectedCloses
|
|
|
|
# #region Test.AppModule.TestAcceptsAndSendsInitialStatus [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_accepts_and_sends_initial_status(self):
|
|
from src.app import websocket_endpoint
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.send_json = AsyncMock(); ws.close = AsyncMock(); ws.accept = AsyncMock()
|
|
task = _make_mock_task("task-1", "RUNNING")
|
|
log = _make_mock_log_entry(msg="Starting")
|
|
lq, sq = asyncio.Queue(), asyncio.Queue()
|
|
await sq.put({"type": "task_status", "task_id": "task-1", "task": {"status": "SUCCESS"}})
|
|
async def sl(t): return lq
|
|
async def ss(t): return sq
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.app.get_task_manager") as mg,
|
|
):
|
|
tm = _make_task_manager_mock(task=task, logs=[log])
|
|
tm.subscribe_logs = sl; tm.subscribe_status = ss; mg.return_value = tm
|
|
await websocket_endpoint(ws, "task-1", source=None, level=None)
|
|
ws.accept.assert_called_once()
|
|
assert ws.send_json.call_count >= 2
|
|
# #endregion Test.AppModule.TestAcceptsAndSendsInitialStatus
|
|
|
|
# #region Test.AppModule.TestSourceFilter [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_source_filter(self):
|
|
from src.app import websocket_endpoint
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.send_json = AsyncMock(); ws.accept = AsyncMock()
|
|
task = _make_mock_task("task-1", "RUNNING")
|
|
plug = _make_mock_log_entry(msg="Plugin", source="plugin")
|
|
super = _make_mock_log_entry(msg="Superset", source="superset_api")
|
|
lq, sq = asyncio.Queue(), asyncio.Queue()
|
|
await sq.put({"type": "task_status", "task_id": "task-1", "task": {"status": "SUCCESS"}})
|
|
async def sl(t): return lq
|
|
async def ss(t): return sq
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.app.get_task_manager") as mg,
|
|
):
|
|
tm = _make_task_manager_mock(task=task, logs=[plug, super])
|
|
tm.subscribe_logs = sl; tm.subscribe_status = ss; mg.return_value = tm
|
|
await websocket_endpoint(ws, "task-1", source="plugin")
|
|
msgs = [c[0][0].get("message") for c in ws.send_json.call_args_list if isinstance(c[0][0], dict)]
|
|
assert "Superset" not in msgs
|
|
# #endregion Test.AppModule.TestSourceFilter
|
|
|
|
# #region Test.AppModule.TestLevelFilter [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_level_filter(self):
|
|
from src.app import websocket_endpoint
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.send_json = AsyncMock(); ws.accept = AsyncMock()
|
|
task = _make_mock_task("task-1", "RUNNING")
|
|
dbg = _make_mock_log_entry(msg="Debug", level="DEBUG")
|
|
inf = _make_mock_log_entry(msg="Info", level="INFO")
|
|
lq, sq = asyncio.Queue(), asyncio.Queue()
|
|
await sq.put({"type": "task_status", "task_id": "task-1", "task": {"status": "SUCCESS"}})
|
|
async def sl(t): return lq
|
|
async def ss(t): return sq
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.app.get_task_manager") as mg,
|
|
):
|
|
tm = _make_task_manager_mock(task=task, logs=[dbg, inf])
|
|
tm.subscribe_logs = sl; tm.subscribe_status = ss; mg.return_value = tm
|
|
await websocket_endpoint(ws, "task-1", source=None, level="INFO")
|
|
msgs = [c[0][0].get("message") for c in ws.send_json.call_args_list if isinstance(c[0][0], dict)]
|
|
assert "Debug" not in msgs
|
|
assert "Info" in msgs
|
|
# #endregion Test.AppModule.TestLevelFilter
|
|
|
|
# #region Test.AppModule.TestDisconnectInMainLoop [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_in_main_loop(self):
|
|
from src.app import websocket_endpoint
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.send_json = AsyncMock(side_effect=[None, WebSocketDisconnect()])
|
|
ws.close = AsyncMock(); ws.accept = AsyncMock()
|
|
task = _make_mock_task("task-1", "RUNNING")
|
|
lq, sq = asyncio.Queue(), asyncio.Queue()
|
|
await sq.put({"type": "task_status", "task_id": "task-1", "task": {"status": "SUCCESS"}})
|
|
async def sl(t): return lq
|
|
async def ss(t): return sq
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.app.get_task_manager") as mg,
|
|
):
|
|
tm = _make_task_manager_mock(task=task, logs=[])
|
|
tm.subscribe_logs = sl; tm.subscribe_status = ss; mg.return_value = tm
|
|
await websocket_endpoint(ws, "task-1")
|
|
ws.accept.assert_called_once()
|
|
# #endregion Test.AppModule.TestDisconnectInMainLoop
|
|
|
|
# #region Test.AppModule.TestAwaitingInputPrompt [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_awaiting_input_prompt(self):
|
|
from src.app import websocket_endpoint
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.send_json = AsyncMock(); ws.accept = AsyncMock()
|
|
task = _make_mock_task("task-1", "AWAITING_INPUT")
|
|
task.input_request = {"type": "database_password", "databases": ["db1"]}
|
|
lq, sq = asyncio.Queue(), asyncio.Queue()
|
|
await sq.put({"type": "task_status", "task_id": "task-1", "task": {"status": "SUCCESS"}})
|
|
async def sl(t): return lq
|
|
async def ss(t): return sq
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.app.get_task_manager") as mg,
|
|
):
|
|
tm = _make_task_manager_mock(task=task, logs=[])
|
|
tm.subscribe_logs = sl; tm.subscribe_status = ss; mg.return_value = tm
|
|
await websocket_endpoint(ws, "task-1")
|
|
msgs = [c[0][0].get("message") for c in ws.send_json.call_args_list if isinstance(c[0][0], dict)]
|
|
assert any("Task paused for user input" in (m or "") for m in msgs)
|
|
# #endregion Test.AppModule.TestAwaitingInputPrompt
|
|
|
|
# #region Test.AppModule.TestTerminalLogTriggersClose [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_terminal_log_triggers_close(self):
|
|
from src.app import websocket_endpoint
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.send_json = AsyncMock(); ws.accept = AsyncMock()
|
|
task = _make_mock_task("task-1", "RUNNING")
|
|
term = _make_mock_log_entry(msg="Task completed successfully")
|
|
lq, sq = asyncio.Queue(), asyncio.Queue()
|
|
await sq.put({"type": "task_status", "task_id": "task-1", "task": {"status": "SUCCESS"}})
|
|
async def sl(t): return lq
|
|
async def ss(t): return sq
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.app.get_task_manager") as mg,
|
|
):
|
|
tm = _make_task_manager_mock(task=task, logs=[term])
|
|
tm.subscribe_logs = sl; tm.subscribe_status = ss; mg.return_value = tm
|
|
await websocket_endpoint(ws, "task-1")
|
|
ws.accept.assert_called_once()
|
|
# #endregion Test.AppModule.TestTerminalLogTriggersClose
|
|
|
|
|
|
class TestMatchesFilters:
|
|
"""matches_filters — extracted filter logic."""
|
|
|
|
# #region Test.AppModule.TestMatchesFilters [C:2] [TYPE Function]
|
|
def test_matches_filters(self):
|
|
def mf(entry, source_filter=None, level_filter=None):
|
|
lh = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3}
|
|
ml = lh.get(level_filter, 0) if level_filter else 0
|
|
src = getattr(entry, "source", None)
|
|
if source_filter and str(src or "").lower() != source_filter:
|
|
return False
|
|
if level_filter:
|
|
el = lh.get(str(entry.level).upper(), 0)
|
|
if el < ml:
|
|
return False
|
|
return True
|
|
class E:
|
|
def __init__(self, s, l): self.source = s; self.level = l
|
|
assert mf(E("plugin", "INFO")) is True
|
|
assert mf(E("plugin", "INFO"), source_filter="other") is False
|
|
assert mf(E("other", "DEBUG"), level_filter="INFO") is False
|
|
assert mf(E("superset_api", "WARNING"), source_filter="superset_api", level_filter="WARNING") is True
|
|
assert mf(E(None, "INFO"), source_filter="plugin") is False
|
|
# #endregion Test.AppModule.TestMatchesFilters
|
|
|
|
|
|
class TestWebSocketMainLoopCoverage:
|
|
"""Cover the main loop lines 654, 658, 660-677 — non-terminal status, log filter, log forwarding, terminal log message."""
|
|
|
|
# #region Test.AppModule.TestNonTerminalStatusContinue [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_non_terminal_status_continue(self):
|
|
"""Non-terminal status (RUNNING) -> continue at line 654 is hit."""
|
|
from src.app import websocket_endpoint
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.send_json = AsyncMock(); ws.accept = AsyncMock(); ws.close = AsyncMock()
|
|
task = _make_mock_task("task-continue", "RUNNING")
|
|
# Put both a non-terminal status and then a terminal one to end the loop
|
|
lq, sq = asyncio.Queue(), asyncio.Queue()
|
|
await sq.put({"type": "task_status", "task_id": "task-continue", "task": {"status": "RUNNING"}})
|
|
await sq.put({"type": "task_status", "task_id": "task-continue", "task": {"status": "SUCCESS"}})
|
|
async def sl(t): return lq
|
|
async def ss(t): return sq
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.app.get_task_manager") as mg,
|
|
):
|
|
tm = _make_task_manager_mock(task=task, logs=[])
|
|
tm.subscribe_logs = sl; tm.subscribe_status = ss; mg.return_value = tm
|
|
await websocket_endpoint(ws, "task-continue")
|
|
ws.accept.assert_called_once()
|
|
# #endregion Test.AppModule.TestNonTerminalStatusContinue
|
|
|
|
# #region Test.AppModule.TestLogFilterContinueAndForward [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_log_filter_continue_and_forward(self):
|
|
"""Log entry filtered out (line 658) + log entry forwarded (lines 660-662).
|
|
|
|
Uses source filter to exercise the matches_filters rejection path.
|
|
NOTE: This test can exhibit flakiness when run in a batch due to
|
|
global task_manager singleton state leakage between test files.
|
|
"""
|
|
from src.app import websocket_endpoint
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.send_json = AsyncMock(); ws.accept = AsyncMock(); ws.close = AsyncMock()
|
|
task = _make_mock_task("task-filter", "RUNNING")
|
|
|
|
# Log entry that won't match source filter — will be filtered out at line 658
|
|
filtered_log = _make_mock_log_entry(msg="Filtered out", source="superset_api", level="DEBUG")
|
|
# Log entry that will match — will be forwarded
|
|
passed_log = _make_mock_log_entry(msg="Passed through", source="plugin", level="INFO")
|
|
|
|
lq, sq = asyncio.Queue(), asyncio.Queue()
|
|
# Non-terminal status first, then the two log entries, then terminal
|
|
await sq.put({"type": "task_status", "task_id": "task-filter", "task": {"status": "RUNNING"}})
|
|
await lq.put(filtered_log)
|
|
await lq.put(passed_log)
|
|
await sq.put({"type": "task_status", "task_id": "task-filter", "task": {"status": "SUCCESS"}})
|
|
|
|
async def sl(t): return lq
|
|
async def ss(t): return sq
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.app.get_task_manager") as mg,
|
|
):
|
|
tm = _make_task_manager_mock(task=task, logs=[])
|
|
tm.subscribe_logs = sl; tm.subscribe_status = ss; mg.return_value = tm
|
|
# Pass source="plugin" so superset_api log entry is filtered out (line 658)
|
|
await websocket_endpoint(ws, "task-filter", source="plugin")
|
|
ws.accept.assert_called_once()
|
|
# #endregion Test.AppModule.TestLogFilterContinueAndForward
|
|
|
|
# #region Test.AppModule.TestTerminalLogMessageDetected [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_terminal_log_message_detected(self):
|
|
"""Log entry with 'Task completed successfully' triggers delay at lines 669-677."""
|
|
from src.app import websocket_endpoint
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.send_json = AsyncMock(); ws.accept = AsyncMock(); ws.close = AsyncMock()
|
|
task = _make_mock_task("task-term-msg", "RUNNING")
|
|
|
|
term_log = _make_mock_log_entry(msg="Task completed successfully", source="plugin", level="INFO")
|
|
|
|
lq, sq = asyncio.Queue(), asyncio.Queue()
|
|
await sq.put({"type": "task_status", "task_id": "task-term-msg", "task": {"status": "RUNNING"}})
|
|
await lq.put(term_log)
|
|
await sq.put({"type": "task_status", "task_id": "task-term-msg", "task": {"status": "SUCCESS"}})
|
|
|
|
async def sl(t): return lq
|
|
async def ss(t): return sq
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.app.get_task_manager") as mg,
|
|
patch("src.app.logger") as mock_logger,
|
|
):
|
|
tm = _make_task_manager_mock(task=task, logs=[])
|
|
tm.subscribe_logs = sl; tm.subscribe_status = ss; mg.return_value = tm
|
|
await websocket_endpoint(ws, "task-term-msg")
|
|
ws.accept.assert_called_once()
|
|
# #endregion Test.AppModule.TestTerminalLogMessageDetected
|
|
|
|
|
|
class TestWebSocketMainLoopExceptions:
|
|
"""Generic Exception re-raise in WS main loops."""
|
|
|
|
# #region Test.AppModule.TestWsEndpointGenericException [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_ws_endpoint_generic_exception(self):
|
|
from src.app import websocket_endpoint
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.send_json = AsyncMock(); ws.accept = AsyncMock()
|
|
task = _make_mock_task("task-1", "RUNNING")
|
|
lq, sq = asyncio.Queue(), asyncio.Queue()
|
|
await sq.put("not-a-dict") # will cause AttributeError
|
|
async def sl(t): return lq
|
|
async def ss(t): return sq
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.app.get_task_manager") as mg,
|
|
):
|
|
tm = _make_task_manager_mock(task=task, logs=[])
|
|
tm.subscribe_logs = sl; tm.subscribe_status = ss; mg.return_value = tm
|
|
with pytest.raises(Exception):
|
|
await websocket_endpoint(ws, "task-1")
|
|
# #endregion Test.AppModule.TestWsEndpointGenericException
|
|
|
|
# #region Test.AppModule.TestTaskEventsGenericException [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_task_events_generic_exception(self):
|
|
from src.app import task_events_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.send_json = AsyncMock(side_effect=RuntimeError("crash"))
|
|
ws.accept = AsyncMock()
|
|
q = asyncio.Queue()
|
|
await q.put({"type": "task_status"})
|
|
async def sub(): return q
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.app.get_task_manager") as mg,
|
|
):
|
|
tm = MagicMock(); tm.subscribe_task_events = sub; tm.unsubscribe_task_events = MagicMock()
|
|
mg.return_value = tm
|
|
with pytest.raises(RuntimeError, match="crash"):
|
|
await task_events_websocket(ws)
|
|
# #endregion Test.AppModule.TestTaskEventsGenericException
|
|
|
|
# #region Test.AppModule.TestMaintenanceEventsGenericException [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_maintenance_events_generic_exception(self):
|
|
from src.app import maintenance_events_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.send_json = AsyncMock(side_effect=ValueError("bad"))
|
|
ws.accept = AsyncMock()
|
|
q = asyncio.Queue(); await q.put({"type": "maintenance.event_created"})
|
|
async def sub(): return q
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.app.get_task_manager") as mg,
|
|
):
|
|
tm = MagicMock(); tm.subscribe_maintenance_events = sub; tm.unsubscribe_maintenance_events = MagicMock()
|
|
mg.return_value = tm
|
|
with pytest.raises(ValueError, match="bad"):
|
|
await maintenance_events_websocket(ws)
|
|
# #endregion Test.AppModule.TestMaintenanceEventsGenericException
|
|
|
|
# #region Test.AppModule.TestDatasetWsGenericException [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_dataset_ws_generic_exception(self):
|
|
from src.app import dataset_websocket_endpoint
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.send_json = AsyncMock(side_effect=RuntimeError("fail"))
|
|
ws.accept = AsyncMock()
|
|
q = asyncio.Queue(); await q.put({"type": "dataset.updated"})
|
|
async def sub(e): return q
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.app.get_task_manager") as mg,
|
|
):
|
|
tm = MagicMock(); tm.subscribe_dataset_events = sub; tm.unsubscribe_dataset_events = MagicMock()
|
|
mg.return_value = tm
|
|
await dataset_websocket_endpoint(ws, "env-1")
|
|
ws.accept.assert_called_once()
|
|
# #endregion Test.AppModule.TestDatasetWsGenericException
|
|
# #endregion Test.AppModule.WsEndpoint
|