- ~60 new/extended test files across api, core, plugins, services, schemas: routes, superset clients, task_manager, lineage, git, translate, dashboard-testing, load-testing, migration, llm_analysis, scheduler, ssl - .coveragerc: enable branch coverage; exclude src/__tests__ (test files) and src/scripts (CLI/ops tools) from the denominator - bug fixes found while testing: * settings: PUT /settings/reports registered under duplicated prefix * schemas/lineage: FleetReportDTO missing run_status (route always 500) * dashboard_testing/baseline_inheritance: visual entry read wrong field * superset_client/_databases: logger extra name shadowed LogRecord attr * routes/datasets: _yaml_string_paths recursion without yield from * translate/sql_generator: restore explicit-type timestamp contract * baseline_catalog: remove unreachable dashboard_id fallback - conftest fixes: pytest_plugins to rootdir conftest (pytest 9), test filename collision, TMPDIR-safe integration fixtures
547 lines
26 KiB
Python
547 lines
26 KiB
Python
# #region Test.AppModule.WsEvents [C:3] [TYPE Module] [SEMANTICS test,app,ws,events,maintenance,translate,dataset]
|
|
# @BRIEF Tests for app.py — task_events_websocket, maintenance_events_websocket, dataset_websocket_endpoint, translate_run_websocket.
|
|
# @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
|
|
|
|
|
|
class TestTaskEventsWebSocket:
|
|
"""task_events_websocket — global task events stream."""
|
|
|
|
# #region Test.AppModule.TestAuthRejected [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_auth_rejected(self):
|
|
from src.app import task_events_websocket
|
|
ws = MagicMock(); ws.query_params = {}; ws.close = AsyncMock(); ws.accept = AsyncMock()
|
|
with patch("src.app._authenticate_websocket", return_value=False):
|
|
await task_events_websocket(ws)
|
|
ws.accept.assert_called_once()
|
|
ws.close.assert_called_once_with(code=4001, reason="Authentication required")
|
|
# #endregion Test.AppModule.TestAuthRejected
|
|
|
|
# #region Test.AppModule.TestPermissionRejected [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_permission_rejected(self):
|
|
from src.app import task_events_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.close = AsyncMock(); ws.accept = AsyncMock()
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=False),
|
|
):
|
|
await task_events_websocket(ws)
|
|
ws.accept.assert_called_once()
|
|
ws.close.assert_called_once_with(code=4003, reason="Insufficient permissions")
|
|
# #endregion Test.AppModule.TestPermissionRejected
|
|
|
|
# #region Test.AppModule.TestAcceptsAndStreams [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_accepts_and_streams(self):
|
|
from src.app import task_events_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.send_json = AsyncMock(side_effect=[None, WebSocketDisconnect()])
|
|
ws.accept = AsyncMock()
|
|
q = asyncio.Queue()
|
|
await q.put({"type": "task_status", "task_id": "t1", "task": {"status": "RUNNING"}})
|
|
await q.put({"type": "task_status", "task_id": "t1", "task": {"status": "COMPLETED"}})
|
|
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
|
|
await task_events_websocket(ws)
|
|
ws.accept.assert_called_once()
|
|
ws.send_json.assert_any_call({"type": "task_status", "task_id": "t1", "task": {"status": "RUNNING"}})
|
|
tm.unsubscribe_task_events.assert_called_once()
|
|
# #endregion Test.AppModule.TestAcceptsAndStreams
|
|
|
|
|
|
class TestMaintenanceEventsWebSocket:
|
|
"""maintenance_events_websocket — maintenance event stream."""
|
|
|
|
# #region Test.AppModule.TestAuthRejected [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_auth_rejected(self):
|
|
from src.app import maintenance_events_websocket
|
|
ws = MagicMock(); ws.query_params = {}; ws.close = AsyncMock(); ws.accept = AsyncMock()
|
|
with patch("src.app._authenticate_websocket", return_value=False):
|
|
await maintenance_events_websocket(ws)
|
|
ws.close.assert_called_once_with(code=4001, reason="Authentication required")
|
|
# #endregion Test.AppModule.TestAuthRejected
|
|
|
|
# #region Test.AppModule.TestPermissionRejected [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_permission_rejected(self):
|
|
from src.app import maintenance_events_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.close = AsyncMock(); ws.accept = AsyncMock()
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=False),
|
|
):
|
|
await maintenance_events_websocket(ws)
|
|
ws.accept.assert_called_once()
|
|
ws.close.assert_called_once_with(code=4003, reason="Insufficient permissions")
|
|
# #endregion Test.AppModule.TestPermissionRejected
|
|
|
|
# #region Test.AppModule.TestAccepts [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_accepts(self):
|
|
from src.app import maintenance_events_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.send_json = AsyncMock(side_effect=[None, WebSocketDisconnect()])
|
|
ws.accept = AsyncMock()
|
|
q = asyncio.Queue()
|
|
await q.put({"type": "maintenance.event_created", "maintenance_id": "m-1"})
|
|
await q.put({"type": "maintenance.event_ended", "maintenance_id": "m-1"})
|
|
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
|
|
await maintenance_events_websocket(ws)
|
|
ws.accept.assert_called_once()
|
|
evt = ws.send_json.call_args_list[0][0][0]
|
|
assert evt.get("type") == "maintenance.event_created"
|
|
tm.unsubscribe_maintenance_events.assert_called_once()
|
|
# #endregion Test.AppModule.TestAccepts
|
|
|
|
|
|
class TestDatasetWebSocket:
|
|
"""dataset_websocket_endpoint — dataset.updated event stream."""
|
|
|
|
# #region Test.AppModule.TestAuthRejected [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_auth_rejected(self):
|
|
from src.app import dataset_websocket_endpoint
|
|
ws = MagicMock(); ws.query_params = {}; ws.close = AsyncMock(); ws.accept = AsyncMock()
|
|
with patch("src.app._authenticate_websocket", return_value=False):
|
|
await dataset_websocket_endpoint(ws, "env-1")
|
|
ws.close.assert_called_once_with(code=4001, reason="Authentication required")
|
|
# #endregion Test.AppModule.TestAuthRejected
|
|
|
|
# #region Test.AppModule.TestPermissionRejected [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_permission_rejected(self):
|
|
from src.app import dataset_websocket_endpoint
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.close = AsyncMock(); ws.accept = AsyncMock()
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=False),
|
|
):
|
|
await dataset_websocket_endpoint(ws, "env-1")
|
|
ws.accept.assert_called_once()
|
|
ws.close.assert_called_once_with(code=4003, reason="Insufficient permissions")
|
|
# #endregion Test.AppModule.TestPermissionRejected
|
|
|
|
# #region Test.AppModule.TestAccepts [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_accepts(self):
|
|
from src.app import dataset_websocket_endpoint
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.send_json = AsyncMock(side_effect=[None, WebSocketDisconnect()])
|
|
ws.accept = AsyncMock()
|
|
q = asyncio.Queue()
|
|
await q.put({"type": "dataset.updated", "dataset_id": "ds-1"})
|
|
await q.put({"type": "dataset.updated", "dataset_id": "ds-2"})
|
|
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()
|
|
tm.unsubscribe_dataset_events.assert_called_once()
|
|
# #endregion Test.AppModule.TestAccepts
|
|
|
|
|
|
class TestTranslateRunWebSocket:
|
|
"""translate_run_websocket — translation run progress stream."""
|
|
|
|
# #region Test.AppModule.TestAuthRejected [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_auth_rejected(self):
|
|
from src.app import translate_run_websocket
|
|
ws = MagicMock(); ws.query_params = {}; ws.close = AsyncMock(); ws.accept = AsyncMock()
|
|
with patch("src.app._authenticate_websocket", return_value=False):
|
|
await translate_run_websocket(ws, "run-1")
|
|
ws.close.assert_called_once_with(code=4001, reason="Authentication required")
|
|
# #endregion Test.AppModule.TestAuthRejected
|
|
|
|
# #region Test.AppModule.TestPermissionRejected [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_permission_rejected(self):
|
|
from src.app import translate_run_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.close = AsyncMock(); ws.accept = AsyncMock()
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=False),
|
|
):
|
|
await translate_run_websocket(ws, "run-1")
|
|
ws.accept.assert_called_once()
|
|
ws.close.assert_called_once_with(code=4003, reason="Insufficient permissions")
|
|
# #endregion Test.AppModule.TestPermissionRejected
|
|
|
|
# #region Test.AppModule.TestAcceptsAndStreams [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_accepts_and_streams(self):
|
|
from src.app import translate_run_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.send_json = AsyncMock(); ws.accept = AsyncMock()
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.core.database.SessionLocal") as msl,
|
|
patch("src.plugins.translate.orchestrator_aggregator.TranslationResultAggregator") as ma,
|
|
patch("src.plugins.translate.events.TranslationEventLog"),
|
|
):
|
|
msl.return_value = MagicMock()
|
|
agg = MagicMock()
|
|
agg.get_run_status.return_value = {
|
|
"status": "COMPLETED", "run_id": "run-1",
|
|
"total_records": 100, "successful_records": 100,
|
|
"failed_records": 0, "skipped_records": 0,
|
|
}
|
|
ma.return_value = agg
|
|
await translate_run_websocket(ws, "run-1")
|
|
ws.accept.assert_called_once()
|
|
sent = ws.send_json.call_args[0][0]
|
|
assert sent.get("status") == "COMPLETED"
|
|
assert sent.get("progressPct") == 100
|
|
# #endregion Test.AppModule.TestAcceptsAndStreams
|
|
|
|
# #region Test.AppModule.TestErrorTick [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_error_tick(self):
|
|
from src.app import translate_run_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.send_json = AsyncMock(); ws.accept = AsyncMock()
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.core.database.SessionLocal", side_effect=Exception("DB failed")),
|
|
):
|
|
await translate_run_websocket(ws, "run-1")
|
|
ws.accept.assert_called_once()
|
|
sent = ws.send_json.call_args[0][0]
|
|
assert "error" in sent
|
|
assert sent.get("error_type") == "Exception"
|
|
assert "DB failed" in sent["error"]
|
|
# #endregion Test.AppModule.TestErrorTick
|
|
|
|
# #region Test.AppModule.TestNonTerminalTick [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_non_terminal_tick(self):
|
|
"""Run progresses through non-terminal status to COMPLETED (covers line 875 sleep)."""
|
|
from src.app import translate_run_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.send_json = AsyncMock(); ws.accept = AsyncMock()
|
|
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.core.database.SessionLocal") as msl,
|
|
patch("src.plugins.translate.orchestrator_aggregator.TranslationResultAggregator") as ma,
|
|
patch("src.plugins.translate.events.TranslationEventLog"),
|
|
):
|
|
msl.return_value = MagicMock()
|
|
agg = MagicMock()
|
|
# Side effect: first tick returns RUNNING, second returns COMPLETED
|
|
agg.get_run_status.side_effect = [
|
|
{"status": "RUNNING", "run_id": "run-1",
|
|
"total_records": 100, "successful_records": 50,
|
|
"failed_records": 0, "skipped_records": 0},
|
|
{"status": "COMPLETED", "run_id": "run-1",
|
|
"total_records": 100, "successful_records": 100,
|
|
"failed_records": 0, "skipped_records": 0},
|
|
]
|
|
ma.return_value = agg
|
|
await translate_run_websocket(ws, "run-1")
|
|
ws.accept.assert_called_once()
|
|
assert ws.send_json.call_count >= 2
|
|
first = ws.send_json.call_args_list[0][0][0]
|
|
assert first.get("status") == "RUNNING"
|
|
# #endregion Test.AppModule.TestNonTerminalTick
|
|
|
|
# #region Test.AppModule.TestWsDisconnectInErrorHandler [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_ws_disconnect_in_error_handler(self):
|
|
"""Tick fails after status send; error frame send raises WebSocketDisconnect — no crash."""
|
|
from src.app import translate_run_websocket
|
|
from starlette.websockets import WebSocketDisconnect
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.accept = AsyncMock()
|
|
# First send_json (status) ok; second (error frame) raises disconnect
|
|
ws.send_json = AsyncMock(side_effect=[None, WebSocketDisconnect()])
|
|
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.core.database.SessionLocal") as msl,
|
|
patch("src.plugins.translate.orchestrator_aggregator.TranslationResultAggregator") as ma,
|
|
patch("src.plugins.translate.events.TranslationEventLog"),
|
|
):
|
|
msl.return_value = MagicMock()
|
|
msl.side_effect = [msl.return_value, Exception("Second tick crash")]
|
|
agg = MagicMock()
|
|
agg.get_run_status.return_value = {
|
|
"status": "RUNNING", "run_id": "run-1",
|
|
"total_records": 100, "successful_records": 0,
|
|
"failed_records": 0, "skipped_records": 0,
|
|
}
|
|
ma.return_value = agg
|
|
await translate_run_websocket(ws, "run-1")
|
|
ws.accept.assert_called_once()
|
|
assert ws.send_json.call_count == 2
|
|
# #endregion Test.AppModule.TestWsDisconnectInErrorHandler
|
|
|
|
# #region Test.AppModule.TestClientGoneOnStatusSend [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_client_gone_on_status_send(self):
|
|
"""send_json(status) raises RuntimeError close-already-sent → no error frame, clean exit."""
|
|
from src.app import translate_run_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.accept = AsyncMock()
|
|
ws.send_json = AsyncMock(
|
|
side_effect=RuntimeError('Cannot call "send" once a close message has been sent.')
|
|
)
|
|
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.core.database.SessionLocal") as msl,
|
|
patch("src.plugins.translate.orchestrator_aggregator.TranslationResultAggregator") as ma,
|
|
patch("src.plugins.translate.events.TranslationEventLog"),
|
|
):
|
|
msl.return_value = MagicMock()
|
|
agg = MagicMock()
|
|
agg.get_run_status.return_value = {
|
|
"status": "RUNNING", "run_id": "run-1",
|
|
"total_records": 100, "successful_records": 0,
|
|
"failed_records": 0, "skipped_records": 0,
|
|
}
|
|
ma.return_value = agg
|
|
await translate_run_websocket(ws, "run-1")
|
|
ws.accept.assert_called_once()
|
|
# Only the failed status send — must not attempt a second error send
|
|
assert ws.send_json.call_count == 1
|
|
# #endregion Test.AppModule.TestClientGoneOnStatusSend
|
|
|
|
# #region Test.AppModule.TestWsDisconnectOnStatusSend [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_ws_disconnect_on_status_send(self):
|
|
"""WebSocketDisconnect on status send re-raises to outer handler (clean disconnect)."""
|
|
from src.app import translate_run_websocket
|
|
from starlette.websockets import WebSocketDisconnect
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.accept = AsyncMock()
|
|
ws.send_json = AsyncMock(side_effect=WebSocketDisconnect())
|
|
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.core.database.SessionLocal") as msl,
|
|
patch("src.plugins.translate.orchestrator_aggregator.TranslationResultAggregator") as ma,
|
|
patch("src.plugins.translate.events.TranslationEventLog"),
|
|
):
|
|
msl.return_value = MagicMock()
|
|
agg = MagicMock()
|
|
agg.get_run_status.return_value = {
|
|
"status": "RUNNING", "run_id": "run-1",
|
|
"total_records": 100, "successful_records": 0,
|
|
"failed_records": 0, "skipped_records": 0,
|
|
}
|
|
ma.return_value = agg
|
|
await translate_run_websocket(ws, "run-1")
|
|
ws.accept.assert_called_once()
|
|
assert ws.send_json.call_count == 1
|
|
# #endregion Test.AppModule.TestWsDisconnectOnStatusSend
|
|
|
|
# #region Test.AppModule.TestGenericExceptionOuter [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_generic_exception_outer(self):
|
|
"""Tick fails → error send raises ValueError → skip error frame, exit cleanly."""
|
|
from src.app import translate_run_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.accept = AsyncMock()
|
|
ws.send_json = AsyncMock(side_effect=[None, ValueError("Send failed")])
|
|
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.core.database.SessionLocal") as msl,
|
|
patch("src.plugins.translate.orchestrator_aggregator.TranslationResultAggregator") as ma,
|
|
patch("src.plugins.translate.events.TranslationEventLog"),
|
|
):
|
|
msl.return_value = MagicMock()
|
|
msl.side_effect = [msl.return_value, Exception("Second tick crash")]
|
|
agg = MagicMock()
|
|
agg.get_run_status.return_value = {
|
|
"status": "RUNNING", "run_id": "run-1",
|
|
"total_records": 100, "successful_records": 0,
|
|
"failed_records": 0, "skipped_records": 0,
|
|
}
|
|
ma.return_value = agg
|
|
await translate_run_websocket(ws, "run-1")
|
|
ws.accept.assert_called_once()
|
|
assert ws.send_json.call_count == 2
|
|
# #endregion Test.AppModule.TestGenericExceptionOuter
|
|
|
|
# #region Test.AppModule.TestOuterExceptionGeneric [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_outer_exception_generic(self):
|
|
"""Loop-level await failing (asyncio.sleep) escapes the tick handler -> outer generic handler."""
|
|
from src.app import translate_run_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.accept = AsyncMock(); ws.send_json = AsyncMock()
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.core.database.SessionLocal") as msl,
|
|
patch("src.plugins.translate.orchestrator_aggregator.TranslationResultAggregator") as ma,
|
|
patch("src.plugins.translate.events.TranslationEventLog"),
|
|
patch("asyncio.sleep", side_effect=RuntimeError("boom")),
|
|
patch("src.app.logger.explore") as mexplore,
|
|
):
|
|
msl.return_value = MagicMock()
|
|
agg = MagicMock()
|
|
agg.get_run_status.return_value = {
|
|
"status": "RUNNING", "run_id": "run-1",
|
|
"total_records": 10, "successful_records": 5,
|
|
"failed_records": 0, "skipped_records": 0,
|
|
}
|
|
ma.return_value = agg
|
|
await translate_run_websocket(ws, "run-1")
|
|
ws.accept.assert_called_once()
|
|
assert any("Translate run WS error" in str(c) for c in mexplore.call_args_list)
|
|
# #endregion Test.AppModule.TestOuterExceptionGeneric
|
|
|
|
# #region Test.AppModule.TestOuterExceptionClientGone [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_outer_exception_client_gone(self):
|
|
"""Loop-level await raising a close-already-sent RuntimeError -> client-gone log path."""
|
|
from src.app import translate_run_websocket
|
|
ws = MagicMock(); ws.query_params = {"token": "valid"}
|
|
ws.accept = AsyncMock(); ws.send_json = AsyncMock()
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.core.database.SessionLocal") as msl,
|
|
patch("src.plugins.translate.orchestrator_aggregator.TranslationResultAggregator") as ma,
|
|
patch("src.plugins.translate.events.TranslationEventLog"),
|
|
patch("asyncio.sleep", side_effect=RuntimeError('Cannot call "send" once a close message has been sent.')),
|
|
patch("src.app.logger.reason") as mreason,
|
|
):
|
|
msl.return_value = MagicMock()
|
|
agg = MagicMock()
|
|
agg.get_run_status.return_value = {
|
|
"status": "RUNNING", "run_id": "run-1",
|
|
"total_records": 10, "successful_records": 5,
|
|
"failed_records": 0, "skipped_records": 0,
|
|
}
|
|
ma.return_value = agg
|
|
await translate_run_websocket(ws, "run-1")
|
|
ws.accept.assert_called_once()
|
|
assert any("Translate run WS client gone" in str(c) for c in mreason.call_args_list)
|
|
# #endregion Test.AppModule.TestOuterExceptionClientGone
|
|
# #endregion Test.AppModule.WsEvents
|
|
|
|
|
|
# #region Test.AppModule.LogsStatusLoop [C:3] [TYPE Class] [SEMANTICS test,app,ws,logs,task_status,leak]
|
|
# @BRIEF Regression: the /ws/logs/{task_id} loop must not leak pending queue.get() coroutines,
|
|
# otherwise the terminal task_status (with the structured result) is swallowed.
|
|
class _FakeLogEntry:
|
|
def __init__(self, message: str):
|
|
self.message = message
|
|
self.level = "INFO"
|
|
self.source = "plugin"
|
|
from datetime import datetime
|
|
self.timestamp = datetime.now()
|
|
|
|
def model_dump(self) -> dict:
|
|
return {
|
|
"message": self.message,
|
|
"level": self.level,
|
|
"source": self.source,
|
|
"timestamp": self.timestamp,
|
|
}
|
|
|
|
|
|
class TestLogsStatusLoop:
|
|
@pytest.mark.asyncio
|
|
async def test_terminal_status_forwarded_after_log(self):
|
|
"""A log entry followed by a terminal status must both reach the client."""
|
|
from src.app import websocket_endpoint
|
|
|
|
ws = MagicMock()
|
|
ws.query_params = {"token": "valid"}
|
|
ws.accept = AsyncMock()
|
|
ws.send_json = AsyncMock()
|
|
ws.close = AsyncMock()
|
|
|
|
log_queue: asyncio.Queue = asyncio.Queue()
|
|
status_queue: asyncio.Queue = asyncio.Queue()
|
|
|
|
tm = MagicMock()
|
|
tm.subscribe_logs = AsyncMock(return_value=log_queue)
|
|
tm.subscribe_status = AsyncMock(return_value=status_queue)
|
|
tm.unsubscribe_logs = MagicMock()
|
|
tm.unsubscribe_status = MagicMock()
|
|
tm.get_task = MagicMock(return_value=None)
|
|
tm.get_task_logs = MagicMock(return_value=[])
|
|
|
|
with (
|
|
patch("src.app._authenticate_websocket", return_value=True),
|
|
patch("src.app._authorize_websocket", return_value=True),
|
|
patch("src.app.get_task_manager", return_value=tm),
|
|
):
|
|
runner = asyncio.create_task(websocket_endpoint(ws, "t1"))
|
|
|
|
# Let the endpoint reach its main listen loop.
|
|
await asyncio.sleep(0.05)
|
|
|
|
# 1) A log entry arrives → processed first (without the fix this leaks a status waiter).
|
|
await log_queue.put(_FakeLogEntry("some log"))
|
|
for _ in range(200):
|
|
if ws.send_json.call_count >= 1:
|
|
break
|
|
await asyncio.sleep(0.01)
|
|
|
|
# 2) Terminal status arrives → must be forwarded, not swallowed by a leaked waiter.
|
|
await status_queue.put({
|
|
"type": "task_status",
|
|
"task_id": "t1",
|
|
"task": {"status": "SUCCESS", "result": {"status": "SUCCESS"}},
|
|
})
|
|
await asyncio.wait_for(runner, timeout=5)
|
|
|
|
sent = [call.args[0] for call in ws.send_json.call_args_list]
|
|
assert any(
|
|
isinstance(m, dict)
|
|
and m.get("type") == "task_status"
|
|
and m.get("task", {}).get("status") == "SUCCESS"
|
|
for m in sent
|
|
), f"terminal task_status was not forwarded; sent={sent}"
|
|
|
|
|
|
# #endregion Test.AppModule.LogsStatusLoop
|