Add comprehensive rule documentation for AI-assisted development covering authentication surfaces, outbound-call safety invariants, and other project conventions to guide Claude's understanding of critical system behaviors.
100 lines
3.5 KiB
Python
100 lines
3.5 KiB
Python
"""
|
|
WebSocket event-stream tests.
|
|
|
|
The socket is owner-gated: refused (4401) when SSO is enabled and no
|
|
credential is supplied, and — in dev-owner mode (SSO disabled) — an
|
|
authorized client immediately receives the synthetic trunk-status event
|
|
followed by the replayed recent history.
|
|
|
|
The WS `_authorize` resolves the owner via a DB session, so an in-memory
|
|
SQLite database is wired in (StaticPool, shared across the TestClient
|
|
thread) mirroring tests/test_data_layer.py.
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
from sqlalchemy.pool import StaticPool
|
|
from starlette.testclient import TestClient
|
|
from starlette.websockets import WebSocketDisconnect
|
|
|
|
import db.database as dbmod
|
|
import main
|
|
from config import Settings, get_settings
|
|
from core.gateway import AIPSTNGateway
|
|
from db.database import Base
|
|
from models.events import EventType, GatewayEvent
|
|
|
|
|
|
@pytest.fixture
|
|
def mem_db(monkeypatch):
|
|
"""Synchronous setup of an in-memory SQLite DB shared with the app."""
|
|
engine = create_async_engine(
|
|
"sqlite+aiosqlite:///:memory:",
|
|
poolclass=StaticPool,
|
|
connect_args={"check_same_thread": False},
|
|
)
|
|
|
|
async def _create():
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
asyncio.run(_create())
|
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
monkeypatch.setattr(dbmod, "_engine", engine)
|
|
monkeypatch.setattr(dbmod, "_session_factory", factory)
|
|
yield
|
|
asyncio.run(engine.dispose())
|
|
|
|
|
|
@pytest.fixture
|
|
def ws_app(monkeypatch, mem_db):
|
|
"""Dev-owner mode: a tokenless WS connect resolves the owner."""
|
|
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
|
gateway = AIPSTNGateway(settings=Settings())
|
|
main.app.state.gateway = gateway
|
|
yield gateway
|
|
del main.app.state.gateway
|
|
|
|
|
|
def _publish(gateway, call_id: str) -> None:
|
|
asyncio.run(gateway.event_bus.publish(GatewayEvent(
|
|
type=EventType.CALL_INITIATED,
|
|
call_id=call_id,
|
|
data={},
|
|
message=f"call {call_id}",
|
|
)))
|
|
|
|
|
|
class TestEventStream:
|
|
def test_refused_without_credential(self, monkeypatch, mem_db):
|
|
"""SSO enabled + no token → the socket is closed with 4401."""
|
|
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
|
|
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
|
|
monkeypatch.setattr(get_settings(), "owner_name", "owner@example.test")
|
|
client = TestClient(main.app)
|
|
with pytest.raises(WebSocketDisconnect) as exc:
|
|
with client.websocket_connect("/ws/events"):
|
|
pass
|
|
assert exc.value.code == 4401
|
|
|
|
def test_trunk_status_then_replayed_history(self, ws_app):
|
|
_publish(ws_app, "call_ws1")
|
|
_publish(ws_app, "call_ws2")
|
|
|
|
client = TestClient(main.app)
|
|
with client.websocket_connect("/ws/events") as ws:
|
|
first = ws.receive_json()
|
|
assert first["type"] == EventType.SIP_TRUNK_REGISTRATION_FAILED.value
|
|
replayed = [ws.receive_json() for _ in range(2)]
|
|
assert [m["call_id"] for m in replayed] == ["call_ws1", "call_ws2"]
|
|
|
|
def test_per_call_stream_filters(self, ws_app):
|
|
client = TestClient(main.app)
|
|
with client.websocket_connect("/ws/calls/call_target/events") as ws:
|
|
_publish(ws_app, "call_other")
|
|
_publish(ws_app, "call_target")
|
|
msg = ws.receive_json()
|
|
assert msg["call_id"] == "call_target"
|