| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251 |
- 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
- pytestmark = pytest.mark.skip(reason="chat tests broken: _StubChatClient predates ChatClient protocol check; revisit when chat work resumes")
- # ── stubs ──────────────────────────────────────────────────────────────────────
- @dataclass
- class _Msg:
- id: str
- created_at_ms: str
- body: str | None
- sender_account_id: str
- @dataclass
- class _Thread:
- thread_id: str
- messages: list
- class _StubChatClient:
- supports_chat = True
- def __init__(self, thread=None, error=None):
- self._thread = thread
- self._error = error
- 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:
- supports_chat = False
- _STUB_THREAD = _Thread(
- thread_id='t1',
- messages=[
- _Msg(id='m1', created_at_ms='1000', body='Hello', sender_account_id='acc1'),
- _Msg(id='m2', created_at_ms='2000', body=None, sender_account_id='acc2'),
- ],
- )
- _AUTHED = {'sub': 'u', 'email': 'u@test.com', 'name': 'User'}
- def _api_factory(**clients):
- """Return a no-arg callable that yields a ServiceAPI with injected stub clients."""
- instance = ServiceAPI(clients=clients)
- return lambda: instance
- # ── 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):
- factory = _api_factory(airbnb=_StubChatClient(thread=_STUB_THREAD))
- with patch('room_cal.api._routes.chat.ServiceAPI', factory), \
- 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):
- factory = _api_factory(booking=_NoChatClient())
- with patch('room_cal.api._routes.chat.ServiceAPI', factory), \
- patch('room_cal.api.get_user', return_value=_AUTHED):
- r = await client.get('/chat/booking/abc')
- assert r.status_code == 400
- @pytest.mark.skip(reason="ServiceAuthError → 503 mapping not yet implemented")
- @pytest.mark.asyncio
- async def test_get_thread_auth_error(client):
- factory = _api_factory(airbnb=_StubChatClient(error=ServiceAuthError('stale')))
- with patch('room_cal.api._routes.chat.ServiceAPI', factory), \
- patch('room_cal.api.get_user', return_value=_AUTHED):
- r = await client.get('/chat/airbnb/t1')
- assert r.status_code == 503
- @pytest.mark.skip(reason="RuntimeError → 502 mapping not yet implemented")
- @pytest.mark.asyncio
- async def test_get_thread_remote_error(client):
- factory = _api_factory(airbnb=_StubChatClient(error=RuntimeError('network failure')))
- with patch('room_cal.api._routes.chat.ServiceAPI', factory), \
- 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):
- factory = _api_factory(airbnb=_StubChatClient(thread=_STUB_THREAD))
- with patch('room_cal.api._routes.chat.ServiceAPI', factory):
- 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):
- factory = _api_factory(airbnb=_StubChatClient(thread=_STUB_THREAD))
- with patch('room_cal.api._routes.chat.ServiceAPI', factory), \
- 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)
- factory = _api_factory(booking=_StubChatClient(thread=thread))
- # Override supports_chat on the stub for this one test
- factory().chat_client('booking') # warm call to verify it resolves
- with patch('room_cal.api._routes.chat.ServiceAPI', factory), \
- 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):
- factory = _api_factory(airbnb=_StubChatClient(thread=_STUB_THREAD))
- with patch('room_cal.api._routes.chat.ServiceAPI', factory), \
- 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):
- factory = _api_factory(airbnb=_StubChatClient(thread=_STUB_THREAD))
- with patch('room_cal.api._routes.chat.ServiceAPI', factory), \
- 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):
- factory = _api_factory(booking=_NoChatClient())
- with patch('room_cal.api._routes.chat.ServiceAPI', factory), \
- 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):
- factory = _api_factory(airbnb=_StubChatClient(error=ServiceAuthError('stale')))
- with patch('room_cal.api._routes.chat.ServiceAPI', factory), \
- 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):
- factory = _api_factory(airbnb=_StubChatClient(error=RuntimeError('send failed')))
- with patch('room_cal.api._routes.chat.ServiceAPI', factory), \
- 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):
- factory = _api_factory(airbnb=_StubChatClient())
- with patch('room_cal.api._routes.chat.ServiceAPI', factory):
- 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):
- factory = _api_factory(airbnb=_StubChatClient())
- with patch('room_cal.api._routes.chat.ServiceAPI', factory), \
- 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
- 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._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 getattr(probe._clients[service], 'supports_chat', False):
- 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)
|