Skip to content

Commit 78d190f

Browse files
committed
feat(ws_manager): refactor WebSocket broadcast integration and update tests
1 parent 3b98215 commit 78d190f

6 files changed

Lines changed: 241 additions & 6 deletions

File tree

backend/modules/events/router.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,9 @@
99

1010
import logging
1111

12-
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
13-
1412
from core.security.ws_auth import require_access_cookie
1513
from core.ws_manager import ws_manager
14+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
1615

1716
log = logging.getLogger("aria.events.ws")
1817

backend/tests/unit/agents/kb_builder/onboarding/test_service_broadcasts.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ async def __call__(self, event_type: str, payload: dict[str, Any]) -> None:
9494

9595
def _patch_broadcast(monkeypatch: pytest.MonkeyPatch) -> _Recorder:
9696
rec = _Recorder()
97-
monkeypatch.setattr(service, "broadcast_stub", rec)
97+
monkeypatch.setattr(service.ws_manager, "broadcast", rec)
9898
return rec
9999

100100

backend/tests/unit/core/security/__init__.py

Whitespace-only changes.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Tests for ``core.security.ws_auth.require_access_cookie`` (issue #23)."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
from core.security import ws_auth
7+
from core.security.cookies import ACCESS_COOKIE
8+
from core.security.ws_auth import WS_AUTH_FAILED, require_access_cookie
9+
10+
11+
class _FakeWS:
12+
def __init__(self, cookies: dict[str, str] | None = None) -> None:
13+
self.cookies: dict[str, str] = cookies or {}
14+
self.closed_with: int | None = None
15+
16+
async def close(self, code: int) -> None:
17+
self.closed_with = code
18+
19+
20+
@pytest.mark.asyncio
21+
async def test_missing_cookie_closes_with_4401(monkeypatch):
22+
ws = _FakeWS()
23+
out = await require_access_cookie(ws) # type: ignore[arg-type]
24+
assert out is None
25+
assert ws.closed_with == WS_AUTH_FAILED
26+
27+
28+
@pytest.mark.asyncio
29+
async def test_invalid_token_closes_with_4401(monkeypatch):
30+
ws = _FakeWS({ACCESS_COOKIE: "garbage.jwt.value"})
31+
monkeypatch.setattr(ws_auth, "verify_access_token", lambda _t: None)
32+
out = await require_access_cookie(ws) # type: ignore[arg-type]
33+
assert out is None
34+
assert ws.closed_with == WS_AUTH_FAILED
35+
36+
37+
@pytest.mark.asyncio
38+
async def test_valid_token_returns_payload(monkeypatch):
39+
ws = _FakeWS({ACCESS_COOKIE: "good.jwt.value"})
40+
monkeypatch.setattr(ws_auth, "verify_access_token", lambda _t: {"sub": "u1", "type": "access"})
41+
out = await require_access_cookie(ws) # type: ignore[arg-type]
42+
assert out == {"sub": "u1", "type": "access"}
43+
assert ws.closed_with is None
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""Tests for ``core.ws_manager`` (issue #23 / M4.1).
2+
3+
Covers:
4+
- ``broadcast`` reaches every connected socket.
5+
- Closed/dead sockets are silently dropped, not raised to the caller.
6+
- ``current_turn_id`` ContextVar is auto-injected when set, omitted when not.
7+
- Smoke test: a broadcast reaches a connected websocket within 100 ms
8+
(acceptance addition from issue #23 comment 1).
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import asyncio
14+
import json
15+
import time
16+
from typing import Any
17+
18+
import pytest
19+
from core.ws_manager import WSManager, current_turn_id, ws_manager
20+
21+
22+
class _FakeWS:
23+
"""Stand-in for ``starlette.websockets.WebSocket``.
24+
25+
Mimics only the surface ``WSManager`` touches: ``accept``, ``send_text``,
26+
``close``, plus a ``client_state`` attribute compared against
27+
``WebSocketState.CONNECTED``.
28+
"""
29+
30+
def __init__(self, *, fail_on_send: bool = False, disconnected: bool = False) -> None:
31+
self.sent: list[str] = []
32+
self.accepted = False
33+
self.fail_on_send = fail_on_send
34+
# Mimic starlette's enum value comparison via duck typing — anything
35+
# not equal to the imported CONNECTED sentinel will be treated as dead.
36+
from starlette.websockets import WebSocketState
37+
38+
self.client_state = (
39+
WebSocketState.DISCONNECTED if disconnected else WebSocketState.CONNECTED
40+
)
41+
42+
async def accept(self) -> None:
43+
self.accepted = True
44+
45+
async def send_text(self, text: str) -> None:
46+
if self.fail_on_send:
47+
raise RuntimeError("socket exploded")
48+
self.sent.append(text)
49+
50+
51+
@pytest.fixture
52+
def mgr() -> WSManager:
53+
return WSManager()
54+
55+
56+
@pytest.mark.asyncio
57+
async def test_connect_accepts_and_registers(mgr: WSManager) -> None:
58+
ws = _FakeWS()
59+
await mgr.connect(ws) # type: ignore[arg-type]
60+
assert ws.accepted is True
61+
assert ws in mgr.connections
62+
63+
64+
def test_disconnect_is_idempotent(mgr: WSManager) -> None:
65+
ws = _FakeWS()
66+
mgr._connections.add(ws) # type: ignore[arg-type]
67+
mgr.disconnect(ws) # type: ignore[arg-type]
68+
mgr.disconnect(ws) # type: ignore[arg-type] # no raise
69+
assert ws not in mgr.connections
70+
71+
72+
@pytest.mark.asyncio
73+
async def test_broadcast_fans_out_to_all_sockets(mgr: WSManager) -> None:
74+
a, b = _FakeWS(), _FakeWS()
75+
await mgr.connect(a) # type: ignore[arg-type]
76+
await mgr.connect(b) # type: ignore[arg-type]
77+
78+
await mgr.broadcast("anomaly_detected", {"cell_id": 2, "value": 9.1})
79+
80+
for ws in (a, b):
81+
assert len(ws.sent) == 1
82+
frame = json.loads(ws.sent[0])
83+
assert frame == {"type": "anomaly_detected", "cell_id": 2, "value": 9.1}
84+
85+
86+
@pytest.mark.asyncio
87+
async def test_broadcast_drops_failing_sockets(mgr: WSManager) -> None:
88+
good = _FakeWS()
89+
bad = _FakeWS(fail_on_send=True)
90+
await mgr.connect(good) # type: ignore[arg-type]
91+
await mgr.connect(bad) # type: ignore[arg-type]
92+
93+
await mgr.broadcast("ui_render", {"agent": "kb_builder"})
94+
95+
assert good in mgr.connections
96+
assert bad not in mgr.connections
97+
assert len(good.sent) == 1
98+
99+
100+
@pytest.mark.asyncio
101+
async def test_broadcast_drops_disconnected_sockets(mgr: WSManager) -> None:
102+
dead = _FakeWS(disconnected=True)
103+
await mgr.connect(dead) # type: ignore[arg-type]
104+
await mgr.broadcast("agent_end", {"agent": "x", "finish_reason": "ok"})
105+
assert dead not in mgr.connections
106+
assert dead.sent == [] # send_text was never even attempted
107+
108+
109+
@pytest.mark.asyncio
110+
async def test_broadcast_no_connections_is_noop(mgr: WSManager) -> None:
111+
# Must not raise even when nobody is listening.
112+
await mgr.broadcast("agent_start", {"agent": "x"})
113+
114+
115+
@pytest.mark.asyncio
116+
async def test_broadcast_injects_turn_id_from_contextvar(mgr: WSManager) -> None:
117+
ws = _FakeWS()
118+
await mgr.connect(ws) # type: ignore[arg-type]
119+
120+
token = current_turn_id.set("abc-123")
121+
try:
122+
await mgr.broadcast("ui_render", {"agent": "kb_builder"})
123+
finally:
124+
current_turn_id.reset(token)
125+
126+
frame = json.loads(ws.sent[0])
127+
assert frame["turn_id"] == "abc-123"
128+
129+
130+
@pytest.mark.asyncio
131+
async def test_broadcast_omits_turn_id_when_not_set(mgr: WSManager) -> None:
132+
ws = _FakeWS()
133+
await mgr.connect(ws) # type: ignore[arg-type]
134+
await mgr.broadcast("ui_render", {"agent": "kb_builder"})
135+
frame = json.loads(ws.sent[0])
136+
assert "turn_id" not in frame
137+
138+
139+
@pytest.mark.asyncio
140+
async def test_broadcast_explicit_turn_id_is_preserved(mgr: WSManager) -> None:
141+
ws = _FakeWS()
142+
await mgr.connect(ws) # type: ignore[arg-type]
143+
144+
token = current_turn_id.set("from-context")
145+
try:
146+
await mgr.broadcast("ui_render", {"agent": "x", "turn_id": "explicit"})
147+
finally:
148+
current_turn_id.reset(token)
149+
150+
frame = json.loads(ws.sent[0])
151+
assert frame["turn_id"] == "explicit"
152+
153+
154+
@pytest.mark.asyncio
155+
async def test_module_singleton_is_reusable() -> None:
156+
"""Acceptance smoke test (#23 comment 1) — a broadcast on the shipped
157+
``ws_manager`` singleton reaches a connected socket within 100 ms.
158+
"""
159+
ws = _FakeWS()
160+
await ws_manager.connect(ws) # type: ignore[arg-type]
161+
try:
162+
start = time.perf_counter()
163+
await asyncio.wait_for(
164+
ws_manager.broadcast("agent_start", {"agent": "smoke"}),
165+
timeout=0.1,
166+
)
167+
elapsed_ms = (time.perf_counter() - start) * 1000
168+
finally:
169+
ws_manager.disconnect(ws) # type: ignore[arg-type]
170+
171+
assert elapsed_ms < 100
172+
assert len(ws.sent) == 1
173+
frame = json.loads(ws.sent[0])
174+
assert frame["type"] == "agent_start"
175+
176+
177+
@pytest.mark.asyncio
178+
async def test_broadcast_drops_unserialisable_payload(mgr: WSManager) -> None:
179+
ws = _FakeWS()
180+
await mgr.connect(ws) # type: ignore[arg-type]
181+
182+
class _NotJSON:
183+
pass
184+
185+
# ``default=str`` in the encoder means most things serialise; force a
186+
# genuine TypeError by passing a circular reference.
187+
bad: dict[str, Any] = {}
188+
bad["self"] = bad
189+
190+
await mgr.broadcast("ui_render", bad)
191+
# No frame sent, no raise.
192+
assert ws.sent == []
193+
assert ws in mgr.connections # socket itself is still healthy

backend/tests/unit/modules/kb/test_router_upload_broadcasts.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
"""Tests for the M3.6 (#22) WebSocket broadcast stubs in the KB upload route.
1+
"""Tests for the M3.6 (#22) WebSocket broadcasts in the KB upload route.
22
33
These tests exercise the orchestration in ``modules.kb.router.upload_pdf``
44
directly (no FastAPI TestClient — the project does not have HTTP fixtures
55
yet) by stubbing every collaborator: ``extract_from_pdf``,
66
``bootstrap_thresholds``, ``mcp_client``, ``KbRepository``, and the
7-
``broadcast_stub`` shim.
7+
``ws_manager`` singleton (M4.1 #23 — swapped from the M3.6 stub).
88
99
Acceptance covered (issue #22 §5):
1010
@@ -77,7 +77,7 @@ async def get_by_cell(self, cell_id: int):
7777

7878
def _patch(monkeypatch: pytest.MonkeyPatch, mcp: _FakeMCP, recorder: _Recorder) -> None:
7979
monkeypatch.setattr(kb_router, "mcp_client", mcp)
80-
monkeypatch.setattr(kb_router, "broadcast_stub", recorder)
80+
monkeypatch.setattr(kb_router.ws_manager, "broadcast", recorder)
8181

8282
async def _fake_extract(_bytes: bytes, _cell_id: int):
8383
return _FakeKB(), "raw markdown"

0 commit comments

Comments
 (0)