Files
ss-tools/backend/tests/test_app_ws_events.py
busya fb8769c577 fix(tests): repair 39 broken tests after auth/authz hardening
- Clean Release API (33 tests): added get_current_user dependency override
  with mock admin user in _make_client — router now requires has_permission

- WebSocket endpoints (23 tests): added _authorize_websocket mock alongside
  existing _authenticate_websocket mock — RBAC check was added before accept()

- Lifespan (1 test): relaxed commit assert_called_once → assert_called —
  RBAC permission catalog sync also calls commit on the same mock

All 7445 backend tests pass (0 failures).
2026-07-23 12:32:50 +03:00

349 lines
16 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()
with patch("src.app._authenticate_websocket", return_value=False):
await task_events_websocket(ws)
ws.close.assert_called_once_with(code=4001, reason="Authentication required")
ws.accept.assert_not_called()
# #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()
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()
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()
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