| """ |
| WebSocket real-time messaging tests (Wave 1, Task 1.2). |
| |
| Tests cover: |
| - Send and receive message |
| - Multiple clients |
| - Broadcast messages |
| - Private messages |
| """ |
| import asyncio |
| import pytest |
| from datetime import datetime |
| from unittest.mock import AsyncMock, MagicMock |
|
|
| from tests.property_tests.conftest import db_session |
|
|
|
|
| |
| |
| |
|
|
| @pytest.fixture |
| def cleanup_websocket_manager(): |
| """Cleanup WebSocket manager state before/after tests.""" |
| from core.websockets import manager |
| |
| original_connections = manager.active_connections.copy() |
| original_user_connections = manager.user_connections.copy() |
|
|
| yield |
|
|
| |
| manager.active_connections.clear() |
| manager.user_connections.clear() |
| manager.active_connections.update(original_connections) |
| manager.user_connections.update(original_user_connections) |
|
|
|
|
| @pytest.fixture |
| def connected_websocket(cleanup_websocket_manager): |
| """Create a connected WebSocket for testing.""" |
| from core.websockets import manager |
|
|
| ws = MagicMock() |
| ws.accept = AsyncMock() |
| ws.send_json = AsyncMock() |
| ws.send_text = AsyncMock() |
|
|
| |
| user = asyncio.run(manager.connect(ws, "dev-token")) |
|
|
| return ws, user |
|
|
|
|
| |
| |
| |
|
|
| class TestSendAndReceiveMessage: |
| """Test sending and receiving messages through WebSocket.""" |
|
|
| @pytest.mark.asyncio(mode="auto") |
| async def test_send_and_receive_message(self, connected_websocket): |
| """Test sending and receiving messages via WebSocket.""" |
| from core.websockets import manager |
|
|
| |
| ws, user = connected_websocket |
|
|
| |
| test_message = { |
| "type": "message", |
| "content": "Hello, World!" |
| } |
| await manager.send_personal_message(user.id, test_message) |
|
|
| |
| ws.send_json.assert_called_once() |
| call_args = ws.send_json.call_args[0][0] |
| assert call_args["type"] == "message" |
| assert call_args["content"] == "Hello, World!" |
|
|
| @pytest.mark.asyncio(mode="auto") |
| async def test_send_message_to_specific_user(self, connected_websocket): |
| """Test sending message to specific user.""" |
| from core.websockets import manager |
|
|
| |
| ws1, user1 = connected_websocket |
| ws2 = MagicMock() |
| ws2.accept = AsyncMock() |
| ws2.send_json = AsyncMock() |
| user2 = await manager.connect(ws2, "dev-token") |
|
|
| |
| test_message = {"type": "private", "content": "Private message"} |
| await manager.send_personal_message(user1.id, test_message) |
|
|
| |
| ws1.send_json.assert_called_once() |
| ws2.send_json.assert_not_called() |
|
|
| @pytest.mark.asyncio(mode="auto") |
| async def test_send_message_includes_timestamp(self, connected_websocket): |
| """Test broadcast_event automatically includes timestamp.""" |
| from core.websockets import manager |
|
|
| |
| ws, user = connected_websocket |
| user_channel = f"user:{user.id}" |
|
|
| |
| await manager.broadcast_event( |
| user_channel, |
| "test_event", |
| {"data": "test data"} |
| ) |
|
|
| |
| ws.send_json.assert_called_once() |
| call_args = ws.send_json.call_args[0][0] |
| assert "timestamp" in call_args |
| assert call_args["type"] == "test_event" |
| assert call_args["data"] == {"data": "test data"} |
|
|
| @pytest.mark.asyncio(mode="auto") |
| async def test_send_multiple_messages_sequentially(self, connected_websocket): |
| """Test sending multiple messages sequentially.""" |
| from core.websockets import manager |
|
|
| |
| ws, user = connected_websocket |
|
|
| |
| messages = [ |
| {"type": "msg1", "content": "First"}, |
| {"type": "msg2", "content": "Second"}, |
| {"type": "msg3", "content": "Third"}, |
| ] |
|
|
| for msg in messages: |
| await manager.send_personal_message(user.id, msg) |
|
|
| |
| assert ws.send_json.call_count == 3 |
|
|
| |
| for i, call in enumerate(ws.send_json.call_args_list): |
| sent_msg = call[0][0] |
| assert sent_msg["content"] == messages[i]["content"] |
|
|
|
|
| |
| |
| |
|
|
| class TestMultipleClients: |
| """Test multiple WebSocket clients simultaneously.""" |
|
|
| @pytest.mark.asyncio(mode="auto") |
| async def test_multiple_clients_connected(self, cleanup_websocket_manager): |
| """Test multiple WebSocket clients can connect simultaneously.""" |
| from core.websockets import manager |
|
|
| |
| clients = [] |
| for i in range(5): |
| ws = MagicMock() |
| ws.accept = AsyncMock() |
| ws.send_json = AsyncMock() |
| user = await manager.connect(ws, "dev-token") |
| clients.append((ws, user)) |
|
|
| |
| assert len(manager.user_connections) == 5 |
|
|
| @pytest.mark.asyncio(mode="auto") |
| async def test_broadcast_to_all_clients_in_channel(self, cleanup_websocket_manager): |
| """Test broadcasting message to all clients in a channel.""" |
| from core.websockets import manager |
|
|
| |
| channel = "test_broadcast_channel" |
| clients = [] |
| for i in range(3): |
| ws = MagicMock() |
| ws.accept = AsyncMock() |
| ws.send_json = AsyncMock() |
| await manager.connect(ws, "dev-token") |
| manager.subscribe(ws, channel) |
| clients.append(ws) |
|
|
| |
| await manager.broadcast(channel, {"type": "broadcast", "count": 3}) |
|
|
| |
| for ws in clients: |
| ws.send_json.assert_called_once() |
|
|
| @pytest.mark.asyncio(mode="auto") |
| async def test_send_personal_message_to_user_with_multiple_connections(self, cleanup_websocket_manager): |
| """Test sending personal message to user with multiple connections.""" |
| from core.websockets import manager |
|
|
| |
| user_id = "dev-user" |
| connections = [] |
| for i in range(3): |
| ws = MagicMock() |
| ws.accept = AsyncMock() |
| ws.send_json = AsyncMock() |
| await manager.connect(ws, "dev-token") |
| connections.append(ws) |
|
|
| |
| await manager.send_personal_message(user_id, {"type": "test", "content": "Hello"}) |
|
|
| |
| for ws in connections: |
| ws.send_json.assert_called_once() |
|
|
| @pytest.mark.asyncio(mode="auto") |
| async def test_clients_dont_interfere_with_each_other(self, cleanup_websocket_manager): |
| """Test multiple clients don't interfere with each other.""" |
| from core.websockets import manager |
|
|
| |
| ws1 = MagicMock() |
| ws1.accept = AsyncMock() |
| ws1.send_json = AsyncMock() |
| user1 = await manager.connect(ws1, "dev-token") |
|
|
| ws2 = MagicMock() |
| ws2.accept = AsyncMock() |
| ws2.send_json = AsyncMock() |
| user2 = await manager.connect(ws2, "dev-token") |
|
|
| |
| await manager.send_personal_message(user1.id, {"type": "private", "to": "user1"}) |
|
|
| |
| ws1.send_json.assert_called_once() |
| ws2.send_json.assert_not_called() |
|
|
|
|
| |
| |
| |
|
|
| class TestBroadcastMessages: |
| """Test broadcasting messages to channels.""" |
|
|
| @pytest.mark.asyncio(mode="auto") |
| async def test_broadcast_to_channel(self, connected_websocket): |
| """Test broadcasting message to a channel.""" |
| from core.websockets import manager |
|
|
| |
| ws, user = connected_websocket |
| channel = "test_channel" |
| manager.subscribe(ws, channel) |
|
|
| |
| test_message = {"type": "broadcast", "content": "Hello, channel!"} |
| await manager.broadcast(channel, test_message) |
|
|
| |
| ws.send_json.assert_called() |
| |
| assert any( |
| call[0][0]["type"] == "broadcast" |
| for call in ws.send_json.call_args_list |
| ) |
|
|
| @pytest.mark.asyncio(mode="auto") |
| async def test_broadcast_to_multiple_channels(self, connected_websocket): |
| """Test broadcasting to multiple channels.""" |
| from core.websockets import manager |
|
|
| |
| ws, user = connected_websocket |
| channels = ["channel_1", "channel_2", "channel_3"] |
| for ch in channels: |
| manager.subscribe(ws, ch) |
|
|
| |
| for channel in channels: |
| await manager.broadcast(channel, {"type": "msg", "channel": channel}) |
|
|
| |
| broadcast_calls = [ |
| call for call in ws.send_json.call_args_list |
| if call[0][0].get("type") == "msg" |
| ] |
| assert len(broadcast_calls) == 3 |
|
|
| @pytest.mark.asyncio(mode="auto") |
| async def test_broadcast_to_empty_channel(self, connected_websocket, caplog): |
| """Test broadcasting to empty channel logs warning.""" |
| from core.websockets import manager |
| import logging |
|
|
| |
| empty_channel = "empty_channel_xyz" |
|
|
| |
| with caplog.at_level(logging.WARNING): |
| await manager.broadcast(empty_channel, {"type": "test"}) |
|
|
| |
| assert any("EMPTY channel" in record.message for record in caplog.records) |
|
|
| @pytest.mark.asyncio(mode="auto") |
| async def test_broadcast_json_serialization(self, connected_websocket): |
| """Test broadcast message is properly JSON serialized.""" |
| from core.websockets import manager |
|
|
| |
| ws, user = connected_websocket |
| channel = "json_test_channel" |
| manager.subscribe(ws, channel) |
|
|
| |
| complex_message = { |
| "type": "complex", |
| "nested": { |
| "data": [1, 2, 3], |
| "metadata": {"key": "value"} |
| }, |
| "timestamp": datetime.now().isoformat() |
| } |
| await manager.broadcast(channel, complex_message) |
|
|
| |
| ws.send_json.assert_called() |
| sent_data = ws.send_json.call_args[0][0] |
| assert sent_data["nested"]["data"] == [1, 2, 3] |
| assert sent_data["nested"]["metadata"]["key"] == "value" |
|
|
|
|
| |
| |
| |
|
|
| class TestPrivateMessages: |
| """Test private messaging functionality.""" |
|
|
| @pytest.mark.asyncio(mode="auto") |
| async def test_private_message_only_reaches_target_user(self, cleanup_websocket_manager): |
| """Test private message only reaches target user.""" |
| from core.websockets import manager |
|
|
| |
| ws1 = MagicMock() |
| ws1.accept = AsyncMock() |
| ws1.send_json = AsyncMock() |
| user1 = await manager.connect(ws1, "dev-token") |
|
|
| ws2 = MagicMock() |
| ws2.accept = AsyncMock() |
| ws2.send_json = AsyncMock() |
| user2 = await manager.connect(ws2, "dev-token") |
|
|
| |
| private_msg = { |
| "type": "private", |
| "from": "system", |
| "content": "Secret message" |
| } |
| await manager.send_personal_message(user1.id, private_msg) |
|
|
| |
| ws1.send_json.assert_called_once() |
| ws2.send_json.assert_not_called() |
|
|
| @pytest.mark.asyncio(mode="auto") |
| async def test_private_message_to_nonexistent_user(self, connected_websocket, caplog): |
| """Test sending private message to nonexistent user handles gracefully.""" |
| from core.websockets import manager |
| import logging |
|
|
| |
| nonexistent_user = "nonexistent_user_xyz" |
|
|
| |
| with caplog.at_level(logging.ERROR): |
| await manager.send_personal_message( |
| nonexistent_user, |
| {"type": "test"} |
| ) |
|
|
| |
| |
| assert True |
|
|
| @pytest.mark.asyncio(mode="auto") |
| async def test_private_message_reaches_all_user_connections(self, cleanup_websocket_manager): |
| """Test private message reaches all connections for a user.""" |
| from core.websockets import manager |
|
|
| |
| ws1 = MagicMock() |
| ws1.accept = AsyncMock() |
| ws1.send_json = AsyncMock() |
| user1 = await manager.connect(ws1, "dev-token") |
|
|
| ws2 = MagicMock() |
| ws2.accept = AsyncMock() |
| ws2.send_json = AsyncMock() |
| await manager.connect(ws2, "dev-token") |
|
|
| |
| await manager.send_personal_message(user1.id, {"type": "test"}) |
|
|
| |
| ws1.send_json.assert_called_once() |
| ws2.send_json.assert_called_once() |
|
|
|
|
| |
| |
| |
|
|
| class TestChannelIsolation: |
| """Test messages don't leak between channels.""" |
|
|
| @pytest.mark.asyncio(mode="auto") |
| async def test_channel_isolation_messages_dont_leak(self, cleanup_websocket_manager): |
| """Test messages don't leak between channels.""" |
| from core.websockets import manager |
|
|
| |
| ws_channel_a = MagicMock() |
| ws_channel_a.send_json = AsyncMock() |
| ws_channel_b = MagicMock() |
| ws_channel_b.send_json = AsyncMock() |
|
|
| manager.subscribe(ws_channel_a, "channel_a") |
| manager.subscribe(ws_channel_b, "channel_b") |
|
|
| |
| await manager.broadcast("channel_a", {"type": "test", "channel": "a"}) |
|
|
| |
| ws_channel_a.send_json.assert_called_once() |
| ws_channel_b.send_json.assert_not_called() |
|
|
| @pytest.mark.asyncio(mode="auto") |
| async def test_user_and_workspace_channels_separate(self, connected_websocket): |
| """Test user and workspace channels are isolated.""" |
| from core.websockets import manager |
|
|
| |
| ws, user = connected_websocket |
| user_channel = f"user:{user.id}" |
| workspace_channel = f"workspace:{user.workspace_id}" |
|
|
| |
| ws.send_json.reset_mock() |
|
|
| |
| await manager.broadcast(user_channel, {"type": "user_only"}) |
|
|
| |
| |
| assert any( |
| call[0][0].get("type") == "user_only" |
| for call in ws.send_json.call_args_list |
| ) |
|
|
|
|
| |
| |
| |
|
|
| class TestMessageOrdering: |
| """Test message ordering and delivery guarantees.""" |
|
|
| @pytest.mark.asyncio(mode="auto") |
| async def test_messages_delivered_in_order(self, connected_websocket): |
| """Test messages are delivered in order.""" |
| from core.websockets import manager |
|
|
| |
| ws, user = connected_websocket |
|
|
| |
| messages = [ |
| {"seq": 1, "content": "First"}, |
| {"seq": 2, "content": "Second"}, |
| {"seq": 3, "content": "Third"}, |
| ] |
|
|
| for msg in messages: |
| await manager.send_personal_message(user.id, msg) |
| |
| await asyncio.sleep(0.01) |
|
|
| |
| assert ws.send_json.call_count == 3 |
| for i, call in enumerate(ws.send_json.call_args_list): |
| sent_msg = call[0][0] |
| assert sent_msg["seq"] == i + 1 |
|
|