- 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>
482 lines
18 KiB
Python
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']}"
|
|
)
|