test_bot_integration.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. """Integration tests for the :class:`~chattolib.bot.Bot` realtime event path.
  2. These run against a live Chatto server and exercise the *receive* side of the
  3. bot framework end-to-end: open the ``/api/realtime`` WebSocket, subscribe to
  4. rooms, and confirm that posted messages come back as typed
  5. :class:`~chattolib.bot.BotMessageEvent` values.
  6. Run with::
  7. CHATTO_BOT_KEY=cht_BK_... pytest tests/test_bot_integration.py -v
  8. Requires the ``CHATTO_BOT_KEY`` environment variable (a bot key, used
  9. directly as a bearer token). The optional ``CHATTO_BASE_URL`` overrides the
  10. target server (defaults to the production ``https://chat.chatto.run``).
  11. Unlike ``test_integration.py`` (which needs a human login/password), a bot
  12. key is enough here — the bot posts to itself, so no second identity is
  13. required. The ``test_bot_receives_message_from_another_user`` test is skipped
  14. unless ``CHATTO_SECOND_LOGIN``/``CHATTO_SECOND_PASSWORD`` are also set.
  15. """
  16. import asyncio
  17. import os
  18. import pytest
  19. from chattolib.bot import Bot, BotMessageEvent
  20. pytestmark = pytest.mark.skipif(
  21. not os.environ.get("CHATTO_BOT_KEY"),
  22. reason="CHATTO_BOT_KEY env var required",
  23. )
  24. BASE_URL = os.environ.get("CHATTO_BASE_URL", "https://chat.chatto.run")
  25. BOT_KEY = os.environ.get("CHATTO_BOT_KEY")
  26. SENTINEL = "[chattolib-bot-integration] receive-path check"
  27. async def _login_bot() -> Bot:
  28. assert BOT_KEY is not None # guaranteed by the module-level skipif
  29. return await Bot.login(BOT_KEY, base_url=BASE_URL)
  30. async def _pick_member_room_id(bot: Bot) -> str:
  31. """Return the id of a room the bot is a member of (joining one if needed)."""
  32. rooms = await bot.list_rooms()
  33. target = next((r for r in rooms if r.room and r.viewer_state.is_member), None)
  34. if target is not None:
  35. return target.room.id
  36. # No member room yet — join the first room in the first group.
  37. groups = await bot.list_room_groups()
  38. if not groups or not groups[0].rooms:
  39. pytest.skip("bot has no rooms and no room groups to join")
  40. first = groups[0].rooms[0].room
  41. if first is None:
  42. pytest.skip("first room group has no rooms")
  43. await bot.join_room(first.id)
  44. return first.id
  45. async def _wait_for_event(
  46. bot: Bot,
  47. room_id: str,
  48. *,
  49. trigger,
  50. matches,
  51. timeout: float = 30.0,
  52. ) -> list[BotMessageEvent]:
  53. """Subscribe to ``room_id``'s timeline, fire ``trigger``, await the event.
  54. ``trigger`` is an awaitable fired *after* the stream is subscribed (so the
  55. posted message is not projected before we're listening). ``matches`` is
  56. called with each incoming :class:`BotMessageEvent`; the first call that
  57. returns ``True`` ends the wait. Returns the list of matching events.
  58. Uses ``bot.run(until=...)`` so the stream shuts down cleanly — cancelling
  59. the run task directly would propagate into the websocket recv loop.
  60. """
  61. received: list[BotMessageEvent] = []
  62. done = asyncio.Event()
  63. def on_event(event: BotMessageEvent) -> None:
  64. if matches(event):
  65. received.append(event)
  66. done.set()
  67. bot.on("message", on_event)
  68. stop = asyncio.Event()
  69. run_task = asyncio.create_task(bot.run(retained_room_ids=[room_id], until=stop))
  70. await asyncio.sleep(2) # let the stream connect + subscribe
  71. try:
  72. await trigger()
  73. await asyncio.wait_for(done.wait(), timeout=timeout)
  74. finally:
  75. stop.set() # tell run() to stop looping
  76. try:
  77. await asyncio.wait_for(run_task, timeout=10)
  78. except (TimeoutError, asyncio.CancelledError, Exception):
  79. run_task.cancel()
  80. try:
  81. await run_task
  82. except (asyncio.CancelledError, Exception):
  83. pass
  84. return received
  85. async def test_bot_receives_its_own_message():
  86. """Post a message and confirm it comes back over the realtime stream.
  87. This is the core receive-path check: the bot subscribes to a room, posts
  88. into it, and the server projects the ``message_posted`` timeline event
  89. back to the bot as a ``BotMessageEvent``.
  90. """
  91. bot = await _login_bot()
  92. try:
  93. target_id = await _pick_member_room_id(bot)
  94. received = await _wait_for_event(
  95. bot,
  96. target_id,
  97. trigger=lambda: bot.say(target_id, SENTINEL),
  98. matches=lambda e: e.room_id == target_id and e.body == SENTINEL,
  99. )
  100. assert len(received) >= 1, "bot did not receive its own posted message"
  101. # The echoed event should carry the same body and the bot as actor.
  102. assert received[0].body == SENTINEL
  103. assert received[0].message.actor_id == bot.user.id
  104. finally:
  105. await bot.close()
  106. async def test_bot_receives_message_from_another_user():
  107. """Receive a message posted by a *different* user (needs 2nd identity).
  108. Skipped unless ``CHATTO_SECOND_LOGIN``/``CHATTO_SECOND_PASSWORD`` are set.
  109. """
  110. second_login = os.environ.get("CHATTO_SECOND_LOGIN")
  111. second_password = os.environ.get("CHATTO_SECOND_PASSWORD")
  112. if not second_login or not second_password:
  113. pytest.skip("CHATTO_SECOND_LOGIN / CHATTO_SECOND_PASSWORD not set")
  114. from chattolib.client import ChattoClient
  115. bot = await _login_bot()
  116. try:
  117. target_id = await _pick_member_room_id(bot)
  118. async def trigger() -> None:
  119. login = ChattoClient.login(second_login, second_password, base_url=BASE_URL)
  120. async with await login as other:
  121. await other.post_message(target_id, SENTINEL)
  122. received = await _wait_for_event(
  123. bot,
  124. target_id,
  125. trigger=trigger,
  126. matches=lambda e: e.message.actor_id != bot.user.id and e.room_id == target_id,
  127. )
  128. assert len(received) >= 1, "bot did not receive the second user's message"
  129. assert received[0].message.actor_id != bot.user.id
  130. finally:
  131. await bot.close()