File size: 17,760 Bytes
aef804e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
"""
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


# =============================================================================
# Test Fixtures
# =============================================================================

@pytest.fixture
def cleanup_websocket_manager():
    """Cleanup WebSocket manager state before/after tests."""
    from core.websockets import manager
    # Store original state
    original_connections = manager.active_connections.copy()
    original_user_connections = manager.user_connections.copy()

    yield

    # Restore state
    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()

    # Connect with dev-token
    user = asyncio.run(manager.connect(ws, "dev-token"))

    return ws, user


# =============================================================================
# Send and Receive Message Tests
# =============================================================================

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

        # Given: Connected WebSocket
        ws, user = connected_websocket

        # When: Send message
        test_message = {
            "type": "message",
            "content": "Hello, World!"
        }
        await manager.send_personal_message(user.id, test_message)

        # Then: Should receive 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

        # Given: Two connected users
        ws1, user1 = connected_websocket
        ws2 = MagicMock()
        ws2.accept = AsyncMock()
        ws2.send_json = AsyncMock()
        user2 = await manager.connect(ws2, "dev-token")

        # When: Send message to user1 only
        test_message = {"type": "private", "content": "Private message"}
        await manager.send_personal_message(user1.id, test_message)

        # Then: Only user1 should receive
        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

        # Given: Connected WebSocket
        ws, user = connected_websocket
        user_channel = f"user:{user.id}"

        # When: Broadcast event
        await manager.broadcast_event(
            user_channel,
            "test_event",
            {"data": "test data"}
        )

        # Then: Should include timestamp
        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

        # Given: Connected WebSocket
        ws, user = connected_websocket

        # When: Send multiple messages
        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)

        # Then: All messages should be sent
        assert ws.send_json.call_count == 3

        # Verify each message
        for i, call in enumerate(ws.send_json.call_args_list):
            sent_msg = call[0][0]
            assert sent_msg["content"] == messages[i]["content"]


# =============================================================================
# Multiple Clients Tests
# =============================================================================

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

        # Given: Multiple clients
        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))

        # Then: All clients should be connected
        assert len(manager.user_connections) == 5  # All "dev-user" but different connections

    @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

        # Given: Multiple clients in same channel
        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)

        # When: Broadcast to channel
        await manager.broadcast(channel, {"type": "broadcast", "count": 3})

        # Then: All clients should receive
        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

        # Given: User with 3 connections
        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)

        # When: Send personal message
        await manager.send_personal_message(user_id, {"type": "test", "content": "Hello"})

        # Then: All connections should receive
        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

        # Given: Two users
        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")

        # When: Send message to user1
        await manager.send_personal_message(user1.id, {"type": "private", "to": "user1"})

        # Then: Only user1 receives
        ws1.send_json.assert_called_once()
        ws2.send_json.assert_not_called()


# =============================================================================
# Broadcast Messages Tests
# =============================================================================

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

        # Given: WebSocket subscribed to channel
        ws, user = connected_websocket
        channel = "test_channel"
        manager.subscribe(ws, channel)

        # When: Broadcast message
        test_message = {"type": "broadcast", "content": "Hello, channel!"}
        await manager.broadcast(channel, test_message)

        # Then: Message should be sent
        ws.send_json.assert_called()
        # At least once (may have other calls from connection)
        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

        # Given: WebSocket subscribed to multiple channels
        ws, user = connected_websocket
        channels = ["channel_1", "channel_2", "channel_3"]
        for ch in channels:
            manager.subscribe(ws, ch)

        # When: Broadcast to each channel
        for channel in channels:
            await manager.broadcast(channel, {"type": "msg", "channel": channel})

        # Then: Should receive all broadcasts
        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

        # Given: Empty channel
        empty_channel = "empty_channel_xyz"

        # When: Try to broadcast
        with caplog.at_level(logging.WARNING):
            await manager.broadcast(empty_channel, {"type": "test"})

        # Then: Should log warning (and not crash)
        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

        # Given: WebSocket subscribed to channel
        ws, user = connected_websocket
        channel = "json_test_channel"
        manager.subscribe(ws, channel)

        # When: Broadcast complex message
        complex_message = {
            "type": "complex",
            "nested": {
                "data": [1, 2, 3],
                "metadata": {"key": "value"}
            },
            "timestamp": datetime.now().isoformat()
        }
        await manager.broadcast(channel, complex_message)

        # Then: Should be serialized correctly
        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"


# =============================================================================
# Private Messages Tests
# =============================================================================

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

        # Given: Two users
        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")

        # When: Send private message to user1
        private_msg = {
            "type": "private",
            "from": "system",
            "content": "Secret message"
        }
        await manager.send_personal_message(user1.id, private_msg)

        # Then: Only user1 receives
        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

        # Given: Nonexistent user
        nonexistent_user = "nonexistent_user_xyz"

        # When: Try to send message (should not raise exception)
        with caplog.at_level(logging.ERROR):
            await manager.send_personal_message(
                nonexistent_user,
                {"type": "test"}
            )

        # Then: Should handle gracefully (may log error)
        # Test passes if no exception is raised
        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

        # Given: User with 2 connections
        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")  # Same user

        # When: Send private message
        await manager.send_personal_message(user1.id, {"type": "test"})

        # Then: Both connections should receive
        ws1.send_json.assert_called_once()
        ws2.send_json.assert_called_once()


# =============================================================================
# Channel Isolation Tests
# =============================================================================

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

        # Given: Connections in different channels
        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")

        # When: Broadcast to channel_a
        await manager.broadcast("channel_a", {"type": "test", "channel": "a"})

        # Then: Only channel_a receives message
        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

        # Given: Connected user with user and workspace channels
        ws, user = connected_websocket
        user_channel = f"user:{user.id}"
        workspace_channel = f"workspace:{user.workspace_id}"

        # Reset call counts to isolate test
        ws.send_json.reset_mock()

        # When: Broadcast to user channel only
        await manager.broadcast(user_channel, {"type": "user_only"})

        # Then: Message should be sent
        # (Workspace channel shouldn't receive this message)
        assert any(
            call[0][0].get("type") == "user_only"
            for call in ws.send_json.call_args_list
        )


# =============================================================================
# Message Ordering Tests
# =============================================================================

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

        # Given: Connected WebSocket
        ws, user = connected_websocket

        # When: Send multiple messages in sequence
        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)
            # Small delay to ensure ordering
            await asyncio.sleep(0.01)

        # Then: Messages should be delivered in order
        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