"""End-to-end tests for the Telegram fiscal-receipt flow (US-009). These tests stitch the public handlers from ``backend.modules.telegram.handlers.receipt_handlers`` together to exercise the three real user journeys, with Telegram, OCR queue, oracle_pool and write_receipt mocked at the module boundary: 1. PDF flow: send → "OCR processing" message → preview → click Confirmă → success 2. Photo flow: send → preview → click Anulează → cleanup message + temp file removed 3. Concurrent: two distinct user_ids submit files in parallel and both reach the preview state in well under 30s (validates ``concurrent_updates=True``). Run:: python3 -m pytest tests/e2e/test_receipt_telegram_flow.py -v """ import asyncio import sys import time import types from datetime import date, datetime from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest # --------------------------------------------------------------------------- # Stub out heavy/optional dependencies BEFORE importing receipt_handlers, # mirroring the strategy used by tests/modules/telegram/test_receipt_handlers.py # so this file runs in environments without telegram / sqlmodel installed. # --------------------------------------------------------------------------- _PROJECT_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(_PROJECT_ROOT)) class _FakeInlineKeyboardButton: """Stand-in for telegram.InlineKeyboardButton — receipt_handlers calls it with positional text and ``callback_data=`` kwarg, then hands it to a keyboard. We just need a non-MagicMock object so MagicMock's spec detection doesn't choke on the surrounding list.""" def __init__(self, text, callback_data=None, **_): self.text = text self.callback_data = callback_data class _FakeInlineKeyboardMarkup: """Stand-in for telegram.InlineKeyboardMarkup. Stores the keyboard so tests can assert against ``call.kwargs['reply_markup'].keyboard``.""" def __init__(self, keyboard): self.keyboard = keyboard def _install_telegram_stubs() -> None: """Install minimal telegram stubs ONLY when the module isn't already registered. We deliberately do NOT mutate an existing stub installed by another test file — instead, we override the bound names on ``receipt_handlers`` itself once it's imported (see below). This keeps the global ``sys.modules['telegram']`` state stable across files and avoids order-dependent test coupling.""" if "telegram" not in sys.modules: tg = types.ModuleType("telegram") tg.Update = MagicMock tg.InlineKeyboardButton = _FakeInlineKeyboardButton tg.InlineKeyboardMarkup = _FakeInlineKeyboardMarkup sys.modules["telegram"] = tg if "telegram.constants" not in sys.modules: constants = types.ModuleType("telegram.constants") class _ParseMode: MARKDOWN = "Markdown" constants.ParseMode = _ParseMode sys.modules["telegram.constants"] = constants if "telegram.error" not in sys.modules: err = types.ModuleType("telegram.error") class _TelegramError(Exception): pass err.TelegramError = _TelegramError sys.modules["telegram.error"] = err if "telegram.ext" not in sys.modules: ext = types.ModuleType("telegram.ext") class _ContextTypes: DEFAULT_TYPE = MagicMock ext.ContextTypes = _ContextTypes sys.modules["telegram.ext"] = ext def _install_data_entry_stubs() -> None: """Stub out backend.modules.data_entry so we don't pull SQLModel et al.""" if "backend.modules.data_entry" in sys.modules: return de = types.ModuleType("backend.modules.data_entry") de.__path__ = [] de_services = types.ModuleType("backend.modules.data_entry.services") de_services.__path__ = [] de_ocr = types.ModuleType("backend.modules.data_entry.services.ocr") de_ocr.__path__ = [] de_queue = types.ModuleType("backend.modules.data_entry.services.ocr.queue_client") de_queue.submit_ocr_job = AsyncMock(return_value="job-stub") de_queue.wait_for_result = AsyncMock( return_value={"success": True, "result": {}} ) de_writer = types.ModuleType( "backend.modules.data_entry.services.oracle_receipt_writer" ) de_writer.write_receipt = MagicMock(return_value=(0, "OK")) sys.modules["backend.modules.data_entry"] = de sys.modules["backend.modules.data_entry.services"] = de_services sys.modules["backend.modules.data_entry.services.ocr"] = de_ocr sys.modules["backend.modules.data_entry.services.ocr.queue_client"] = de_queue sys.modules[ "backend.modules.data_entry.services.oracle_receipt_writer" ] = de_writer _install_telegram_stubs() _install_data_entry_stubs() import oracledb # noqa: E402 from backend.modules.telegram.handlers import receipt_handlers as rh # noqa: E402 # `from telegram import InlineKeyboardButton, InlineKeyboardMarkup` in # receipt_handlers binds the names at import time, so even after we install # fake classes on the stub module, those references inside ``rh`` may still # point at the older MagicMock-based stub. Re-bind them explicitly so the # real handler's keyboard construction works against our fakes. rh.InlineKeyboardButton = _FakeInlineKeyboardButton # type: ignore[attr-defined] rh.InlineKeyboardMarkup = _FakeInlineKeyboardMarkup # type: ignore[attr-defined] # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) def _reset_module_state(): rh._pending_receipts.clear() rh._write_pool_registered.clear() yield rh._pending_receipts.clear() rh._write_pool_registered.clear() def _make_doc_update(user_id: int, file_id: str = "tg-file-1", file_name: str = "bon.pdf"): """Update for a Telegram document message (PDF/JPG).""" update = MagicMock() update.effective_user.id = user_id update.message.reply_text = AsyncMock() processing_msg = MagicMock() processing_msg.edit_text = AsyncMock() update.message.reply_text.return_value = processing_msg doc = MagicMock() doc.file_id = file_id doc.file_name = file_name update.message.document = doc return update, processing_msg def _make_photo_update(user_id: int, file_id: str = "tg-photo-large"): """Update for a Telegram photo message (uses largest = photo[-1]).""" update = MagicMock() update.effective_user.id = user_id update.message.reply_text = AsyncMock() processing_msg = MagicMock() processing_msg.edit_text = AsyncMock() update.message.reply_text.return_value = processing_msg photo_small = MagicMock() photo_small.file_id = "tg-photo-small" photo_large = MagicMock() photo_large.file_id = file_id update.message.photo = [photo_small, photo_large] return update, processing_msg def _make_callback_update(user_id: int, action: str): update = MagicMock() update.effective_user.id = user_id update.callback_query.data = f"receipt:{action}:{user_id}" update.callback_query.answer = AsyncMock() update.callback_query.edit_message_text = AsyncMock() return update def _make_context_with_download(): """Telegram context whose bot.get_file() simulates a file download. The returned tg_file's download_to_drive writes a tiny placeholder so the temp path on disk actually exists (lets us assert it gets unlinked later). """ context = MagicMock() tg_file = MagicMock() async def _download_to_drive(target_path): Path(target_path).write_bytes(b"%PDF-fake-receipt-bytes") tg_file.download_to_drive = AsyncMock(side_effect=_download_to_drive) context.bot.get_file = AsyncMock(return_value=tg_file) return context, tg_file def _sample_ocr_result(): return { "success": True, "result": { "partner_name": "LIDL DISCOUNT S.R.L.", "cui": "22891860", "receipt_date": date(2026, 5, 8), "receipt_number": "BON-2026-001", "amount": 156.78, "tva_total": 25.04, "confidence": 0.92, }, } # =========================================================================== # Scenario 1: PDF send → preview → confirm → Oracle save # =========================================================================== @pytest.mark.asyncio async def test_e2e_pdf_send_preview_confirm_success(tmp_path, monkeypatch): """Full happy path for a PDF receipt: download → OCR → preview → confirm → write.""" user_id = 4001 captured_temp_path: list[Path] = [] update, processing_msg = _make_doc_update(user_id, file_name="bon-lidl.pdf") context, _tg_file = _make_context_with_download() # Capture the temp path created by handle_document_message real_named_temp = rh.tempfile.NamedTemporaryFile def _named_tmp(*args, **kwargs): kwargs["dir"] = str(tmp_path) f = real_named_temp(*args, **kwargs) captured_temp_path.append(Path(f.name)) return f monkeypatch.setattr(rh.tempfile, "NamedTemporaryFile", _named_tmp) with patch( "backend.modules.telegram.handlers.receipt_handlers.is_user_linked", new=AsyncMock(return_value=True), ), patch( "backend.modules.telegram.handlers.receipt_handlers.submit_ocr_job", new=AsyncMock(return_value="job-pdf-1"), ), patch( "backend.modules.telegram.handlers.receipt_handlers.wait_for_result", new=AsyncMock(return_value=_sample_ocr_result()), ): await rh.handle_document_message(update, context) # --- Assertions on the preview phase ---------------------------------- # 1) "OCR processing" placeholder was sent. update.message.reply_text.assert_awaited_once() first_reply_text = update.message.reply_text.await_args.args[0] assert "Procesez bonul" in first_reply_text # 2) Preview replaced the placeholder with parsed data. processing_msg.edit_text.assert_awaited() preview_text = processing_msg.edit_text.await_args_list[-1].args[0] assert "LIDL DISCOUNT" in preview_text assert "22891860" in preview_text assert "08.05.2026" in preview_text assert "156.78" in preview_text assert "Confirmați" in preview_text # Inline keyboard with confirm/cancel was attached. edit_kwargs = processing_msg.edit_text.await_args_list[-1].kwargs assert "reply_markup" in edit_kwargs # Pending state stored against this user. assert user_id in rh._pending_receipts assert captured_temp_path, "tempfile was not created" assert captured_temp_path[0].exists() # Document was a .pdf so the temp file should preserve that suffix. assert captured_temp_path[0].suffix == ".pdf" assert captured_temp_path[0].name.startswith(rh.TEMP_FILE_PREFIX) # --- Confirm phase ---------------------------------------------------- cb_update = _make_callback_update(user_id, "confirm") fake_user_row = {"oracle_server_id": "srv1"} fake_cfg = {"user": "TESTUSER", "password": "PASS", "dsn": "host:1521/ROA"} fake_session = MagicMock() fake_session.get_active_company.return_value = {"id": 110, "name": "MARIUSM AUTO"} fake_sm = MagicMock() fake_sm.get_or_create_session = AsyncMock(return_value=fake_session) with patch( "backend.modules.telegram.handlers.receipt_handlers.get_user", new=AsyncMock(return_value=fake_user_row), ), patch.object( rh, "_build_oracle_config", return_value=fake_cfg ), patch.object( rh, "_resolve_schema", new=AsyncMock(return_value="MARIUSM_AUTO") ), patch( "backend.modules.telegram.agent.session.get_session_manager", return_value=fake_sm, ), patch.object( rh, "_save_to_oracle", new=AsyncMock(return_value=(7777, "Document salvat în ACT_TEMP")), ): await rh.handle_receipt_callback(cb_update, MagicMock()) edit_calls = cb_update.callback_query.edit_message_text.await_args_list assert len(edit_calls) >= 2 # Last message is the success confirmation. success_text = edit_calls[-1].args[0] assert "salvat" in success_text.lower() assert "7777" in success_text # Pending cleared and temp file removed. assert user_id not in rh._pending_receipts assert not captured_temp_path[0].exists() # =========================================================================== # Scenario 2: Photo send → preview → cancel → temp file deleted # =========================================================================== @pytest.mark.asyncio async def test_e2e_photo_send_preview_cancel_cleanup(tmp_path, monkeypatch): """Photo path: cancellation must clear pending state, post a 'Bon anulat' message and unlink the temp file.""" user_id = 4002 captured_temp_path: list[Path] = [] update, processing_msg = _make_photo_update(user_id, file_id="big-photo") context, _tg_file = _make_context_with_download() real_named_temp = rh.tempfile.NamedTemporaryFile def _named_tmp(*args, **kwargs): kwargs["dir"] = str(tmp_path) f = real_named_temp(*args, **kwargs) captured_temp_path.append(Path(f.name)) return f monkeypatch.setattr(rh.tempfile, "NamedTemporaryFile", _named_tmp) with patch( "backend.modules.telegram.handlers.receipt_handlers.is_user_linked", new=AsyncMock(return_value=True), ), patch( "backend.modules.telegram.handlers.receipt_handlers.submit_ocr_job", new=AsyncMock(return_value="job-photo-1"), ), patch( "backend.modules.telegram.handlers.receipt_handlers.wait_for_result", new=AsyncMock(return_value=_sample_ocr_result()), ): await rh.handle_photo_message(update, context) # Largest photo was used. context.bot.get_file.assert_awaited_once_with("big-photo") # Preview shown and pending stored. assert user_id in rh._pending_receipts assert captured_temp_path[0].exists() preview_text = processing_msg.edit_text.await_args_list[-1].args[0] assert "Preview bon fiscal" in preview_text # --- Cancel phase ----------------------------------------------------- cb_update = _make_callback_update(user_id, "cancel") await rh.handle_receipt_callback(cb_update, MagicMock()) # Cancel acknowledgement message. cancel_text = cb_update.callback_query.edit_message_text.await_args.args[0] assert "anulat" in cancel_text.lower() # Cleanup verified: pending entry gone, temp file removed. assert user_id not in rh._pending_receipts assert not captured_temp_path[0].exists() # =========================================================================== # Scenario 3: Two users submit concurrently → both reach preview < 30s # =========================================================================== @pytest.mark.asyncio async def test_e2e_concurrent_two_users_get_previews(tmp_path, monkeypatch): """Validates that ``concurrent_updates=True`` style usage is safe: two users submitting in parallel both end up with their own pending entry, isolated by user_id, and the whole thing finishes well under 30s. """ user_a = 5001 user_b = 5002 update_a, msg_a = _make_doc_update(user_a, file_id="tg-a", file_name="a.pdf") update_b, msg_b = _make_photo_update(user_b, file_id="tg-b-large") ctx_a, _ = _make_context_with_download() ctx_b, _ = _make_context_with_download() real_named_temp = rh.tempfile.NamedTemporaryFile def _named_tmp(*args, **kwargs): kwargs["dir"] = str(tmp_path) return real_named_temp(*args, **kwargs) monkeypatch.setattr(rh.tempfile, "NamedTemporaryFile", _named_tmp) # In-flight counter: increments on entry, decrements on exit. If the two # flows actually overlap on the event loop, max_in_flight reaches 2; under # serial execution it stays at 1 regardless of total elapsed time. This # is what *proves* concurrency — the wall-clock check below is just an # AC-driven sanity bound. state = {"in_flight": 0, "max_in_flight": 0, "submit_count": 0} async def _slow_submit(_path): state["in_flight"] += 1 state["max_in_flight"] = max(state["max_in_flight"], state["in_flight"]) state["submit_count"] += 1 await asyncio.sleep(0.05) state["in_flight"] -= 1 return f"job-{state['submit_count']}" async def _slow_wait(_job_id, timeout=120): await asyncio.sleep(0.05) return _sample_ocr_result() with patch( "backend.modules.telegram.handlers.receipt_handlers.is_user_linked", new=AsyncMock(return_value=True), ), patch( "backend.modules.telegram.handlers.receipt_handlers.submit_ocr_job", side_effect=_slow_submit, ), patch( "backend.modules.telegram.handlers.receipt_handlers.wait_for_result", side_effect=_slow_wait, ): start = time.monotonic() await asyncio.gather( rh.handle_document_message(update_a, ctx_a), rh.handle_photo_message(update_b, ctx_b), ) elapsed = time.monotonic() - start # Both users got their own preview, no cross-talk. assert user_a in rh._pending_receipts assert user_b in rh._pending_receipts assert rh._pending_receipts[user_a]["temp_path"] != rh._pending_receipts[user_b]["temp_path"] msg_a.edit_text.assert_awaited() msg_b.edit_text.assert_awaited() assert "Preview bon fiscal" in msg_a.edit_text.await_args.args[0] assert "Preview bon fiscal" in msg_b.edit_text.await_args.args[0] # AC requires "preview în <30s"; with concurrent execution and 50ms stubs # this should finish in well under a second on any sane runner. assert elapsed < 30.0, f"concurrent flow took {elapsed:.2f}s (>30s budget)" # Direct proof of concurrency: at some moment both submit_ocr_job calls # were simultaneously inside the await, which can only happen when the # two handlers progress in parallel on the event loop. assert state["max_in_flight"] == 2, ( f"flows did not overlap; max in-flight={state['max_in_flight']}" )