Files
ss-tools/backend/tests/test_app_ws_events.py
busya a1cb18fad9 fix: stop WS reconnect storm on auth rejection; map 502/503/504 to NetworkError
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.
2026-08-01 13:23:30 +07:00

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