test_realtime.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  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. _wrap_event,
  12. realtime_url,
  13. )
  14. def test_realtime_url_https_to_wss():
  15. assert realtime_url("https://chat.chatto.run") == "wss://chat.chatto.run/api/realtime"
  16. def test_realtime_url_http_to_ws():
  17. assert realtime_url("http://localhost:9000") == "ws://localhost:9000/api/realtime"
  18. def test_realtime_url_strips_trailing_slash():
  19. assert (
  20. realtime_url("https://chat.chatto.run/") == "wss://chat.chatto.run/api/realtime"
  21. )
  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. envelope.user_typing.thread_root_event_id = "t1"
  29. wrapped = _wrap_event(envelope)
  30. assert isinstance(wrapped, RealtimeEvent)
  31. assert wrapped.id == "evt_1"
  32. assert wrapped.kind == "user_typing"
  33. assert wrapped.actor_id == "u1"
  34. assert wrapped.payload.room_id == "r1"
  35. assert wrapped.payload.thread_root_event_id == "t1"
  36. def test_wrap_event_presence_changed():
  37. from chattolib._pb.chatto.api.v1 import presence_pb2
  38. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  39. envelope = realtime_pb2.RealtimeEventEnvelope()
  40. envelope.id = "evt_2"
  41. envelope.presence_changed.user_id = "u1"
  42. envelope.presence_changed.status = presence_pb2.PresenceStatus.PRESENCE_STATUS_ONLINE
  43. wrapped = _wrap_event(envelope)
  44. assert wrapped.kind == "presence_changed"
  45. assert wrapped.payload.user_id == "u1"
  46. assert wrapped.payload.status == presence_pb2.PresenceStatus.PRESENCE_STATUS_ONLINE
  47. # No actor_id set on this envelope, so it should be None.
  48. assert wrapped.actor_id is None
  49. def test_wrap_event_without_variant():
  50. """Envelope with no oneof set (server bug or truncation) should still wrap."""
  51. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  52. envelope = realtime_pb2.RealtimeEventEnvelope()
  53. envelope.id = "evt_3"
  54. wrapped = _wrap_event(envelope)
  55. assert wrapped.kind == ""
  56. assert wrapped.payload is None
  57. def test_close_exception_carries_reconnect_hint():
  58. exc = ChattoRealtimeCloseError(
  59. "auth_expired", "please reconnect", reconnect=True, retry_after_ms=5000
  60. )
  61. assert exc.code == "auth_expired"
  62. assert exc.reconnect is True
  63. assert exc.retry_after_ms == 5000
  64. assert "auth_expired" in str(exc)
  65. def test_error_exception_marks_fatal():
  66. fatal = ChattoRealtimeError("protocol_error", "bad frame", fatal=True)
  67. assert fatal.fatal is True
  68. recoverable = ChattoRealtimeError("noop", "ignore me")
  69. assert recoverable.fatal is False
  70. def test_frame_roundtrip_hello():
  71. """Client hello serializes and parses back with the fields we set."""
  72. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  73. frame = realtime_pb2.RealtimeClientFrame()
  74. frame.hello.protocol_version = 2
  75. frame.hello.bearer_token = "cht_abc"
  76. wire = frame.SerializeToString()
  77. parsed = realtime_pb2.RealtimeClientFrame()
  78. parsed.ParseFromString(wire)
  79. assert parsed.WhichOneof("frame") == "hello"
  80. assert parsed.hello.protocol_version == 2
  81. assert parsed.hello.bearer_token == "cht_abc"
  82. @pytest.mark.parametrize(
  83. "case,attr",
  84. [
  85. ("subscribe_events", "subscribe_events"),
  86. ("ping", "ping"),
  87. ("hydrate_room", "hydrate_room"),
  88. ],
  89. )
  90. def test_client_frame_oneof_cases(case: str, attr: str):
  91. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  92. frame = realtime_pb2.RealtimeClientFrame()
  93. getattr(frame, attr).SetInParent()
  94. assert frame.WhichOneof("frame") == case
  95. def test_wrap_projection_event_carries_resume_cursor():
  96. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  97. from chattolib.realtime import _wrap_projection_event
  98. frame = realtime_pb2.RealtimeServerFrame()
  99. frame.projection_event.id = "pe_1"
  100. frame.projection_event.actor_id = "u1"
  101. frame.projection_event.resume_cursor = "cursor_xyz"
  102. frame.projection_event.operations.add().reset.SetInParent()
  103. wrapped = _wrap_projection_event(frame)
  104. assert wrapped.kind == "projection_event"
  105. assert wrapped.id == "pe_1"
  106. assert wrapped.actor_id == "u1"
  107. assert wrapped.payload.resume_cursor == "cursor_xyz"
  108. assert len(wrapped.payload.operations) == 1
  109. def test_wrap_caught_up_exposes_cursor():
  110. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  111. from chattolib.realtime import _wrap_caught_up
  112. frame = realtime_pb2.RealtimeServerFrame()
  113. frame.caught_up.cursor = "cursor_live_boundary"
  114. wrapped = _wrap_caught_up(frame)
  115. assert wrapped.kind == "caught_up"
  116. assert wrapped.id == ""
  117. assert wrapped.payload.cursor == "cursor_live_boundary"
  118. def test_error_exception_carries_hydrate_hints():
  119. exc = ChattoRealtimeError(
  120. "too_many_retained_rooms",
  121. "cap exceeded",
  122. retry_after_ms=1000,
  123. room_id="r1",
  124. )
  125. assert exc.retry_after_ms == 1000
  126. assert exc.room_id == "r1"