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.
349 lines
17 KiB
Python
349 lines
17 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.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.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.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.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
|
|
# #endregion Test.AppModule.WsEvents
|