main
py 152 lines 4.49 KB
Raw
1 import sys
2 import threading
3 from pathlib import Path
4
5 import pytest
6 import asyncio
7 import time
8
9 PROJECT_ROOT = Path(__file__).resolve().parents[1]
10 if str(PROJECT_ROOT) not in sys.path:
11 sys.path.insert(0, str(PROJECT_ROOT))
12
13 from helpers.ws_manager import WsManager
14
15 NAMESPACE = "/ws"
16
17
18 class FakeSocketIOServer:
19 def __init__(self) -> None:
20 from unittest.mock import AsyncMock
21
22 self.emit = AsyncMock()
23 self.disconnect = AsyncMock()
24
25
26 async def _create_manager() -> tuple[WsManager, "WsWebui"]:
27 from api.ws_webui import WsWebui
28 from helpers.state_monitor import _reset_state_monitor_for_testing
29
30 socketio = FakeSocketIOServer()
31 lock = threading.RLock()
32 manager = WsManager(socketio, lock)
33
34 _reset_state_monitor_for_testing()
35 handler = WsWebui(socketio, lock, manager=manager, namespace=NAMESPACE)
36 await manager.handle_connect(NAMESPACE, "sid-1")
37 await handler.on_connect("sid-1")
38 return manager, handler
39
40
41 async def _create_manager_with_socketio() -> tuple[WsManager, "WsWebui", FakeSocketIOServer]:
42 from api.ws_webui import WsWebui
43 from helpers.state_monitor import _reset_state_monitor_for_testing
44
45 socketio = FakeSocketIOServer()
46 lock = threading.RLock()
47 manager = WsManager(socketio, lock)
48
49 _reset_state_monitor_for_testing()
50 handler = WsWebui(socketio, lock, manager=manager, namespace=NAMESPACE)
51 await manager.handle_connect(NAMESPACE, "sid-1")
52 await handler.on_connect("sid-1")
53 return manager, handler, socketio
54
55
56 @pytest.mark.asyncio
57 async def test_state_request_success_returns_wire_level_shape_and_contract_payload():
58 from helpers.state_monitor import get_state_monitor
59
60 _manager, handler = await _create_manager()
61
62 result = await handler.process(
63 "state_request",
64 {
65 "correlationId": "client-1",
66 "context": None,
67 "log_from": 0,
68 "notifications_from": 0,
69 "timezone": "UTC",
70 "collections_delta": True,
71 },
72 "sid-1",
73 )
74
75 assert isinstance(result, dict)
76 assert set(result.keys()) >= {"runtime_epoch", "seq_base"}
77 assert isinstance(result["runtime_epoch"], str) and result["runtime_epoch"]
78 assert isinstance(result["seq_base"], int)
79 projection = get_state_monitor()._projections[(NAMESPACE, "sid-1")]
80 assert projection.request is not None
81 assert projection.request.collections_delta is True
82
83
84 @pytest.mark.asyncio
85 async def test_state_request_invalid_payload_returns_invalid_request_error():
86 _manager, handler = await _create_manager()
87
88 result = await handler.process(
89 "state_request",
90 {
91 "correlationId": "client-2",
92 "context": None,
93 "log_from": -1,
94 "notifications_from": 0,
95 "timezone": "UTC",
96 },
97 "sid-1",
98 )
99
100 assert isinstance(result, dict)
101 assert result.get("code") == "INVALID_REQUEST"
102
103
104 @pytest.mark.asyncio
105 async def test_state_push_gating_and_initial_snapshot_delivery():
106 from helpers.state_monitor import get_state_monitor
107 from helpers.state_snapshot import validate_snapshot_schema_v1
108
109 manager, handler, socketio = await _create_manager_with_socketio()
110
111 push_ready = asyncio.Event()
112 captured: dict[str, object] = {}
113
114 async def _emit(event_type, envelope, **_kwargs):
115 if event_type == "state_push":
116 captured["envelope"] = envelope
117 push_ready.set()
118
119 socketio.emit.side_effect = _emit
120
121 # INVARIANT.STATE.GATING: no push before a successful state_request.
122 get_state_monitor().mark_dirty(NAMESPACE, "sid-1")
123 await asyncio.sleep(0.2)
124 assert not push_ready.is_set()
125
126 start = time.monotonic()
127 await handler.process(
128 "state_request",
129 {
130 "correlationId": "client-gating",
131 "context": None,
132 "log_from": 0,
133 "notifications_from": 0,
134 "timezone": "UTC",
135 },
136 "sid-1",
137 )
138
139 await asyncio.wait_for(push_ready.wait(), timeout=1.0)
140 assert (time.monotonic() - start) <= 1.0
141
142 envelope = captured.get("envelope")
143 assert isinstance(envelope, dict)
144 data = envelope.get("data")
145 assert isinstance(data, dict)
146 assert set(data.keys()) >= {"runtime_epoch", "seq", "snapshot"}
147 assert isinstance(data["runtime_epoch"], str) and data["runtime_epoch"]
148 assert isinstance(data["seq"], int)
149 assert isinstance(data["snapshot"], dict)
150 validate_snapshot_schema_v1(data["snapshot"])
151
152 await manager.handle_disconnect(NAMESPACE, "sid-1")