From 4dae72da2b60bef40e6fb77109b0152df676a336 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 20:17:56 +0400 Subject: [PATCH] =?UTF-8?q?test(ws):=20=D0=BA=D1=8D=D1=88=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B5=D0=B4=D0=B8=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20-=20unit=20?= =?UTF-8?q?=D0=B8=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=BE=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20websocket=5Fconnect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ..._websocket_connection_cache_integration.py | 49 ++++++++++++ .../unit/test_websocket_connection_cache.py | 74 +++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 api/tests/integration/test_websocket_connection_cache_integration.py create mode 100644 api/tests/unit/test_websocket_connection_cache.py diff --git a/api/tests/integration/test_websocket_connection_cache_integration.py b/api/tests/integration/test_websocket_connection_cache_integration.py new file mode 100644 index 0000000..806bd1f --- /dev/null +++ b/api/tests/integration/test_websocket_connection_cache_integration.py @@ -0,0 +1,49 @@ +"""AURORA-1845: WebSocket листа формы читает user/форму один раз на соединение.""" +from src.services.budget_form_service import BudgetFormService +from src.services.user_service import UserService + +WS_URL = "/api/v1/ws/form/1/sheet/AHR?direction=Support" +CELL = {"line_id": 1, "column": "plan.q1"} + + +def _roundtrip(ws, event: str, data: dict) -> dict: + ws.send_json({"event": event, "data": data}) + return ws.receive_json() + + +def test_ws_reads_user_and_form_once_per_connection(client, admin_tokens, monkeypatch): + calls = {"user": 0, "form": 0} + orig_user_get = UserService.get + orig_form_get = BudgetFormService.get + + async def user_get(self, *a, **kw): + calls["user"] += 1 + return await orig_user_get(self, *a, **kw) + + async def form_get(self, *a, **kw): + calls["form"] += 1 + return await orig_form_get(self, *a, **kw) + + monkeypatch.setattr(UserService, "get", user_get) + monkeypatch.setattr(BudgetFormService, "get", form_get) + + n = 3 + with client.websocket_connect(WS_URL) as ws: + ws.send_json({"event": "user_login", "data": {"token": admin_tokens["access_token"]}}) + try: + for i in range(n): + started = _roundtrip(ws, "cell_edit_start", CELL) + assert not started.get("error") + updated = _roundtrip(ws, "cell_updated", {**CELL, "value": 100 + i}) + assert not updated.get("error"), updated + _roundtrip(ws, "cell_edit_end", CELL) + # Только счётчики до отката: откат ниже идёт тем же соединением. + user_calls, form_calls = calls["user"], calls["form"] + finally: + # WS коммитит: возвращаем исходное значение (в фикстуре plan.q1 = NULL). + _roundtrip(ws, "cell_edit_start", CELL) + _roundtrip(ws, "cell_updated", {**CELL, "value": None}) + _roundtrip(ws, "cell_edit_end", CELL) + + assert user_calls == 1 + assert form_calls == 1 diff --git a/api/tests/unit/test_websocket_connection_cache.py b/api/tests/unit/test_websocket_connection_cache.py new file mode 100644 index 0000000..d220c80 --- /dev/null +++ b/api/tests/unit/test_websocket_connection_cache.py @@ -0,0 +1,74 @@ +"""AURORA-1845: user/форма читаются из БД один раз на соединение, а не на каждое сообщение.""" +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.api.v1.websocket import ConnectionCache, FormEventProcess + + +def _processor(user=None, form=object()): + proc = FormEventProcess(MagicMock()) + proc.user_service = MagicMock(get=AsyncMock(return_value=user or SimpleNamespace(id=7, role_id=2))) + proc.bf_service = MagicMock(get=AsyncMock(return_value=form)) + proc.sheet_service = MagicMock(update_cell=AsyncMock(return_value=[])) + return proc + + +EVENT = {"event": "cell_updated", "data": {"line_id": 1, "column": "c", "value": 1}} + + +async def _send(proc, cache, n): + for _ in range(n): + await proc.process(event_data=EVENT, form_id=5, user_id=7, sheet="S", cache=cache) + + +@pytest.mark.asyncio +async def test_cache_reads_user_and_form_once_per_connection(): + proc = _processor() + cache = ConnectionCache() + await _send(proc, cache, 5) + assert proc.user_service.get.await_count == 1 + assert proc.bf_service.get.await_count == 1 + assert proc.sheet_service.update_cell.await_count == 5 + + +@pytest.mark.asyncio +async def test_without_cache_reads_every_message(): + proc = _processor() + await _send(proc, None, 3) + assert proc.user_service.get.await_count == 3 + assert proc.bf_service.get.await_count == 3 + + +@pytest.mark.asyncio +async def test_cache_expires_after_ttl(): + now = [0.0] + cache = ConnectionCache(ttl=30, clock=lambda: now[0]) + proc = _processor() + await _send(proc, cache, 2) + now[0] = 31 + await _send(proc, cache, 2) + assert proc.user_service.get.await_count == 2 + assert proc.bf_service.get.await_count == 2 + + +@pytest.mark.asyncio +async def test_missing_form_is_not_cached(): + proc = _processor(form=None) + cache = ConnectionCache() + for _ in range(3): + res = await proc.process(event_data=EVENT, form_id=5, user_id=7, sheet="S", cache=cache) + assert res is None + assert proc.bf_service.get.await_count == 3 + proc.sheet_service.update_cell.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_missing_user_is_not_cached(): + proc = _processor() + proc.user_service.get = AsyncMock(return_value=None) + cache = ConnectionCache() + assert cache.get_user(7) is None + cache.set_user(None) + assert cache.get_user(7) is None