test_chat.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. import pytest
  2. import httpx
  3. from dataclasses import dataclass
  4. from unittest.mock import patch
  5. from room_cal.api import create_app
  6. from room_cal.service_api import ServiceAPI
  7. from room_cal.service_api.types import ServiceAuthError
  8. # ── stubs ──────────────────────────────────────────────────────────────────────
  9. @dataclass
  10. class _Msg:
  11. id: str
  12. created_at_ms: str
  13. body: str | None
  14. sender_account_id: str
  15. sender_name: str | None = None
  16. @dataclass
  17. class _Thread:
  18. thread_id: str
  19. messages: list
  20. class _StubChatClient:
  21. """Satisfies ChatClient (which extends ServiceClient) so isinstance checks in
  22. ServiceAPI.chat_client pass — the calendar/pricing methods are unused by these tests."""
  23. def __init__(self, thread=None, error=None):
  24. self._thread = thread
  25. self._error = error
  26. async def get_calendar(self, date_from, date_to):
  27. return {}
  28. async def get_reservations(self, date_from, date_to):
  29. return []
  30. async def set_price(self, d, price):
  31. pass
  32. async def set_availability(self, d, available):
  33. pass
  34. async def probe(self):
  35. pass
  36. async def get_thread(self, chat_id):
  37. if self._error:
  38. raise self._error
  39. return self._thread
  40. async def send_message(self, chat_id, body):
  41. if self._error:
  42. raise self._error
  43. class _NoChatClient:
  44. """Implements only ServiceClient, not ChatClient — no get_thread/send_message."""
  45. async def get_calendar(self, date_from, date_to):
  46. return {}
  47. async def get_reservations(self, date_from, date_to):
  48. return []
  49. async def set_price(self, d, price):
  50. pass
  51. async def set_availability(self, d, available):
  52. pass
  53. async def probe(self):
  54. pass
  55. _STUB_THREAD = _Thread(
  56. thread_id='t1',
  57. messages=[
  58. _Msg(id='m1', created_at_ms='1000', body='Hello', sender_account_id='acc1', sender_name='acc1'),
  59. _Msg(id='m2', created_at_ms='2000', body=None, sender_account_id='acc2', sender_name='acc2'),
  60. ],
  61. )
  62. _AUTHED = {'sub': 'u', 'email': 'u@test.com', 'name': 'User'}
  63. def _install_api(**clients):
  64. """Install a ServiceAPI singleton with injected stub clients; conftest resets it after each test."""
  65. ServiceAPI.set(ServiceAPI(clients=clients))
  66. # ── fixtures ───────────────────────────────────────────────────────────────────
  67. @pytest.fixture
  68. def app():
  69. return create_app()
  70. @pytest.fixture
  71. async def client(app):
  72. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as c:
  73. yield c
  74. # ── GET /chat: error paths ─────────────────────────────────────────────────────
  75. @pytest.mark.asyncio
  76. async def test_get_thread_unknown_service(client):
  77. _install_api(airbnb=_StubChatClient(thread=_STUB_THREAD))
  78. with patch('room_cal.api.get_user', return_value=_AUTHED):
  79. r = await client.get('/chat/nonexistent/abc')
  80. assert r.status_code == 400
  81. @pytest.mark.asyncio
  82. async def test_get_thread_no_chat_support(client):
  83. _install_api(booking=_NoChatClient())
  84. with patch('room_cal.api.get_user', return_value=_AUTHED):
  85. r = await client.get('/chat/booking/abc')
  86. assert r.status_code == 400
  87. @pytest.mark.asyncio
  88. async def test_get_thread_auth_error(client):
  89. _install_api(airbnb=_StubChatClient(error=ServiceAuthError('stale')))
  90. with patch('room_cal.api.get_user', return_value=_AUTHED):
  91. r = await client.get('/chat/airbnb/t1')
  92. assert r.status_code == 503
  93. @pytest.mark.asyncio
  94. async def test_get_thread_remote_error(client):
  95. _install_api(airbnb=_StubChatClient(error=RuntimeError('network failure')))
  96. with patch('room_cal.api.get_user', return_value=_AUTHED):
  97. r = await client.get('/chat/airbnb/t1')
  98. assert r.status_code == 502
  99. @pytest.mark.asyncio
  100. async def test_get_thread_requires_auth(client):
  101. _install_api(airbnb=_StubChatClient(thread=_STUB_THREAD))
  102. r = await client.get('/chat/airbnb/t1')
  103. assert r.status_code == 401
  104. # ── GET /chat: success + normalization ─────────────────────────────────────────
  105. @pytest.mark.asyncio
  106. async def test_get_thread_normalizes_response(client):
  107. _install_api(airbnb=_StubChatClient(thread=_STUB_THREAD))
  108. with patch('room_cal.api.get_user', return_value=_AUTHED):
  109. r = await client.get('/chat/airbnb/t1')
  110. assert r.status_code == 200
  111. data = r.json()
  112. assert data['chat_id'] == 't1'
  113. assert len(data['messages']) == 2
  114. m0 = data['messages'][0]
  115. assert m0['id'] == 'm1'
  116. assert m0['created_at'] == '1000'
  117. assert m0['body'] == 'Hello'
  118. assert m0['sender'] == 'acc1'
  119. assert data['messages'][1]['body'] is None
  120. @pytest.mark.asyncio
  121. async def test_get_thread_includes_is_reply_needed_when_present(client):
  122. @dataclass
  123. class _BookingThread:
  124. reservation_id: str
  125. messages: list
  126. is_reply_needed: bool = True
  127. thread = _BookingThread(reservation_id='r1', messages=[], is_reply_needed=True)
  128. _install_api(booking=_StubChatClient(thread=thread))
  129. with patch('room_cal.api.get_user', return_value=_AUTHED):
  130. r = await client.get('/chat/booking/r1')
  131. assert r.status_code == 200
  132. assert r.json()['is_reply_needed'] is True
  133. @pytest.mark.asyncio
  134. async def test_get_thread_omits_is_reply_needed_when_absent(client):
  135. _install_api(airbnb=_StubChatClient(thread=_STUB_THREAD))
  136. with patch('room_cal.api.get_user', return_value=_AUTHED):
  137. r = await client.get('/chat/airbnb/t1')
  138. assert 'is_reply_needed' not in r.json()
  139. # ── POST /chat: error paths ────────────────────────────────────────────────────
  140. @pytest.mark.asyncio
  141. async def test_post_message_unknown_service(client):
  142. _install_api(airbnb=_StubChatClient(thread=_STUB_THREAD))
  143. with patch('room_cal.api.get_user', return_value=_AUTHED):
  144. r = await client.post('/chat/nonexistent/abc', json={'body': 'hi'})
  145. assert r.status_code == 400
  146. @pytest.mark.asyncio
  147. async def test_post_message_no_chat_support(client):
  148. _install_api(booking=_NoChatClient())
  149. with patch('room_cal.api.get_user', return_value=_AUTHED):
  150. r = await client.post('/chat/booking/abc', json={'body': 'hi'})
  151. assert r.status_code == 400
  152. @pytest.mark.asyncio
  153. async def test_post_message_auth_error(client):
  154. _install_api(airbnb=_StubChatClient(error=ServiceAuthError('stale')))
  155. with patch('room_cal.api.get_user', return_value=_AUTHED):
  156. r = await client.post('/chat/airbnb/t1', json={'body': 'hi'})
  157. assert r.status_code == 503
  158. @pytest.mark.asyncio
  159. async def test_post_message_remote_error(client):
  160. _install_api(airbnb=_StubChatClient(error=RuntimeError('send failed')))
  161. with patch('room_cal.api.get_user', return_value=_AUTHED):
  162. r = await client.post('/chat/airbnb/t1', json={'body': 'hi'})
  163. assert r.status_code == 502
  164. @pytest.mark.asyncio
  165. async def test_post_message_requires_auth(client):
  166. _install_api(airbnb=_StubChatClient())
  167. r = await client.post('/chat/airbnb/t1', json={'body': 'hi'})
  168. assert r.status_code == 401
  169. # ── POST /chat: success ────────────────────────────────────────────────────────
  170. @pytest.mark.asyncio
  171. async def test_post_message_success(client):
  172. _install_api(airbnb=_StubChatClient())
  173. with patch('room_cal.api.get_user', return_value=_AUTHED):
  174. r = await client.post('/chat/airbnb/t1', json={'body': 'Hello guest'})
  175. assert r.status_code == 204
  176. # ── live integration ───────────────────────────────────────────────────────────
  177. @pytest.mark.asyncio
  178. @pytest.mark.live
  179. async def test_get_thread_live():
  180. """Fetch a real thread using chat_id from a recent CalDAV reservation."""
  181. from datetime import date, timedelta
  182. from room_cal.reservations import Reservation
  183. from room_cal.service_api._protocols import ChatClient
  184. from room_cal._utils import safe_json_loads
  185. res_cal = Reservation()
  186. events = await res_cal.list(date.today() - timedelta(days=90), date.today())
  187. target_service = None
  188. target_chat_id = None
  189. for ev in events:
  190. xd = safe_json_loads(ev.get('x_data', '')) or {}
  191. service = xd.get('service')
  192. chat_id = xd.get('chat_id')
  193. if not service or not chat_id:
  194. continue
  195. probe = ServiceAPI()
  196. if service in probe._clients and isinstance(probe._clients[service], ChatClient):
  197. target_service = service
  198. target_chat_id = chat_id
  199. break
  200. if target_service is None:
  201. pytest.skip('No recent reservation with chat_id on a chat-capable service')
  202. thread = await ServiceAPI().chat_client(target_service).get_thread(target_chat_id)
  203. assert thread is not None
  204. assert hasattr(thread, 'messages')
  205. assert isinstance(thread.messages, list)