66from unittest .mock import AsyncMock , Mock , patch
77from fastapi .testclient import TestClient
88from src .main import app
9- from src .chat import ChatManager , ChatMessage , EscalationEvent
9+ from src .chat import ChatManager , ChatMessage , EscalationEvent , ReadReceiptEvent , TypingEvent
1010
1111
1212@pytest .fixture
@@ -344,4 +344,192 @@ async def test_cleanup_on_websocket_disconnect(chat_manager):
344344 await chat_manager .disconnect (mock_websocket , conversation_id , user_id )
345345
346346 # Check cleanup
347- assert len (chat_manager .active_connections .get (conversation_id , [])) == 0
347+ assert len (chat_manager .active_connections .get (conversation_id , [])) == 0
348+
349+
350+ # ---------------------------------------------------------------------------
351+ # Typing indicators and read receipts
352+ # ---------------------------------------------------------------------------
353+
354+ @pytest .mark .asyncio
355+ async def test_typing_event_broadcast_not_persisted (chat_manager ):
356+ """Typing events are broadcast to all participants but not stored in message history."""
357+ conversation_id = "typing_test_conv"
358+
359+ websockets = [AsyncMock () for _ in range (2 )]
360+ for i , ws in enumerate (websockets ):
361+ await chat_manager .connect (ws , conversation_id , f"user_{ i } " )
362+
363+ event = TypingEvent (
364+ sender_id = "user_0" ,
365+ conversation_id = conversation_id ,
366+ is_typing = True ,
367+ )
368+ await chat_manager .broadcast_event (event )
369+
370+ # All participants should receive the event
371+ for ws in websockets :
372+ ws .send_text .assert_called ()
373+
374+ # Nothing should be stored in message history
375+ assert chat_manager .message_history .get (conversation_id , []) == []
376+
377+
378+ @pytest .mark .asyncio
379+ async def test_typing_event_stop_broadcast_not_persisted (chat_manager ):
380+ """is_typing=False is broadcast and not persisted."""
381+ conversation_id = "typing_stop_conv"
382+
383+ ws = AsyncMock ()
384+ await chat_manager .connect (ws , conversation_id , "user_1" )
385+
386+ event = TypingEvent (
387+ sender_id = "user_1" ,
388+ conversation_id = conversation_id ,
389+ is_typing = False ,
390+ )
391+ await chat_manager .broadcast_event (event )
392+
393+ ws .send_text .assert_called_once ()
394+ assert chat_manager .message_history .get (conversation_id , []) == []
395+
396+
397+ @pytest .mark .asyncio
398+ async def test_read_receipt_broadcast_not_persisted (chat_manager ):
399+ """Read receipt events are broadcast to all participants but not stored in message history."""
400+ conversation_id = "receipt_test_conv"
401+
402+ websockets = [AsyncMock () for _ in range (3 )]
403+ for i , ws in enumerate (websockets ):
404+ await chat_manager .connect (ws , conversation_id , f"user_{ i } " )
405+
406+ event = ReadReceiptEvent (
407+ sender_id = "user_0" ,
408+ conversation_id = conversation_id ,
409+ last_read_message_id = "msg_42" ,
410+ )
411+ await chat_manager .broadcast_event (event )
412+
413+ for ws in websockets :
414+ ws .send_text .assert_called ()
415+
416+ assert chat_manager .message_history .get (conversation_id , []) == []
417+
418+
419+ @pytest .mark .asyncio
420+ async def test_broadcast_event_no_active_connections (chat_manager ):
421+ """broadcast_event returns True when there are no active connections."""
422+ event = TypingEvent (
423+ sender_id = "user_x" ,
424+ conversation_id = "empty_conv" ,
425+ is_typing = True ,
426+ )
427+ result = await chat_manager .broadcast_event (event )
428+ assert result is True
429+
430+
431+ @pytest .mark .asyncio
432+ async def test_typing_event_payload_shape (chat_manager ):
433+ """The JSON sent for a typing event contains the correct fields."""
434+ conversation_id = "shape_test_conv"
435+ ws = AsyncMock ()
436+ await chat_manager .connect (ws , conversation_id , "user_1" )
437+
438+ event = TypingEvent (
439+ sender_id = "user_1" ,
440+ conversation_id = conversation_id ,
441+ is_typing = True ,
442+ )
443+ await chat_manager .broadcast_event (event )
444+
445+ ws .send_text .assert_called_once ()
446+ payload = json .loads (ws .send_text .call_args [0 ][0 ])
447+ assert payload ["type" ] == "typing"
448+ assert payload ["sender_id" ] == "user_1"
449+ assert payload ["conversation_id" ] == conversation_id
450+ assert payload ["is_typing" ] is True
451+
452+
453+ @pytest .mark .asyncio
454+ async def test_read_receipt_payload_shape (chat_manager ):
455+ """The JSON sent for a read receipt event contains the correct fields."""
456+ conversation_id = "receipt_shape_conv"
457+ ws = AsyncMock ()
458+ await chat_manager .connect (ws , conversation_id , "user_2" )
459+
460+ event = ReadReceiptEvent (
461+ sender_id = "user_2" ,
462+ conversation_id = conversation_id ,
463+ last_read_message_id = "msg_99" ,
464+ )
465+ await chat_manager .broadcast_event (event )
466+
467+ ws .send_text .assert_called_once ()
468+ payload = json .loads (ws .send_text .call_args [0 ][0 ])
469+ assert payload ["type" ] == "read_receipt"
470+ assert payload ["sender_id" ] == "user_2"
471+ assert payload ["last_read_message_id" ] == "msg_99"
472+
473+
474+ @pytest .mark .asyncio
475+ async def test_regular_message_still_persisted_after_typing (chat_manager ):
476+ """Regular chat messages are still persisted even after typing events are broadcast."""
477+ conversation_id = "mixed_conv"
478+ ws = AsyncMock ()
479+ await chat_manager .connect (ws , conversation_id , "user_1" )
480+
481+ # Broadcast a typing event (should not persist)
482+ typing_event = TypingEvent (
483+ sender_id = "user_1" ,
484+ conversation_id = conversation_id ,
485+ is_typing = True ,
486+ )
487+ await chat_manager .broadcast_event (typing_event )
488+
489+ # Send a real message (should persist)
490+ message = ChatMessage (
491+ id = "msg_real" ,
492+ sender_id = "user_1" ,
493+ sender_type = "user" ,
494+ content = "Here is my message" ,
495+ timestamp = datetime .utcnow (),
496+ conversation_id = conversation_id ,
497+ )
498+ await chat_manager .send_message (message )
499+
500+ history = chat_manager .get_message_history (conversation_id )
501+ assert len (history ) == 1
502+ assert history [0 ].id == "msg_real"
503+
504+
505+ # ---------------------------------------------------------------------------
506+ # HTTP typing endpoint
507+ # ---------------------------------------------------------------------------
508+
509+ def test_typing_endpoint_broadcasts_and_returns_success (client ):
510+ """POST /chat/{conversation_id}/typing returns 200 with status=success."""
511+ response = client .post (
512+ "/chat/conv_typing_http/typing" ,
513+ json = {"sender_id" : "user_1" , "is_typing" : True },
514+ )
515+ assert response .status_code == 200
516+ assert response .json ()["status" ] == "success"
517+
518+
519+ def test_typing_endpoint_is_typing_false (client ):
520+ """POST /chat/{conversation_id}/typing with is_typing=False returns success."""
521+ response = client .post (
522+ "/chat/conv_typing_stop/typing" ,
523+ json = {"sender_id" : "user_2" , "is_typing" : False },
524+ )
525+ assert response .status_code == 200
526+ assert response .json ()["status" ] == "success"
527+
528+
529+ def test_typing_endpoint_missing_sender_id (client ):
530+ """POST /chat/{conversation_id}/typing with missing sender_id returns 422."""
531+ response = client .post (
532+ "/chat/conv_typing_bad/typing" ,
533+ json = {"is_typing" : True },
534+ )
535+ assert response .status_code == 422
0 commit comments