test_realtime.py 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. """Tests for the realtime WebSocket helpers.
  2. Exercises the pure functions and the frame decoding path. The WebSocket
  3. handshake itself is covered by the live integration test.
  4. """
  5. from __future__ import annotations
  6. import pytest
  7. from chattolib.realtime import (
  8. ChattoRealtimeCloseError,
  9. ChattoRealtimeError,
  10. RealtimeEvent,
  11. RealtimeProjectionEvent,
  12. _wrap_event,
  13. _wrap_projection_event,
  14. realtime_url,
  15. )
  16. def test_realtime_url_https_to_wss():
  17. assert realtime_url("https://chat.chatto.run") == "wss://chat.chatto.run/api/realtime"
  18. def test_realtime_url_http_to_ws():
  19. assert realtime_url("http://localhost:9000") == "ws://localhost:9000/api/realtime"
  20. def test_realtime_url_strips_trailing_slash():
  21. assert realtime_url("https://chat.chatto.run/") == "wss://chat.chatto.run/api/realtime"
  22. def test_wrap_event_user_typing():
  23. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  24. envelope = realtime_pb2.RealtimeEventEnvelope()
  25. envelope.id = "evt_1"
  26. envelope.actor_id = "u1"
  27. envelope.user_typing.room_id = "r1"
  28. wrapped = _wrap_event(envelope)
  29. assert isinstance(wrapped, RealtimeEvent)
  30. assert wrapped.id == "evt_1"
  31. assert wrapped.kind == "user_typing"
  32. assert wrapped.actor_id == "u1"
  33. assert wrapped.payload.room_id == "r1"
  34. def test_wrap_projection_event():
  35. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  36. envelope = realtime_pb2.RealtimeProjectionEvent()
  37. envelope.id = "proj_1"
  38. envelope.resume_cursor = "cur_1"
  39. op = envelope.operations.add()
  40. op.room_remove.room_id = "r1"
  41. op2 = envelope.operations.add()
  42. op2.reset.SetInParent()
  43. wrapped = _wrap_projection_event(envelope)
  44. assert isinstance(wrapped, RealtimeProjectionEvent)
  45. assert wrapped.id == "proj_1"
  46. assert wrapped.resume_cursor == "cur_1"
  47. assert [o.operation for o in wrapped.operations] == ["room_remove", "reset"]
  48. assert wrapped.operations[0].payload.room_id == "r1"
  49. def test_wrap_event_presence_changed():
  50. from chattolib._pb.chatto.api.v1 import presence_pb2
  51. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  52. envelope = realtime_pb2.RealtimeEventEnvelope()
  53. envelope.id = "evt_2"
  54. envelope.presence_changed.user_id = "u1"
  55. envelope.presence_changed.status = presence_pb2.PresenceStatus.PRESENCE_STATUS_ONLINE
  56. wrapped = _wrap_event(envelope)
  57. assert wrapped.kind == "presence_changed"
  58. assert wrapped.payload.user_id == "u1"
  59. assert wrapped.payload.status == presence_pb2.PresenceStatus.PRESENCE_STATUS_ONLINE
  60. # No actor_id set on this envelope, so it should be None.
  61. assert wrapped.actor_id is None
  62. def test_wrap_event_without_variant():
  63. """Envelope with no oneof set (server bug or truncation) should still wrap."""
  64. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  65. envelope = realtime_pb2.RealtimeEventEnvelope()
  66. envelope.id = "evt_3"
  67. wrapped = _wrap_event(envelope)
  68. assert wrapped.kind == ""
  69. assert wrapped.payload is None
  70. def test_close_exception_carries_reconnect_hint():
  71. exc = ChattoRealtimeCloseError(
  72. "auth_expired", "please reconnect", reconnect=True, retry_after_ms=5000
  73. )
  74. assert exc.code == "auth_expired"
  75. assert exc.reconnect is True
  76. assert exc.retry_after_ms == 5000
  77. assert "auth_expired" in str(exc)
  78. def test_error_exception_marks_fatal():
  79. fatal = ChattoRealtimeError("protocol_error", "bad frame", fatal=True)
  80. assert fatal.fatal is True
  81. recoverable = ChattoRealtimeError("noop", "ignore me")
  82. assert recoverable.fatal is False
  83. def test_frame_roundtrip_hello():
  84. """Client hello serializes and parses back with the fields we set."""
  85. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  86. frame = realtime_pb2.RealtimeClientFrame()
  87. frame.hello.protocol_version = 1
  88. frame.hello.bearer_token = "cht_abc"
  89. wire = frame.SerializeToString()
  90. parsed = realtime_pb2.RealtimeClientFrame()
  91. parsed.ParseFromString(wire)
  92. assert parsed.WhichOneof("frame") == "hello"
  93. assert parsed.hello.protocol_version == 1
  94. assert parsed.hello.bearer_token == "cht_abc"
  95. @pytest.mark.parametrize(
  96. "case,attr",
  97. [
  98. ("subscribe_events", "subscribe_events"),
  99. ("ping", "ping"),
  100. ],
  101. )
  102. def test_client_frame_oneof_cases(case: str, attr: str):
  103. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  104. frame = realtime_pb2.RealtimeClientFrame()
  105. getattr(frame, attr).SetInParent()
  106. assert frame.WhichOneof("frame") == case