test_realtime.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  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. RealtimeSnapshot,
  12. _close_code_name,
  13. _wrap_event,
  14. _wrap_snapshot,
  15. realtime_url,
  16. )
  17. def test_realtime_url_https_to_wss():
  18. assert realtime_url("https://chat.chatto.run") == "wss://chat.chatto.run/api/realtime"
  19. def test_realtime_url_http_to_ws():
  20. assert realtime_url("http://localhost:9000") == "ws://localhost:9000/api/realtime"
  21. def test_realtime_url_strips_trailing_slash():
  22. assert realtime_url("https://chat.chatto.run/") == "wss://chat.chatto.run/api/realtime"
  23. def test_wrap_event_user_typing():
  24. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  25. envelope = realtime_pb2.RealtimeEvent()
  26. envelope.id = "evt_1"
  27. envelope.actor_id = "u1"
  28. envelope.cursor = "cur_1"
  29. envelope.user_typing.room_id = "r1"
  30. wrapped = _wrap_event(envelope)
  31. assert isinstance(wrapped, RealtimeEvent)
  32. assert wrapped.id == "evt_1"
  33. assert wrapped.kind == "user_typing"
  34. assert wrapped.actor_id == "u1"
  35. assert wrapped.cursor == "cur_1"
  36. assert wrapped.payload.room_id == "r1"
  37. def test_wrap_event_presence_changed():
  38. from chattolib._pb.chatto.api.v1 import presence_pb2
  39. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  40. envelope = realtime_pb2.RealtimeEvent()
  41. envelope.id = "evt_2"
  42. envelope.presence_changed.status = presence_pb2.PresenceStatus.PRESENCE_STATUS_ONLINE
  43. wrapped = _wrap_event(envelope)
  44. assert wrapped.kind == "presence_changed"
  45. # In protocol 4 the actor lives on the envelope, not the payload.
  46. assert wrapped.actor_id is None
  47. assert wrapped.payload.status == presence_pb2.PresenceStatus.PRESENCE_STATUS_ONLINE
  48. def test_wrap_event_viewer_presence_preference_changed():
  49. """0.5.0b6: the 48th oneof member is an empty hint delivered only to the
  50. account whose own presence preference changed."""
  51. from chattolib._pb.chatto.realtime.v1 import events_pb2, realtime_pb2
  52. envelope = realtime_pb2.RealtimeEvent()
  53. envelope.id = "evt_9"
  54. envelope.actor_id = "u_me"
  55. # Setting the empty message sets the oneof case.
  56. envelope.viewer_presence_preference_changed.SetInParent()
  57. wrapped = _wrap_event(envelope)
  58. assert wrapped.kind == "viewer_presence_preference_changed"
  59. assert wrapped.actor_id == "u_me"
  60. assert isinstance(wrapped.payload, events_pb2.ViewerPresencePreferenceChangedEvent)
  61. def test_wrap_event_without_variant():
  62. """Envelope with no oneof set (server bug or truncation) should still wrap."""
  63. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  64. envelope = realtime_pb2.RealtimeEvent()
  65. envelope.id = "evt_3"
  66. wrapped = _wrap_event(envelope)
  67. assert wrapped.kind == ""
  68. assert wrapped.payload is None
  69. assert wrapped.cursor is None
  70. def test_wrap_snapshot_copies_families():
  71. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  72. snap = realtime_pb2.RealtimeSnapshot()
  73. snap.server.name = "Acme"
  74. snap.rooms.add().room.id = "r1"
  75. snap.rooms.add().room.id = "r2"
  76. snap.users.add().user.id = "u1"
  77. wrapped = _wrap_snapshot(snap)
  78. assert isinstance(wrapped, RealtimeSnapshot)
  79. assert wrapped.server is not None
  80. assert wrapped.server.name == "Acme"
  81. assert [r.room.id for r in wrapped.rooms] == ["r1", "r2"]
  82. assert [u.user.id for u in wrapped.users] == ["u1"]
  83. assert wrapped.room_groups == []
  84. assert wrapped.active_calls == []
  85. def test_close_code_name_strips_prefix():
  86. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  87. assert (
  88. _close_code_name(realtime_pb2.REALTIME_CLOSE_CODE_SESSION_TERMINATED)
  89. == "SESSION_TERMINATED"
  90. )
  91. assert _close_code_name(12345) == "UNKNOWN_12345"
  92. def test_close_exception_carries_reconnect_hint():
  93. exc = ChattoRealtimeCloseError(
  94. "auth_expired", "please reconnect", reconnect=True, retry_after_ms=5000
  95. )
  96. assert exc.code == "auth_expired"
  97. assert exc.reconnect is True
  98. assert exc.retry_after_ms == 5000
  99. assert "auth_expired" in str(exc)
  100. def test_error_exception_marks_fatal():
  101. fatal = ChattoRealtimeError("protocol_error", "bad frame", fatal=True)
  102. assert fatal.fatal is True
  103. recoverable = ChattoRealtimeError("noop", "ignore me")
  104. assert recoverable.fatal is False
  105. def test_subscribe_roundtrip():
  106. """The v4 handshake message serializes and parses back with the fields set."""
  107. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  108. frame = realtime_pb2.RealtimeSubscribe()
  109. frame.protocol_version = 4
  110. frame.bearer_token = "cht_abc"
  111. frame.initial_state = realtime_pb2.REALTIME_INITIAL_STATE_LIVE_ONLY
  112. wire = frame.SerializeToString()
  113. parsed = realtime_pb2.RealtimeSubscribe()
  114. parsed.ParseFromString(wire)
  115. assert parsed.protocol_version == 4
  116. assert parsed.bearer_token == "cht_abc"
  117. assert parsed.initial_state == realtime_pb2.REALTIME_INITIAL_STATE_LIVE_ONLY
  118. @pytest.mark.parametrize(
  119. "case,set_",
  120. [
  121. ("event", lambda s: s.__setattr__("id", "e")),
  122. ("heartbeat", lambda s: s.__setattr__("cursor", "c")),
  123. ("close", lambda s: s.__setattr__("message", "x")),
  124. ("caught_up", lambda s: s.__setattr__("cursor", "c")),
  125. ("snapshot", lambda s: s.server.__setattr__("name", "x")),
  126. ],
  127. )
  128. def test_server_frame_oneof_cases(case: str, set_):
  129. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  130. frame = realtime_pb2.RealtimeServerFrame()
  131. set_(getattr(frame, case)) # touching a (nested) field marks the oneof case as set
  132. assert frame.WhichOneof("frame") == case