|
@@ -1307,6 +1307,75 @@ class TestInteractiveReactionRouting:
|
|
|
handler.assert_called_once()
|
|
handler.assert_called_once()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+# -- Realtime resume --
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestRealtimeResume:
|
|
|
|
|
+ """A reconnect resumes from the last applied cursor instead of starting at
|
|
|
|
|
+ the live boundary, which would silently skip everything that arrived while
|
|
|
|
|
+ the socket was down."""
|
|
|
|
|
+
|
|
|
|
|
+ def _event(self, cursor):
|
|
|
|
|
+ from chattolib.realtime import RealtimeEvent
|
|
|
|
|
+
|
|
|
|
|
+ return RealtimeEvent(
|
|
|
|
|
+ id=f"evt-{cursor}",
|
|
|
|
|
+ created_at=None,
|
|
|
|
|
+ actor_id="human-1",
|
|
|
|
|
+ cursor=cursor,
|
|
|
|
|
+ kind="reaction_added",
|
|
|
|
|
+ payload=MagicMock(),
|
|
|
|
|
+ raw=None,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ async def _run_loop(self, adapter, passes):
|
|
|
|
|
+ """Drive the loop through ``passes`` stream connections, then stop it.
|
|
|
|
|
+
|
|
|
|
|
+ ``passes`` is a list of event lists, one per connection; the recorded
|
|
|
|
|
+ ``resume_cursor`` of each call comes back so the test can assert what
|
|
|
|
|
+ the second connection asked the server for.
|
|
|
|
|
+ """
|
|
|
|
|
+ adapter._closing = False
|
|
|
|
|
+ adapter._require_chatto_client = AsyncMock(return_value=adapter._chatto_client)
|
|
|
|
|
+ adapter._refresh_rooms = AsyncMock()
|
|
|
|
|
+ adapter._handle_realtime_event = AsyncMock()
|
|
|
|
|
+
|
|
|
|
|
+ resume_cursors: list[str | None] = []
|
|
|
|
|
+
|
|
|
|
|
+ async def fake_stream(client, *, resume_cursor=None):
|
|
|
|
|
+ resume_cursors.append(resume_cursor)
|
|
|
|
|
+ for event in passes[len(resume_cursors) - 1]:
|
|
|
|
|
+ yield event
|
|
|
|
|
+
|
|
|
|
|
+ async def stop_after_last_pass(seconds):
|
|
|
|
|
+ if len(resume_cursors) >= len(passes):
|
|
|
|
|
+ adapter._closing = True
|
|
|
|
|
+
|
|
|
|
|
+ with (
|
|
|
|
|
+ patch("adapter.stream_events", fake_stream),
|
|
|
|
|
+ patch.object(adapter, "_sleep_interruptible", stop_after_last_pass),
|
|
|
|
|
+ ):
|
|
|
|
|
+ await adapter._chattolib_event_loop()
|
|
|
|
|
+
|
|
|
|
|
+ return resume_cursors
|
|
|
|
|
+
|
|
|
|
|
+ async def test_reconnect_resumes_from_the_last_event_cursor(self):
|
|
|
|
|
+ adapter = _make_adapter()
|
|
|
|
|
+ resume_cursors = await self._run_loop(
|
|
|
|
|
+ adapter, [[self._event("cur-1")], []]
|
|
|
|
|
+ )
|
|
|
|
|
+ assert resume_cursors == [None, "cur-1"]
|
|
|
|
|
+
|
|
|
|
|
+ async def test_an_unreplayable_event_keeps_the_previous_cursor(self):
|
|
|
|
|
+ """``cursor`` is None for events the server cannot replay; it must not
|
|
|
|
|
+ wipe the last good resume point."""
|
|
|
|
|
+ adapter = _make_adapter()
|
|
|
|
|
+ resume_cursors = await self._run_loop(
|
|
|
|
|
+ adapter, [[self._event("cur-1"), self._event(None)], []]
|
|
|
|
|
+ )
|
|
|
|
|
+ assert resume_cursors == [None, "cur-1"]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
# -- chat_type mapping --
|
|
# -- chat_type mapping --
|
|
|
|
|
|
|
|
|
|
|