import pytest import httpx from dataclasses import dataclass from unittest.mock import patch from room_cal.api import create_app from room_cal.service_api import ServiceAPI from room_cal.service_api.types import ServiceAuthError # ── stubs ────────────────────────────────────────────────────────────────────── @dataclass class _Msg: id: str created_at_ms: str body: str | None sender_account_id: str sender_name: str | None = None @dataclass class _Thread: thread_id: str messages: list class _StubChatClient: """Satisfies ChatClient (which extends ServiceClient) so isinstance checks in ServiceAPI.chat_client pass — the calendar/pricing methods are unused by these tests.""" def __init__(self, thread=None, error=None): self._thread = thread self._error = error async def get_calendar(self, date_from, date_to): return {} async def get_reservations(self, date_from, date_to): return [] async def set_price(self, d, price): pass async def set_availability(self, d, available): pass async def probe(self): pass async def get_thread(self, chat_id): if self._error: raise self._error return self._thread async def send_message(self, chat_id, body): if self._error: raise self._error class _NoChatClient: """Implements only ServiceClient, not ChatClient — no get_thread/send_message.""" async def get_calendar(self, date_from, date_to): return {} async def get_reservations(self, date_from, date_to): return [] async def set_price(self, d, price): pass async def set_availability(self, d, available): pass async def probe(self): pass _STUB_THREAD = _Thread( thread_id='t1', messages=[ _Msg(id='m1', created_at_ms='1000', body='Hello', sender_account_id='acc1', sender_name='acc1'), _Msg(id='m2', created_at_ms='2000', body=None, sender_account_id='acc2', sender_name='acc2'), ], ) _AUTHED = {'sub': 'u', 'email': 'u@test.com', 'name': 'User'} def _install_api(**clients): """Install a ServiceAPI singleton with injected stub clients; conftest resets it after each test.""" ServiceAPI.set(ServiceAPI(clients=clients)) # ── fixtures ─────────────────────────────────────────────────────────────────── @pytest.fixture def app(): return create_app() @pytest.fixture async def client(app): async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as c: yield c # ── GET /chat: error paths ───────────────────────────────────────────────────── @pytest.mark.asyncio async def test_get_thread_unknown_service(client): _install_api(airbnb=_StubChatClient(thread=_STUB_THREAD)) with patch('room_cal.api.get_user', return_value=_AUTHED): r = await client.get('/chat/nonexistent/abc') assert r.status_code == 400 @pytest.mark.asyncio async def test_get_thread_no_chat_support(client): _install_api(booking=_NoChatClient()) with patch('room_cal.api.get_user', return_value=_AUTHED): r = await client.get('/chat/booking/abc') assert r.status_code == 400 @pytest.mark.asyncio async def test_get_thread_auth_error(client): _install_api(airbnb=_StubChatClient(error=ServiceAuthError('stale'))) with patch('room_cal.api.get_user', return_value=_AUTHED): r = await client.get('/chat/airbnb/t1') assert r.status_code == 503 @pytest.mark.asyncio async def test_get_thread_remote_error(client): _install_api(airbnb=_StubChatClient(error=RuntimeError('network failure'))) with patch('room_cal.api.get_user', return_value=_AUTHED): r = await client.get('/chat/airbnb/t1') assert r.status_code == 502 @pytest.mark.asyncio async def test_get_thread_requires_auth(client): _install_api(airbnb=_StubChatClient(thread=_STUB_THREAD)) r = await client.get('/chat/airbnb/t1') assert r.status_code == 401 # ── GET /chat: success + normalization ───────────────────────────────────────── @pytest.mark.asyncio async def test_get_thread_normalizes_response(client): _install_api(airbnb=_StubChatClient(thread=_STUB_THREAD)) with patch('room_cal.api.get_user', return_value=_AUTHED): r = await client.get('/chat/airbnb/t1') assert r.status_code == 200 data = r.json() assert data['chat_id'] == 't1' assert len(data['messages']) == 2 m0 = data['messages'][0] assert m0['id'] == 'm1' assert m0['created_at'] == '1000' assert m0['body'] == 'Hello' assert m0['sender'] == 'acc1' assert data['messages'][1]['body'] is None @pytest.mark.asyncio async def test_get_thread_includes_is_reply_needed_when_present(client): @dataclass class _BookingThread: reservation_id: str messages: list is_reply_needed: bool = True thread = _BookingThread(reservation_id='r1', messages=[], is_reply_needed=True) _install_api(booking=_StubChatClient(thread=thread)) with patch('room_cal.api.get_user', return_value=_AUTHED): r = await client.get('/chat/booking/r1') assert r.status_code == 200 assert r.json()['is_reply_needed'] is True @pytest.mark.asyncio async def test_get_thread_omits_is_reply_needed_when_absent(client): _install_api(airbnb=_StubChatClient(thread=_STUB_THREAD)) with patch('room_cal.api.get_user', return_value=_AUTHED): r = await client.get('/chat/airbnb/t1') assert 'is_reply_needed' not in r.json() # ── POST /chat: error paths ──────────────────────────────────────────────────── @pytest.mark.asyncio async def test_post_message_unknown_service(client): _install_api(airbnb=_StubChatClient(thread=_STUB_THREAD)) with patch('room_cal.api.get_user', return_value=_AUTHED): r = await client.post('/chat/nonexistent/abc', json={'body': 'hi'}) assert r.status_code == 400 @pytest.mark.asyncio async def test_post_message_no_chat_support(client): _install_api(booking=_NoChatClient()) with patch('room_cal.api.get_user', return_value=_AUTHED): r = await client.post('/chat/booking/abc', json={'body': 'hi'}) assert r.status_code == 400 @pytest.mark.asyncio async def test_post_message_auth_error(client): _install_api(airbnb=_StubChatClient(error=ServiceAuthError('stale'))) with patch('room_cal.api.get_user', return_value=_AUTHED): r = await client.post('/chat/airbnb/t1', json={'body': 'hi'}) assert r.status_code == 503 @pytest.mark.asyncio async def test_post_message_remote_error(client): _install_api(airbnb=_StubChatClient(error=RuntimeError('send failed'))) with patch('room_cal.api.get_user', return_value=_AUTHED): r = await client.post('/chat/airbnb/t1', json={'body': 'hi'}) assert r.status_code == 502 @pytest.mark.asyncio async def test_post_message_requires_auth(client): _install_api(airbnb=_StubChatClient()) r = await client.post('/chat/airbnb/t1', json={'body': 'hi'}) assert r.status_code == 401 # ── POST /chat: success ──────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_post_message_success(client): _install_api(airbnb=_StubChatClient()) with patch('room_cal.api.get_user', return_value=_AUTHED): r = await client.post('/chat/airbnb/t1', json={'body': 'Hello guest'}) assert r.status_code == 204 # ── live integration ─────────────────────────────────────────────────────────── @pytest.mark.asyncio @pytest.mark.live async def test_get_thread_live(): """Fetch a real thread using chat_id from a recent CalDAV reservation.""" from datetime import date, timedelta from room_cal.reservations import Reservation from room_cal.service_api._protocols import ChatClient from room_cal._utils import safe_json_loads res_cal = Reservation() events = await res_cal.list(date.today() - timedelta(days=90), date.today()) target_service = None target_chat_id = None for ev in events: xd = safe_json_loads(ev.get('x_data', '')) or {} service = xd.get('service') chat_id = xd.get('chat_id') if not service or not chat_id: continue probe = ServiceAPI() if service in probe._clients and isinstance(probe._clients[service], ChatClient): target_service = service target_chat_id = chat_id break if target_service is None: pytest.skip('No recent reservation with chat_id on a chat-capable service') thread = await ServiceAPI().chat_client(target_service).get_thread(target_chat_id) assert thread is not None assert hasattr(thread, 'messages') assert isinstance(thread.messages, list)