Files
roa2web-service-auto/tests/e2e/test_receipt_telegram_flow.py
Marius Mutu e257fa5d5f feat(telegram): bot bonuri fiscale — OCR → preview → Oracle write
- US-001: mută queue_client.py în data_entry/services/ocr/
- US-002/003/004: oracle_receipt_writer + oracle_server_id în DB
- US-005: receipt_handlers.py (PDF/photo/callback flow)
- US-006: wire handlers în main.py, per-schema connect, seq_cod.nextval
- US-007: .gitignore secrets/*.oracle_pass
- US-008/009/010: teste unit + integration + E2E
- setup-secrets.sh helper + template
- docs/telegram/README.md actualizat cu arhitectura nouă

Testat E2E pe DB live (MARIUSM_AUTO). COD din seq_cod.nextval.
pypdfium2 fallback pentru PDF decode (fără poppler).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 09:26:58 +00:00

482 lines
18 KiB
Python

"""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']}"
)