import unittest import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock, patch from fastapi import HTTPException from app.integrations.telegram_satellite_service import TelegramSatelliteService from app.services.orchestration.conversation_state_store import ConversationStateStore class _DummySession: def close(self): return None class TelegramMultimodalTests(unittest.IsolatedAsyncioTestCase): def _build_service(self) -> TelegramSatelliteService: service = TelegramSatelliteService( "token-teste", state_repository=ConversationStateStore(), ) self._service_under_test = service return service async def asyncTearDown(self): service = getattr(self, "_service_under_test", None) if service is not None: await service._shutdown_chat_workers() async def test_process_message_uses_extracted_image_message(self): service = self._build_service() tools_db = _DummySession() mock_db = _DummySession() with patch("app.integrations.telegram_satellite_service.SessionLocal", return_value=tools_db), patch( "app.integrations.telegram_satellite_service.SessionMockLocal", return_value=mock_db, ), patch("app.integrations.telegram_satellite_service.UserService") as user_service_cls, patch( "app.integrations.telegram_satellite_service.OrquestradorService" ) as orchestrator_cls, patch.object( service, "_build_orchestration_message_from_image", AsyncMock(return_value="[imagem recebida no telegram]\nDados extraidos da imagem: Registrar multa de aluguel: placa ABC1D23; valor 293,47; auto_infracao A123456."), ): user_service_cls.return_value.get_or_create.return_value = SimpleNamespace(id=7) orchestrator_cls.return_value.handle_message = AsyncMock(return_value="ok") answer = await service._process_message( text="segue a multa", sender={"id": 99, "first_name": "Vitor"}, chat_id=99, image_attachments=[{"mime_type": "image/jpeg", "data": b"123"}], ) self.assertEqual(answer, "ok") orchestrator_cls.return_value.handle_message.assert_awaited_once() kwargs = orchestrator_cls.return_value.handle_message.await_args.kwargs self.assertIn("Registrar multa de aluguel", kwargs["message"]) self.assertEqual(kwargs["user_id"], 7) async def test_process_message_returns_direct_failure_for_unreadable_image(self): service = self._build_service() tools_db = _DummySession() mock_db = _DummySession() with patch("app.integrations.telegram_satellite_service.SessionLocal", return_value=tools_db), patch( "app.integrations.telegram_satellite_service.SessionMockLocal", return_value=mock_db, ), patch("app.integrations.telegram_satellite_service.UserService") as user_service_cls, patch( "app.integrations.telegram_satellite_service.OrquestradorService" ) as orchestrator_cls, patch.object( service, "_build_orchestration_message_from_image", AsyncMock(return_value="Nao consegui identificar os dados da imagem. Descreva o documento ou envie uma foto mais nitida."), ): user_service_cls.return_value.get_or_create.return_value = SimpleNamespace(id=7) orchestrator_cls.return_value.handle_message = AsyncMock() answer = await service._process_message( text="", sender={"id": 99}, chat_id=99, image_attachments=[{"mime_type": "image/jpeg", "data": b"123"}], ) self.assertIn("Nao consegui identificar os dados da imagem", answer) self.assertFalse(orchestrator_cls.return_value.handle_message.await_count) async def test_process_message_returns_direct_failure_for_receipt_without_watermark(self): service = self._build_service() tools_db = _DummySession() mock_db = _DummySession() with patch("app.integrations.telegram_satellite_service.SessionLocal", return_value=tools_db), patch( "app.integrations.telegram_satellite_service.SessionMockLocal", return_value=mock_db, ), patch("app.integrations.telegram_satellite_service.UserService") as user_service_cls, patch( "app.integrations.telegram_satellite_service.OrquestradorService" ) as orchestrator_cls, patch.object( service, "_build_orchestration_message_from_image", AsyncMock(return_value="O comprovante enviado nao e valido. Envie um comprovante valido com a marca d'agua SysaltiIA visivel."), ): user_service_cls.return_value.get_or_create.return_value = SimpleNamespace(id=7) orchestrator_cls.return_value.handle_message = AsyncMock() answer = await service._process_message( text="segue o comprovante", sender={"id": 99}, chat_id=99, image_attachments=[{"mime_type": "image/jpeg", "data": b"123"}], ) self.assertIn("marca d'agua SysaltiIA visivel", answer) self.assertFalse(orchestrator_cls.return_value.handle_message.await_count) async def test_handle_update_masks_sensitive_domain_error_in_logs(self): service = self._build_service() update = { "update_id": 1, "message": { "chat": {"id": 99}, "from": {"id": 99}, "text": "segue o pagamento", }, } with patch.object(service, "_extract_image_attachments", AsyncMock(return_value=[])), patch.object( service, "_process_message", AsyncMock( side_effect=HTTPException( status_code=409, detail={ "cpf": "12345678909", "placa": "ABC1D23", "external_id": "987654321", "identificador_comprovante": "NSU123", }, ) ), ), patch.object(service, "_send_message", AsyncMock()), patch( "app.integrations.telegram_satellite_service.logger.warning" ) as logger_warning: await service._handle_update(session=SimpleNamespace(), update=update) self.assertTrue(logger_warning.called) logged_detail = str(logger_warning.call_args.args[1]) self.assertNotIn("12345678909", logged_detail) self.assertNotIn("ABC1D23", logged_detail) self.assertNotIn("987654321", logged_detail) self.assertNotIn("NSU123", logged_detail) self.assertIn("***.***.***-09", logged_detail) self.assertIn("ABC***3", logged_detail) self.assertIn("******321", logged_detail) self.assertIn("***123", logged_detail) async def test_handle_update_reuses_cached_answer_for_duplicate_message(self): service = self._build_service() update = { "update_id": 10, "message": { "message_id": 77, "chat": {"id": 99}, "from": {"id": 99}, "text": "quero ver a frota", }, } with patch.object(service, "_extract_image_attachments", AsyncMock(return_value=[])), patch.object( service, "_process_message", AsyncMock(return_value="Segue a frota disponivel."), ) as process_message, patch.object(service, "_send_message", AsyncMock()) as send_message: await service._handle_update(session=SimpleNamespace(), update=update) await service._handle_update(session=SimpleNamespace(), update=update) self.assertEqual(process_message.await_count, 1) self.assertEqual(send_message.await_count, 2) first_text = send_message.await_args_list[0].kwargs["text"] second_text = send_message.await_args_list[1].kwargs["text"] self.assertEqual(first_text, "Segue a frota disponivel.") self.assertEqual(second_text, "Segue a frota disponivel.") async def test_handle_update_processes_same_text_again_when_message_id_changes(self): service = self._build_service() first_update = { "update_id": 10, "message": { "message_id": 77, "chat": {"id": 99}, "from": {"id": 99}, "text": "quero ver a frota", }, } second_update = { "update_id": 11, "message": { "message_id": 78, "chat": {"id": 99}, "from": {"id": 99}, "text": "quero ver a frota", }, } with patch.object(service, "_extract_image_attachments", AsyncMock(return_value=[])), patch.object( service, "_process_message", AsyncMock(side_effect=["Resposta 1", "Resposta 2"]), ) as process_message, patch.object(service, "_send_message", AsyncMock()) as send_message: await service._handle_update(session=SimpleNamespace(), update=first_update) await service._handle_update(session=SimpleNamespace(), update=second_update) self.assertEqual(process_message.await_count, 2) self.assertEqual(send_message.await_count, 2) self.assertEqual(send_message.await_args_list[0].kwargs["text"], "Resposta 1") self.assertEqual(send_message.await_args_list[1].kwargs["text"], "Resposta 2") async def test_schedule_update_processing_allows_parallel_chats(self): service = self._build_service() release_first_chat = asyncio.Event() chat_one_started = asyncio.Event() started_chats: list[int] = [] async def fake_handle_update(*, session, update): chat_id = update["message"]["chat"]["id"] started_chats.append(chat_id) if chat_id == 1: chat_one_started.set() await release_first_chat.wait() with patch.object(service, "_handle_update", new=fake_handle_update): await service._schedule_update_processing( session=SimpleNamespace(), update={"update_id": 1, "message": {"chat": {"id": 1}, "text": "primeiro"}}, ) await chat_one_started.wait() await service._schedule_update_processing( session=SimpleNamespace(), update={"update_id": 2, "message": {"chat": {"id": 2}, "text": "segundo"}}, ) await asyncio.sleep(0) self.assertEqual(started_chats, [1, 2]) release_first_chat.set() await asyncio.sleep(0) async def test_schedule_update_processing_preserves_order_per_chat(self): service = self._build_service() first_started = asyncio.Event() allow_first_to_finish = asyncio.Event() second_started = asyncio.Event() started_updates: list[int] = [] async def fake_handle_update(*, session, update): update_id = update["update_id"] started_updates.append(update_id) if update_id == 1: first_started.set() await allow_first_to_finish.wait() return second_started.set() with patch.object(service, "_handle_update", new=fake_handle_update): await service._schedule_update_processing( session=SimpleNamespace(), update={"update_id": 1, "message": {"chat": {"id": 1}, "text": "primeiro"}}, ) await first_started.wait() await service._schedule_update_processing( session=SimpleNamespace(), update={"update_id": 2, "message": {"chat": {"id": 1}, "text": "segundo"}}, ) await asyncio.sleep(0) self.assertFalse(second_started.is_set()) allow_first_to_finish.set() await asyncio.wait_for(second_started.wait(), timeout=1) self.assertEqual(started_updates, [1, 2])