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>
This commit is contained in:
229
tests/backend/test_telegram_oracle_server_id.py
Normal file
229
tests/backend/test_telegram_oracle_server_id.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""Unit tests for US-004: oracle_server_id in telegram_users.
|
||||
|
||||
Tests cover:
|
||||
- link_user_to_oracle() persists server_id
|
||||
- link_user_to_oracle() works without server_id (backwards compat)
|
||||
- get_user() returns oracle_server_id from stored row
|
||||
- get_user_auth_data() includes server_id in returned dict
|
||||
- Round-trip: link with server_id → auth data returns same server_id
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
import aiosqlite
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../'))
|
||||
|
||||
# python-telegram-bot is not installed in the test environment; mock it so that
|
||||
# linking.py can be imported (it has `from telegram import User` at top-level).
|
||||
from unittest.mock import MagicMock
|
||||
sys.modules.setdefault('telegram', MagicMock())
|
||||
import backend.modules.telegram.auth.linking # noqa: F401 — registers module for patch() resolution
|
||||
|
||||
from backend.modules.telegram.db.operations import (
|
||||
create_or_update_user,
|
||||
link_user_to_oracle,
|
||||
get_user,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _make_test_db(tmp_path: Path) -> Path:
|
||||
"""Create a minimal telegram_users table in a temp SQLite file."""
|
||||
db_file = tmp_path / "test_app.db"
|
||||
async with aiosqlite.connect(db_file) as db:
|
||||
await db.execute("""CREATE TABLE telegram_users (
|
||||
telegram_user_id INTEGER PRIMARY KEY,
|
||||
username TEXT, first_name TEXT NOT NULL, last_name TEXT,
|
||||
oracle_username TEXT, oracle_server_id TEXT,
|
||||
jwt_token TEXT, jwt_refresh_token TEXT,
|
||||
token_expires_at TIMESTAMP, linked_at TIMESTAMP,
|
||||
last_active_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT 1
|
||||
)""")
|
||||
await db.commit()
|
||||
return db_file
|
||||
|
||||
|
||||
async def _insert_user(db_path: Path, telegram_user_id: int):
|
||||
async with aiosqlite.connect(db_path) as db:
|
||||
await db.execute(
|
||||
"INSERT INTO telegram_users (telegram_user_id, first_name) VALUES (?, ?)",
|
||||
(telegram_user_id, "Test")
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: link_user_to_oracle persists server_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_link_user_persists_server_id(tmp_path):
|
||||
db_file = await _make_test_db(tmp_path)
|
||||
await _insert_user(db_file, 12345)
|
||||
|
||||
with patch("backend.modules.telegram.db.operations.DB_PATH", db_file):
|
||||
result = await link_user_to_oracle(
|
||||
telegram_user_id=12345,
|
||||
oracle_username="TESTUSER",
|
||||
jwt_token="tok",
|
||||
jwt_refresh_token="rtok",
|
||||
token_expires_at=datetime.now() + timedelta(minutes=30),
|
||||
server_id="SERVER1"
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
async with aiosqlite.connect(db_file) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"SELECT oracle_server_id FROM telegram_users WHERE telegram_user_id = ?",
|
||||
(12345,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
assert row["oracle_server_id"] == "SERVER1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_link_user_without_server_id(tmp_path):
|
||||
"""Backwards compat: omitting server_id stores NULL."""
|
||||
db_file = await _make_test_db(tmp_path)
|
||||
await _insert_user(db_file, 22222)
|
||||
|
||||
with patch("backend.modules.telegram.db.operations.DB_PATH", db_file):
|
||||
result = await link_user_to_oracle(
|
||||
telegram_user_id=22222,
|
||||
oracle_username="TESTUSER2",
|
||||
jwt_token="tok",
|
||||
jwt_refresh_token="rtok",
|
||||
token_expires_at=datetime.now() + timedelta(minutes=30),
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
async with aiosqlite.connect(db_file) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"SELECT oracle_server_id FROM telegram_users WHERE telegram_user_id = ?",
|
||||
(22222,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
assert row["oracle_server_id"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: get_user returns oracle_server_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_returns_oracle_server_id(tmp_path):
|
||||
db_file = await _make_test_db(tmp_path)
|
||||
await _insert_user(db_file, 33333)
|
||||
|
||||
with patch("backend.modules.telegram.db.operations.DB_PATH", db_file):
|
||||
await link_user_to_oracle(
|
||||
telegram_user_id=33333,
|
||||
oracle_username="TESTUSER3",
|
||||
jwt_token="tok",
|
||||
jwt_refresh_token="rtok",
|
||||
token_expires_at=datetime.now() + timedelta(minutes=30),
|
||||
server_id="PROD_SERVER"
|
||||
)
|
||||
user = await get_user(33333)
|
||||
|
||||
assert user is not None
|
||||
assert user["oracle_server_id"] == "PROD_SERVER"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: get_user_auth_data returns server_id (round-trip)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_auth_data_includes_server_id(tmp_path):
|
||||
"""Round-trip: link with server_id → get_user_auth_data returns same server_id."""
|
||||
db_file = await _make_test_db(tmp_path)
|
||||
await _insert_user(db_file, 44444)
|
||||
|
||||
expires_at = datetime.now() + timedelta(hours=1)
|
||||
|
||||
with patch("backend.modules.telegram.db.operations.DB_PATH", db_file):
|
||||
await link_user_to_oracle(
|
||||
telegram_user_id=44444,
|
||||
oracle_username="TESTUSER4",
|
||||
jwt_token="valid_token",
|
||||
jwt_refresh_token="rtoken",
|
||||
token_expires_at=expires_at,
|
||||
server_id="ALPHA_SERVER"
|
||||
)
|
||||
|
||||
mock_companies = [{"id": 1, "name": "Test Co"}]
|
||||
mock_backend = AsyncMock()
|
||||
mock_backend.__aenter__ = AsyncMock(return_value=mock_backend)
|
||||
mock_backend.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_backend.get_user_companies = AsyncMock(return_value=mock_companies)
|
||||
|
||||
with (
|
||||
patch("backend.modules.telegram.db.operations.DB_PATH", db_file),
|
||||
patch("backend.modules.telegram.auth.linking.get_user") as mock_get_user,
|
||||
patch("backend.modules.telegram.auth.linking.get_backend_client", return_value=mock_backend),
|
||||
):
|
||||
# Return stored user row (simulating what get_user() would return)
|
||||
mock_get_user.return_value = {
|
||||
"telegram_user_id": 44444,
|
||||
"oracle_username": "TESTUSER4",
|
||||
"oracle_server_id": "ALPHA_SERVER",
|
||||
"jwt_token": "valid_token",
|
||||
"jwt_refresh_token": "rtoken",
|
||||
"token_expires_at": expires_at.isoformat(),
|
||||
}
|
||||
|
||||
from backend.modules.telegram.auth.linking import get_user_auth_data
|
||||
auth_data = await get_user_auth_data(44444)
|
||||
|
||||
assert auth_data is not None
|
||||
assert "server_id" in auth_data
|
||||
assert auth_data["server_id"] == "ALPHA_SERVER"
|
||||
assert auth_data["username"] == "TESTUSER4"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_auth_data_server_id_none_when_not_set(tmp_path):
|
||||
"""server_id is None in auth data when user was linked without a server_id."""
|
||||
mock_companies = []
|
||||
mock_backend = AsyncMock()
|
||||
mock_backend.__aenter__ = AsyncMock(return_value=mock_backend)
|
||||
mock_backend.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_backend.get_user_companies = AsyncMock(return_value=mock_companies)
|
||||
|
||||
expires_at = datetime.now() + timedelta(hours=1)
|
||||
|
||||
with (
|
||||
patch("backend.modules.telegram.auth.linking.get_user") as mock_get_user,
|
||||
patch("backend.modules.telegram.auth.linking.get_backend_client", return_value=mock_backend),
|
||||
):
|
||||
mock_get_user.return_value = {
|
||||
"telegram_user_id": 55555,
|
||||
"oracle_username": "TESTUSER5",
|
||||
"oracle_server_id": None,
|
||||
"jwt_token": "valid_token",
|
||||
"jwt_refresh_token": "rtoken",
|
||||
"token_expires_at": expires_at.isoformat(),
|
||||
}
|
||||
|
||||
from backend.modules.telegram.auth.linking import get_user_auth_data
|
||||
auth_data = await get_user_auth_data(55555)
|
||||
|
||||
assert auth_data is not None
|
||||
assert "server_id" in auth_data
|
||||
assert auth_data["server_id"] is None
|
||||
0
tests/e2e/__init__.py
Normal file
0
tests/e2e/__init__.py
Normal file
481
tests/e2e/test_receipt_telegram_flow.py
Normal file
481
tests/e2e/test_receipt_telegram_flow.py
Normal file
@@ -0,0 +1,481 @@
|
||||
"""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']}"
|
||||
)
|
||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
350
tests/integration/test_oracle_receipt_writer.py
Normal file
350
tests/integration/test_oracle_receipt_writer.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""Integration tests for oracle_receipt_writer.write_receipt.
|
||||
|
||||
Tests cover:
|
||||
- Mocked Oracle connection (always runs): validates SQL/callproc invocation,
|
||||
ACT_TEMP INSERT lines, commit/rollback flow, exception handling.
|
||||
- Real Oracle dev DB (optional): gated by env var ORACLE_INTEGRATION_DSN.
|
||||
Uses commit=False (dry-run) for cleanup so dev DB stays untouched.
|
||||
|
||||
Run mocked (default):
|
||||
pytest tests/integration/test_oracle_receipt_writer.py -v
|
||||
|
||||
Run against real dev DB:
|
||||
ORACLE_INTEGRATION_DSN=10.0.20.121:1521/ROA \\
|
||||
ORACLE_INTEGRATION_USER=MARIUSM_AUTO \\
|
||||
ORACLE_INTEGRATION_PASSWORD=ROMFASTSOFT \\
|
||||
pytest tests/integration/test_oracle_receipt_writer.py -v
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure project root on sys.path
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(_PROJECT_ROOT))
|
||||
|
||||
|
||||
def _load_writer_module():
|
||||
"""Load oracle_receipt_writer.py directly to avoid the heavy services/__init__.py
|
||||
import chain (which pulls sqlmodel/aiosqlite/etc. that aren't needed for this test).
|
||||
"""
|
||||
module_path = _PROJECT_ROOT / "backend/modules/data_entry/services/oracle_receipt_writer.py"
|
||||
spec = importlib.util.spec_from_file_location("oracle_receipt_writer", module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
# Register in sys.modules so `patch("oracle_receipt_writer.oracledb.connect", ...)` resolves
|
||||
sys.modules["oracle_receipt_writer"] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
_writer = _load_writer_module()
|
||||
write_receipt = _writer.write_receipt
|
||||
|
||||
|
||||
SAMPLE_RECEIPT = {
|
||||
"partner_name": "MOL Romania SRL",
|
||||
"cui": "RO14991381",
|
||||
"receipt_date": date(2026, 5, 8),
|
||||
"receipt_number": "12345",
|
||||
"amount": 250.00,
|
||||
"tva_total": 47.50,
|
||||
}
|
||||
|
||||
SAMPLE_CONFIG = {
|
||||
"user": "TEST_USER",
|
||||
"password": "TEST_PASS",
|
||||
"dsn": "localhost:1521/TESTDB",
|
||||
}
|
||||
|
||||
|
||||
def _build_mock_cursor(*, next_cod: int = 5, id_part: int = 123, oracle_msg: str = "OK"):
|
||||
"""Build a mock cursor pre-loaded with the queries write_receipt issues.
|
||||
|
||||
next_cod: value returned by `SELECT seq_cod.nextval FROM DUAL`
|
||||
(globally unique document COD).
|
||||
"""
|
||||
cursor = MagicMock()
|
||||
cursor.fetchone.side_effect = [(next_cod,), (id_part,)]
|
||||
var_obj = MagicMock()
|
||||
var_obj.getvalue.return_value = oracle_msg
|
||||
cursor.var.return_value = var_obj
|
||||
return cursor
|
||||
|
||||
|
||||
def _build_mock_connection(cursor):
|
||||
conn = MagicMock()
|
||||
conn.cursor.return_value = cursor
|
||||
return conn
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Mocked-DB integration tests
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
class TestWriteReceiptMocked:
|
||||
"""Validates write_receipt logic against a fully mocked Oracle connection."""
|
||||
|
||||
def test_returns_cod_and_message_tuple(self):
|
||||
cursor = _build_mock_cursor(next_cod=43, oracle_msg="Saved OK")
|
||||
mock_conn = _build_mock_connection(cursor)
|
||||
|
||||
with patch(
|
||||
"oracle_receipt_writer.oracledb.connect",
|
||||
return_value=mock_conn,
|
||||
):
|
||||
cod, mesaj = write_receipt(SAMPLE_RECEIPT, SAMPLE_CONFIG, commit=True)
|
||||
|
||||
assert cod == 43
|
||||
assert mesaj == "Saved OK"
|
||||
|
||||
def test_inserts_three_act_temp_lines_when_tva_present(self):
|
||||
cursor = _build_mock_cursor()
|
||||
mock_conn = _build_mock_connection(cursor)
|
||||
|
||||
with patch(
|
||||
"oracle_receipt_writer.oracledb.connect",
|
||||
return_value=mock_conn,
|
||||
):
|
||||
write_receipt(SAMPLE_RECEIPT, SAMPLE_CONFIG, commit=True)
|
||||
|
||||
# cursor.execute is called for: SELECT max(COD), SELECT ID_PART, then INSERTs
|
||||
execute_calls = cursor.execute.call_args_list
|
||||
insert_calls = [c for c in execute_calls if "INSERT INTO ACT_TEMP" in c.args[0]]
|
||||
assert len(insert_calls) == 3, "Expected 3 ACT_TEMP lines: expense, TVA, payment"
|
||||
|
||||
# Validate accounting accounts are correct (MOL → 6022, with TVA → 4426)
|
||||
scds = [c.kwargs["scd"] for c in insert_calls]
|
||||
sccs = [c.kwargs["scc"] for c in insert_calls]
|
||||
assert scds == ["6022", "4426", "401"]
|
||||
assert sccs == ["401", "401", "5311"]
|
||||
|
||||
def test_inserts_two_act_temp_lines_when_no_tva(self):
|
||||
receipt = {**SAMPLE_RECEIPT, "tva_total": 0}
|
||||
cursor = _build_mock_cursor()
|
||||
mock_conn = _build_mock_connection(cursor)
|
||||
|
||||
with patch(
|
||||
"oracle_receipt_writer.oracledb.connect",
|
||||
return_value=mock_conn,
|
||||
):
|
||||
write_receipt(receipt, SAMPLE_CONFIG, commit=True)
|
||||
|
||||
insert_calls = [
|
||||
c for c in cursor.execute.call_args_list
|
||||
if "INSERT INTO ACT_TEMP" in c.args[0]
|
||||
]
|
||||
assert len(insert_calls) == 2 # No TVA line
|
||||
|
||||
def test_invokes_pack_contafin_initializeaza_and_finalizeaza(self):
|
||||
cursor = _build_mock_cursor()
|
||||
mock_conn = _build_mock_connection(cursor)
|
||||
|
||||
with patch(
|
||||
"oracle_receipt_writer.oracledb.connect",
|
||||
return_value=mock_conn,
|
||||
):
|
||||
write_receipt(SAMPLE_RECEIPT, SAMPLE_CONFIG, commit=True)
|
||||
|
||||
callproc_names = [c.args[0] for c in cursor.callproc.call_args_list]
|
||||
assert "PACK_CONTAFIN.INITIALIZEAZA_SCRIERE_ACT_RUL" in callproc_names
|
||||
assert "PACK_CONTAFIN.FINALIZEAZA_SCRIERE_ACT_RUL" in callproc_names
|
||||
|
||||
def test_commit_true_calls_commit(self):
|
||||
cursor = _build_mock_cursor()
|
||||
mock_conn = _build_mock_connection(cursor)
|
||||
|
||||
with patch(
|
||||
"oracle_receipt_writer.oracledb.connect",
|
||||
return_value=mock_conn,
|
||||
):
|
||||
write_receipt(SAMPLE_RECEIPT, SAMPLE_CONFIG, commit=True)
|
||||
|
||||
mock_conn.commit.assert_called_once()
|
||||
mock_conn.rollback.assert_not_called()
|
||||
|
||||
def test_commit_false_triggers_rollback_for_cleanup(self):
|
||||
"""Dry-run mode rolls back even on success — the cleanup path."""
|
||||
cursor = _build_mock_cursor()
|
||||
mock_conn = _build_mock_connection(cursor)
|
||||
|
||||
with patch(
|
||||
"oracle_receipt_writer.oracledb.connect",
|
||||
return_value=mock_conn,
|
||||
):
|
||||
cod, mesaj = write_receipt(SAMPLE_RECEIPT, SAMPLE_CONFIG, commit=False)
|
||||
|
||||
mock_conn.rollback.assert_called_once()
|
||||
mock_conn.commit.assert_not_called()
|
||||
# Cod and mesaj are still returned — dry-run gives caller visibility
|
||||
assert cod > 0
|
||||
assert mesaj == "OK"
|
||||
|
||||
def test_exception_during_insert_triggers_rollback(self):
|
||||
cursor = MagicMock()
|
||||
cursor.fetchone.side_effect = [(1,), (123,)]
|
||||
cursor.var.return_value = MagicMock(getvalue=lambda: "")
|
||||
|
||||
# Cursor.execute raises on the INSERT (third execute call)
|
||||
execute_results = [None, None, RuntimeError("DB blew up")]
|
||||
cursor.execute.side_effect = execute_results
|
||||
mock_conn = _build_mock_connection(cursor)
|
||||
|
||||
with patch(
|
||||
"oracle_receipt_writer.oracledb.connect",
|
||||
return_value=mock_conn,
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="DB blew up"):
|
||||
write_receipt(SAMPLE_RECEIPT, SAMPLE_CONFIG, commit=True)
|
||||
|
||||
mock_conn.rollback.assert_called_once()
|
||||
mock_conn.commit.assert_not_called()
|
||||
|
||||
def test_owns_connection_when_dict_config_passed(self):
|
||||
cursor = _build_mock_cursor()
|
||||
mock_conn = _build_mock_connection(cursor)
|
||||
|
||||
with patch(
|
||||
"oracle_receipt_writer.oracledb.connect",
|
||||
return_value=mock_conn,
|
||||
) as mock_connect:
|
||||
write_receipt(SAMPLE_RECEIPT, SAMPLE_CONFIG, commit=True)
|
||||
|
||||
mock_connect.assert_called_once_with(
|
||||
user="TEST_USER", password="TEST_PASS", dsn="localhost:1521/TESTDB"
|
||||
)
|
||||
mock_conn.close.assert_called_once()
|
||||
|
||||
def test_does_not_close_pre_acquired_connection(self):
|
||||
"""When caller passes oracledb.Connection directly, lifecycle is theirs."""
|
||||
import oracledb
|
||||
|
||||
cursor = _build_mock_cursor()
|
||||
# Spec as oracledb.Connection so isinstance(...) check succeeds
|
||||
mock_conn = MagicMock(spec=oracledb.Connection)
|
||||
mock_conn.cursor.return_value = cursor
|
||||
|
||||
write_receipt(SAMPLE_RECEIPT, mock_conn, commit=True)
|
||||
|
||||
mock_conn.commit.assert_called_once()
|
||||
mock_conn.close.assert_not_called()
|
||||
|
||||
def test_partner_lookup_falls_back_to_zero_when_cui_unknown(self):
|
||||
cursor = MagicMock()
|
||||
# max_cod = 1, id_part lookup returns nothing
|
||||
cursor.fetchone.side_effect = [(1,), None]
|
||||
cursor.var.return_value = MagicMock(getvalue=lambda: "OK")
|
||||
mock_conn = _build_mock_connection(cursor)
|
||||
|
||||
with patch(
|
||||
"oracle_receipt_writer.oracledb.connect",
|
||||
return_value=mock_conn,
|
||||
):
|
||||
write_receipt(SAMPLE_RECEIPT, SAMPLE_CONFIG, commit=True)
|
||||
|
||||
# All ACT_TEMP inserts must succeed even with id_part=0
|
||||
insert_calls = [
|
||||
c for c in cursor.execute.call_args_list
|
||||
if "INSERT INTO ACT_TEMP" in c.args[0]
|
||||
]
|
||||
assert len(insert_calls) == 3
|
||||
# First line (expense): id_partc=0 (partner unknown), id_partd=0
|
||||
assert insert_calls[0].kwargs["id_partc"] == 0
|
||||
|
||||
def test_uses_receipt_date_for_year_and_month(self):
|
||||
cursor = _build_mock_cursor()
|
||||
mock_conn = _build_mock_connection(cursor)
|
||||
|
||||
with patch(
|
||||
"oracle_receipt_writer.oracledb.connect",
|
||||
return_value=mock_conn,
|
||||
):
|
||||
write_receipt(SAMPLE_RECEIPT, SAMPLE_CONFIG, commit=True)
|
||||
|
||||
# INITIALIZEAZA call args: [id_util, datetime.now(), an, luna, ...]
|
||||
init_call = next(
|
||||
c for c in cursor.callproc.call_args_list
|
||||
if c.args[0] == "PACK_CONTAFIN.INITIALIZEAZA_SCRIERE_ACT_RUL"
|
||||
)
|
||||
an, luna = init_call.args[1][2], init_call.args[1][3]
|
||||
assert an == 2026
|
||||
assert luna == 5
|
||||
|
||||
def test_falls_back_to_now_when_receipt_date_missing(self):
|
||||
receipt = {**SAMPLE_RECEIPT, "receipt_date": None}
|
||||
cursor = _build_mock_cursor()
|
||||
mock_conn = _build_mock_connection(cursor)
|
||||
|
||||
with patch(
|
||||
"oracle_receipt_writer.oracledb.connect",
|
||||
return_value=mock_conn,
|
||||
):
|
||||
write_receipt(receipt, SAMPLE_CONFIG, commit=True)
|
||||
|
||||
init_call = next(
|
||||
c for c in cursor.callproc.call_args_list
|
||||
if c.args[0] == "PACK_CONTAFIN.INITIALIZEAZA_SCRIERE_ACT_RUL"
|
||||
)
|
||||
an = init_call.args[1][2]
|
||||
# Year should be current year (not 1970 or similar)
|
||||
assert an == datetime.now().year
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Real Oracle dev DB integration tests (opt-in)
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
_ORACLE_DSN = os.getenv("ORACLE_INTEGRATION_DSN")
|
||||
_ORACLE_USER = os.getenv("ORACLE_INTEGRATION_USER")
|
||||
_ORACLE_PASSWORD = os.getenv("ORACLE_INTEGRATION_PASSWORD")
|
||||
|
||||
_real_db_unavailable = not all([_ORACLE_DSN, _ORACLE_USER, _ORACLE_PASSWORD])
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
_real_db_unavailable,
|
||||
reason="ORACLE_INTEGRATION_DSN/USER/PASSWORD not set — skipping real DB tests",
|
||||
)
|
||||
class TestWriteReceiptRealDb:
|
||||
"""Real Oracle dev DB tests. Use commit=False so dev DB stays untouched."""
|
||||
|
||||
@pytest.fixture
|
||||
def oracle_cfg(self):
|
||||
return {
|
||||
"user": _ORACLE_USER,
|
||||
"password": _ORACLE_PASSWORD,
|
||||
"dsn": _ORACLE_DSN,
|
||||
}
|
||||
|
||||
def test_dry_run_returns_valid_cod_and_message(self, oracle_cfg):
|
||||
"""Dry-run write should return (cod, message) and rollback the transaction."""
|
||||
cod, mesaj = write_receipt(SAMPLE_RECEIPT, oracle_cfg, commit=False)
|
||||
|
||||
assert isinstance(cod, int) and cod > 0, f"Expected positive int COD, got {cod!r}"
|
||||
assert isinstance(mesaj, str), f"Expected string message, got {type(mesaj)}"
|
||||
|
||||
def test_dry_run_act_temp_row_rolled_back(self, oracle_cfg):
|
||||
"""After commit=False, ACT_TEMP must NOT contain the row (rollback verified)."""
|
||||
import oracledb
|
||||
|
||||
cod, _ = write_receipt(SAMPLE_RECEIPT, oracle_cfg, commit=False)
|
||||
|
||||
# Verify the row was rolled back — query ACT_TEMP for the COD
|
||||
with oracledb.connect(**oracle_cfg) as verify_conn:
|
||||
with verify_conn.cursor() as verify_cursor:
|
||||
verify_cursor.execute(
|
||||
"SELECT COUNT(*) FROM ACT_TEMP WHERE COD = :cod AND AN = :an AND LUNA = :luna",
|
||||
cod=cod,
|
||||
an=SAMPLE_RECEIPT["receipt_date"].year,
|
||||
luna=SAMPLE_RECEIPT["receipt_date"].month,
|
||||
)
|
||||
count = verify_cursor.fetchone()[0]
|
||||
assert count == 0, f"Rollback failed: {count} rows still in ACT_TEMP for COD={cod}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
0
tests/modules/__init__.py
Normal file
0
tests/modules/__init__.py
Normal file
0
tests/modules/telegram/__init__.py
Normal file
0
tests/modules/telegram/__init__.py
Normal file
692
tests/modules/telegram/test_receipt_handlers.py
Normal file
692
tests/modules/telegram/test_receipt_handlers.py
Normal file
@@ -0,0 +1,692 @@
|
||||
"""Unit tests for backend.modules.telegram.handlers.receipt_handlers (US-008).
|
||||
|
||||
Tests are organized by function:
|
||||
- _format_receipt_preview (3 tests)
|
||||
- _confidence_warning (2 tests)
|
||||
- _build_oracle_config (3 tests)
|
||||
- _format_oracle_error (4 tests)
|
||||
- _save_to_oracle (6 tests)
|
||||
- handle_receipt_callback (7 tests)
|
||||
- handle_document_message (5 tests)
|
||||
- handle_photo_message (3 tests)
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
import types
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module mocks (BEFORE importing receipt_handlers).
|
||||
# python-telegram-bot, sqlmodel and the data_entry services package import
|
||||
# heavy dependencies that aren't available in the unit-test environment, so we
|
||||
# stub them out to keep these tests pure (no DB / no Telegram).
|
||||
# ---------------------------------------------------------------------------
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||
|
||||
# Telegram package stubs.
|
||||
_tg = types.ModuleType("telegram")
|
||||
_tg.Update = MagicMock
|
||||
_tg.InlineKeyboardButton = MagicMock
|
||||
_tg.InlineKeyboardMarkup = MagicMock
|
||||
sys.modules.setdefault("telegram", _tg)
|
||||
|
||||
_tg_constants = types.ModuleType("telegram.constants")
|
||||
|
||||
|
||||
class _FakeParseMode:
|
||||
MARKDOWN = "Markdown"
|
||||
|
||||
|
||||
_tg_constants.ParseMode = _FakeParseMode
|
||||
sys.modules.setdefault("telegram.constants", _tg_constants)
|
||||
|
||||
_tg_error = types.ModuleType("telegram.error")
|
||||
|
||||
|
||||
class _FakeTelegramError(Exception):
|
||||
"""Fake replacement for telegram.error.TelegramError."""
|
||||
|
||||
|
||||
_tg_error.TelegramError = _FakeTelegramError
|
||||
sys.modules.setdefault("telegram.error", _tg_error)
|
||||
|
||||
_tg_ext = types.ModuleType("telegram.ext")
|
||||
|
||||
|
||||
class _FakeContextTypes:
|
||||
DEFAULT_TYPE = MagicMock
|
||||
|
||||
|
||||
_tg_ext.ContextTypes = _FakeContextTypes
|
||||
sys.modules.setdefault("telegram.ext", _tg_ext)
|
||||
|
||||
# Stub out backend.modules.data_entry.services package — its real __init__
|
||||
# imports SQLModel-based models that are heavy and not needed for these tests.
|
||||
_de = types.ModuleType("backend.modules.data_entry")
|
||||
_de.__path__ = []
|
||||
_de_services = types.ModuleType("backend.modules.data_entry.services")
|
||||
_de_services.__path__ = []
|
||||
_de_services_ocr = types.ModuleType("backend.modules.data_entry.services.ocr")
|
||||
_de_services_ocr.__path__ = []
|
||||
_de_queue = types.ModuleType("backend.modules.data_entry.services.ocr.queue_client")
|
||||
_de_queue.submit_ocr_job = AsyncMock(return_value="job-test")
|
||||
_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=(123, "OK"))
|
||||
|
||||
sys.modules.setdefault("backend.modules.data_entry", _de)
|
||||
sys.modules.setdefault("backend.modules.data_entry.services", _de_services)
|
||||
sys.modules.setdefault("backend.modules.data_entry.services.ocr", _de_services_ocr)
|
||||
sys.modules.setdefault(
|
||||
"backend.modules.data_entry.services.ocr.queue_client", _de_queue
|
||||
)
|
||||
sys.modules.setdefault(
|
||||
"backend.modules.data_entry.services.oracle_receipt_writer", _de_writer
|
||||
)
|
||||
|
||||
# Now safe to import the module under test.
|
||||
import oracledb # noqa: E402
|
||||
|
||||
from backend.modules.telegram.handlers import receipt_handlers as rh # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_module_state():
|
||||
"""Clear caches between tests so they don't leak."""
|
||||
rh._pending_receipts.clear()
|
||||
rh._write_pool_registered.clear()
|
||||
yield
|
||||
rh._pending_receipts.clear()
|
||||
rh._write_pool_registered.clear()
|
||||
|
||||
|
||||
def _make_server(server_id="srv1", password="readpass"):
|
||||
srv = MagicMock()
|
||||
srv.id = server_id
|
||||
srv.name = "Test Server"
|
||||
srv.host = "localhost"
|
||||
srv.port = 1521
|
||||
srv.user = "TESTUSER"
|
||||
srv.password = password
|
||||
srv.sid = "ROA"
|
||||
srv.service_name = None
|
||||
srv.get_dsn = MagicMock(return_value="localhost:1521/ROA")
|
||||
return srv
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _format_receipt_preview (3 tests)
|
||||
# ===========================================================================
|
||||
|
||||
def test_format_receipt_preview_complete():
|
||||
receipt = {
|
||||
"partner_name": "LIDL DISCOUNT",
|
||||
"cui": "22891860",
|
||||
"receipt_date": date(2026, 1, 15),
|
||||
"receipt_number": "BON-001",
|
||||
"amount": 123.45,
|
||||
"tva_total": 19.71,
|
||||
}
|
||||
out = rh._format_receipt_preview(receipt)
|
||||
|
||||
assert "LIDL DISCOUNT" in out
|
||||
assert "22891860" in out
|
||||
assert "15.01.2026" in out
|
||||
assert "BON-001" in out
|
||||
assert "123.45" in out
|
||||
assert "19.71" in out
|
||||
assert out.startswith("📄 *Preview bon fiscal*")
|
||||
|
||||
|
||||
def test_format_receipt_preview_with_missing_fields():
|
||||
out = rh._format_receipt_preview({})
|
||||
|
||||
assert "Necunoscut" in out # partner_name fallback
|
||||
assert "—" in out # other fields fallback
|
||||
assert "0.00 RON" in out # amount fallback
|
||||
|
||||
|
||||
def test_format_receipt_preview_with_string_date():
|
||||
"""Date as ISO string (not yet normalized) must not crash and is stringified."""
|
||||
receipt = {
|
||||
"partner_name": "Test",
|
||||
"cui": "1",
|
||||
"receipt_date": "2026-03-04",
|
||||
"receipt_number": "X",
|
||||
"amount": 50,
|
||||
"tva_total": 5,
|
||||
}
|
||||
out = rh._format_receipt_preview(receipt)
|
||||
assert "2026-03-04" in out
|
||||
assert "50.00" in out
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _confidence_warning (2 tests)
|
||||
# ===========================================================================
|
||||
|
||||
def test_confidence_warning_below_threshold():
|
||||
out = rh._confidence_warning(0.45)
|
||||
assert "Atenție" in out
|
||||
assert "45%" in out
|
||||
assert out.startswith("\n⚠")
|
||||
|
||||
|
||||
def test_confidence_warning_above_threshold_or_none():
|
||||
assert rh._confidence_warning(0.99) == ""
|
||||
assert rh._confidence_warning(rh.LOW_CONFIDENCE_THRESHOLD) == ""
|
||||
assert rh._confidence_warning(None) == ""
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _build_oracle_config (3 tests)
|
||||
# ===========================================================================
|
||||
|
||||
def test_build_oracle_config_returns_read_credentials():
|
||||
"""Single Oracle user: config returns the same user/password used for read."""
|
||||
srv = _make_server(server_id="srv1", password="readpass")
|
||||
fake_settings = MagicMock()
|
||||
fake_settings.get_oracle_server.return_value = srv
|
||||
|
||||
with patch.object(rh, "settings", fake_settings):
|
||||
cfg = rh._build_oracle_config("srv1")
|
||||
|
||||
assert cfg == {
|
||||
"user": "TESTUSER",
|
||||
"password": "readpass",
|
||||
"dsn": "localhost:1521/ROA",
|
||||
}
|
||||
|
||||
|
||||
def test_build_oracle_config_no_server_id_uses_default():
|
||||
srv = _make_server(server_id="default")
|
||||
fake_settings = MagicMock()
|
||||
fake_settings.get_default_oracle_server.return_value = srv
|
||||
|
||||
with patch.object(rh, "settings", fake_settings):
|
||||
cfg = rh._build_oracle_config(None)
|
||||
|
||||
assert cfg is not None
|
||||
assert cfg["user"] == "TESTUSER"
|
||||
fake_settings.get_default_oracle_server.assert_called_once()
|
||||
|
||||
|
||||
def test_build_oracle_config_no_server_returns_none():
|
||||
fake_settings = MagicMock()
|
||||
fake_settings.get_default_oracle_server.return_value = None
|
||||
fake_settings.get_oracle_server.return_value = None
|
||||
|
||||
with patch.object(rh, "settings", fake_settings):
|
||||
assert rh._build_oracle_config(None) is None
|
||||
assert rh._build_oracle_config("does-not-exist") is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _format_oracle_error (4 tests)
|
||||
# ===========================================================================
|
||||
|
||||
def test_format_oracle_error_ora_01017():
|
||||
err = oracledb.DatabaseError("ORA-01017: invalid username/password")
|
||||
msg = rh._format_oracle_error(err)
|
||||
assert "Credențiale Oracle incorecte" in msg
|
||||
assert msg.startswith("❌")
|
||||
|
||||
|
||||
def test_format_oracle_error_ora_00001():
|
||||
err = oracledb.DatabaseError("ORA-00001: unique constraint violated")
|
||||
msg = rh._format_oracle_error(err)
|
||||
assert "duplicat" in msg.lower()
|
||||
|
||||
|
||||
def test_format_oracle_error_ora_12541():
|
||||
err = oracledb.DatabaseError("ORA-12541: TNS no listener")
|
||||
msg = rh._format_oracle_error(err)
|
||||
assert "nu este disponibil" in msg
|
||||
|
||||
|
||||
def test_format_oracle_error_unknown_code():
|
||||
err = oracledb.DatabaseError("ORA-99999: something exotic")
|
||||
msg = rh._format_oracle_error(err)
|
||||
assert "Eroare Oracle" in msg
|
||||
assert "ORA-99999" in msg
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _save_to_oracle (6 tests)
|
||||
# ===========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_to_oracle_registers_pool_first_time():
|
||||
receipt = {"partner_name": "Test"}
|
||||
cfg = {"user": "READUSER", "password": "P", "dsn": "localhost:1521/ROA"}
|
||||
srv = _make_server()
|
||||
fake_settings = MagicMock()
|
||||
fake_settings.get_oracle_server.return_value = srv
|
||||
|
||||
fake_pool = MagicMock()
|
||||
fake_conn = MagicMock()
|
||||
fake_pool.acquire.return_value = fake_conn
|
||||
|
||||
with patch.object(rh, "settings", fake_settings), \
|
||||
patch.object(rh.oracle_pool, "register_server") as mock_register, \
|
||||
patch.object(rh.oracle_pool, "is_server_registered", return_value=True), \
|
||||
patch.object(rh.oracle_pool, "get_pool", new=AsyncMock(return_value=fake_pool)), \
|
||||
patch.object(rh, "write_receipt", return_value=(7, "OK")) as mock_write:
|
||||
cod, mesaj = await rh._save_to_oracle(receipt, cfg, server_id="srv1", schema="MARIUSM_AUTO")
|
||||
|
||||
mock_register.assert_called_once()
|
||||
kwargs = mock_register.call_args.kwargs
|
||||
# Connect AS schema owner with the read user's password
|
||||
assert kwargs["user"] == "MARIUSM_AUTO"
|
||||
assert kwargs["password"] == "P"
|
||||
assert "srv1_write_MARIUSM_AUTO" in rh._write_pool_registered
|
||||
mock_write.assert_called_once_with(receipt, fake_conn)
|
||||
assert (cod, mesaj) == (7, "OK")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_to_oracle_uses_existing_pool_no_reregister():
|
||||
receipt = {"x": 1}
|
||||
cfg = {"user": "READUSER", "password": "P", "dsn": "d"}
|
||||
rh._write_pool_registered.add("srv1_write_MARIUSM_AUTO") # simulate previous registration
|
||||
|
||||
fake_pool = MagicMock()
|
||||
fake_pool.acquire.return_value = MagicMock()
|
||||
|
||||
with patch.object(rh.oracle_pool, "register_server") as mock_register, \
|
||||
patch.object(rh.oracle_pool, "is_server_registered", return_value=True), \
|
||||
patch.object(rh.oracle_pool, "get_pool", new=AsyncMock(return_value=fake_pool)), \
|
||||
patch.object(rh, "write_receipt", return_value=(1, "OK")):
|
||||
await rh._save_to_oracle(receipt, cfg, server_id="srv1", schema="MARIUSM_AUTO")
|
||||
|
||||
mock_register.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_to_oracle_returns_cod_and_message():
|
||||
fake_pool = MagicMock()
|
||||
fake_pool.acquire.return_value = MagicMock()
|
||||
fake_settings = MagicMock()
|
||||
fake_settings.get_default_oracle_server.return_value = _make_server()
|
||||
|
||||
with patch.object(rh, "settings", fake_settings), \
|
||||
patch.object(rh.oracle_pool, "register_server"), \
|
||||
patch.object(rh.oracle_pool, "is_server_registered", return_value=True), \
|
||||
patch.object(rh.oracle_pool, "get_pool", new=AsyncMock(return_value=fake_pool)), \
|
||||
patch.object(rh, "write_receipt", return_value=(42, "Bon salvat")):
|
||||
cod, mesaj = await rh._save_to_oracle({}, {"user": "u", "password": "p", "dsn": "d"}, None)
|
||||
|
||||
assert cod == 42
|
||||
assert mesaj == "Bon salvat"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_to_oracle_falls_back_to_direct_when_no_server_config():
|
||||
"""When server lookup returns None and pool isn't registered, write_receipt
|
||||
is called directly with the dict cfg (no pool indirection)."""
|
||||
cfg = {"user": "u", "password": "p", "dsn": "d"}
|
||||
fake_settings = MagicMock()
|
||||
fake_settings.get_default_oracle_server.return_value = None
|
||||
|
||||
with patch.object(rh, "settings", fake_settings), \
|
||||
patch.object(rh.oracle_pool, "is_server_registered", return_value=False), \
|
||||
patch.object(rh, "write_receipt", return_value=(99, "OK")) as mock_write:
|
||||
cod, mesaj = await rh._save_to_oracle({"a": 1}, cfg, None)
|
||||
|
||||
mock_write.assert_called_once_with({"a": 1}, cfg)
|
||||
assert (cod, mesaj) == (99, "OK")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_to_oracle_releases_connection_after_write():
|
||||
"""Connection acquired from pool must be closed in finally clause."""
|
||||
fake_conn = MagicMock()
|
||||
fake_pool = MagicMock()
|
||||
fake_pool.acquire.return_value = fake_conn
|
||||
rh._write_pool_registered.add("srv1_write_MARIUSM_AUTO")
|
||||
|
||||
with patch.object(rh.oracle_pool, "is_server_registered", return_value=True), \
|
||||
patch.object(rh.oracle_pool, "get_pool", new=AsyncMock(return_value=fake_pool)), \
|
||||
patch.object(rh, "write_receipt", return_value=(1, "OK")):
|
||||
await rh._save_to_oracle({}, {"user": "u", "password": "p", "dsn": "d"}, "srv1", "MARIUSM_AUTO")
|
||||
|
||||
fake_conn.close.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_to_oracle_propagates_oracle_error():
|
||||
"""Errors from write_receipt bubble up so handler can translate them."""
|
||||
fake_pool = MagicMock()
|
||||
fake_pool.acquire.return_value = MagicMock()
|
||||
rh._write_pool_registered.add("srv1_write")
|
||||
|
||||
db_err = oracledb.DatabaseError("ORA-00001: unique violation")
|
||||
|
||||
with patch.object(rh.oracle_pool, "is_server_registered", return_value=True), \
|
||||
patch.object(rh.oracle_pool, "get_pool", new=AsyncMock(return_value=fake_pool)), \
|
||||
patch.object(rh, "write_receipt", side_effect=db_err):
|
||||
with pytest.raises(oracledb.DatabaseError):
|
||||
await rh._save_to_oracle({}, {"user": "u", "password": "p", "dsn": "d"}, "srv1")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# handle_receipt_callback (7 tests)
|
||||
# ===========================================================================
|
||||
|
||||
def _make_callback_update(data, effective_user_id=42):
|
||||
update = MagicMock()
|
||||
update.effective_user.id = effective_user_id
|
||||
update.callback_query.data = data
|
||||
update.callback_query.answer = AsyncMock()
|
||||
update.callback_query.edit_message_text = AsyncMock()
|
||||
return update
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_receipt_callback_invalid_data_format():
|
||||
update = _make_callback_update("garbage")
|
||||
await rh.handle_receipt_callback(update, MagicMock())
|
||||
|
||||
update.callback_query.edit_message_text.assert_awaited_once()
|
||||
args, _ = update.callback_query.edit_message_text.call_args
|
||||
assert "invalidă" in args[0].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_receipt_callback_invalid_uid_int():
|
||||
update = _make_callback_update("receipt:confirm:notanumber")
|
||||
await rh.handle_receipt_callback(update, MagicMock())
|
||||
|
||||
update.callback_query.edit_message_text.assert_awaited_once()
|
||||
args, _ = update.callback_query.edit_message_text.call_args
|
||||
assert "invalidă" in args[0].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_receipt_callback_unauthorized_other_user():
|
||||
"""Caller is user 99, but the callback targets user 42 → forbidden."""
|
||||
update = _make_callback_update("receipt:confirm:42", effective_user_id=99)
|
||||
await rh.handle_receipt_callback(update, MagicMock())
|
||||
|
||||
# second answer() call carries the alert message
|
||||
answer_calls = update.callback_query.answer.await_args_list
|
||||
assert len(answer_calls) == 2
|
||||
assert "permisiunea" in answer_calls[1].args[0].lower()
|
||||
update.callback_query.edit_message_text.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_receipt_callback_pending_not_found():
|
||||
update = _make_callback_update("receipt:confirm:42", effective_user_id=42)
|
||||
# Empty _pending_receipts (autouse fixture clears it).
|
||||
await rh.handle_receipt_callback(update, MagicMock())
|
||||
|
||||
update.callback_query.edit_message_text.assert_awaited_once()
|
||||
args, _ = update.callback_query.edit_message_text.call_args
|
||||
assert "expirat" in args[0].lower() or "procesat" in args[0].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_receipt_callback_pending_expired():
|
||||
update = _make_callback_update("receipt:confirm:42", effective_user_id=42)
|
||||
rh._pending_receipts[42] = {
|
||||
"receipt_dict": {},
|
||||
"temp_path": "/tmp/receipt_doesnotexist.pdf",
|
||||
"created_at": datetime.now() - timedelta(seconds=rh.PENDING_TTL_S + 60),
|
||||
}
|
||||
|
||||
await rh.handle_receipt_callback(update, MagicMock())
|
||||
|
||||
assert 42 not in rh._pending_receipts
|
||||
args, _ = update.callback_query.edit_message_text.call_args
|
||||
assert "expirat" in args[0].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_receipt_callback_cancel_clears_state():
|
||||
update = _make_callback_update("receipt:cancel:42", effective_user_id=42)
|
||||
rh._pending_receipts[42] = {
|
||||
"receipt_dict": {"partner_name": "X"},
|
||||
"temp_path": "/tmp/receipt_xyz.pdf",
|
||||
"created_at": datetime.now(),
|
||||
}
|
||||
|
||||
with patch("pathlib.Path.unlink") as mock_unlink:
|
||||
await rh.handle_receipt_callback(update, MagicMock())
|
||||
|
||||
assert 42 not in rh._pending_receipts
|
||||
mock_unlink.assert_called_once_with(missing_ok=True)
|
||||
args, _ = update.callback_query.edit_message_text.call_args
|
||||
assert "anulat" in args[0].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_receipt_callback_confirm_writes_to_oracle():
|
||||
update = _make_callback_update("receipt:confirm:42", effective_user_id=42)
|
||||
rh._pending_receipts[42] = {
|
||||
"receipt_dict": {"partner_name": "X", "cui": "1", "amount": 50, "tva_total": 5},
|
||||
"temp_path": "/tmp/receipt_xyz.pdf",
|
||||
"created_at": datetime.now(),
|
||||
}
|
||||
|
||||
fake_user = {"oracle_server_id": "srv1"}
|
||||
fake_cfg = {"user": "TESTUSER", "password": "P", "dsn": "d"}
|
||||
|
||||
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)), \
|
||||
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=(101, "Document salvat"))), \
|
||||
patch("pathlib.Path.unlink"):
|
||||
await rh.handle_receipt_callback(update, MagicMock())
|
||||
|
||||
# Two edit_message_text calls: "Salvez..." then success message.
|
||||
edit_calls = update.callback_query.edit_message_text.await_args_list
|
||||
assert len(edit_calls) >= 2
|
||||
final_text = edit_calls[-1].args[0]
|
||||
assert "salvat" in final_text.lower()
|
||||
assert "101" in final_text
|
||||
assert 42 not in rh._pending_receipts
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# handle_document_message (5 tests)
|
||||
# ===========================================================================
|
||||
|
||||
def _make_message_update(user_id=42, document=None, photo=None):
|
||||
update = MagicMock()
|
||||
update.effective_user.id = user_id
|
||||
update.message.reply_text = AsyncMock()
|
||||
if document is not None:
|
||||
update.message.document = document
|
||||
if photo is not None:
|
||||
update.message.photo = photo
|
||||
return update
|
||||
|
||||
|
||||
def _make_telegram_doc(file_id="file-1", file_name="bon.pdf"):
|
||||
doc = MagicMock()
|
||||
doc.file_id = file_id
|
||||
doc.file_name = file_name
|
||||
return doc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_document_unlinked_user_replies_login_hint():
|
||||
update = _make_message_update(user_id=1, document=_make_telegram_doc())
|
||||
context = MagicMock()
|
||||
context.bot.get_file = AsyncMock()
|
||||
|
||||
with patch("backend.modules.telegram.handlers.receipt_handlers.is_user_linked",
|
||||
new=AsyncMock(return_value=False)):
|
||||
await rh.handle_document_message(update, context)
|
||||
|
||||
args, _ = update.message.reply_text.call_args
|
||||
assert "/login" in args[0]
|
||||
context.bot.get_file.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_document_pending_exists_blocks_new_submission():
|
||||
rh._pending_receipts[42] = {
|
||||
"receipt_dict": {},
|
||||
"temp_path": "/tmp/receipt_x.pdf",
|
||||
"created_at": datetime.now(),
|
||||
}
|
||||
update = _make_message_update(user_id=42, document=_make_telegram_doc())
|
||||
context = MagicMock()
|
||||
context.bot.get_file = AsyncMock()
|
||||
|
||||
with patch("backend.modules.telegram.handlers.receipt_handlers.is_user_linked",
|
||||
new=AsyncMock(return_value=True)):
|
||||
await rh.handle_document_message(update, context)
|
||||
|
||||
args, _ = update.message.reply_text.call_args
|
||||
assert "așteptare" in args[0]
|
||||
context.bot.get_file.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_document_download_failure_replies_with_error():
|
||||
update = _make_message_update(user_id=42, document=_make_telegram_doc())
|
||||
context = MagicMock()
|
||||
tg_file = MagicMock()
|
||||
tg_file.download_to_drive = AsyncMock(side_effect=OSError("net down"))
|
||||
context.bot.get_file = AsyncMock(return_value=tg_file)
|
||||
|
||||
with patch("backend.modules.telegram.handlers.receipt_handlers.is_user_linked",
|
||||
new=AsyncMock(return_value=True)), \
|
||||
patch.object(rh, "_submit_ocr_and_preview", new=AsyncMock()) as mock_submit:
|
||||
await rh.handle_document_message(update, context)
|
||||
|
||||
# Last reply must be an error message; submit-ocr must not have run.
|
||||
last_args, _ = update.message.reply_text.call_args
|
||||
assert "❌" in last_args[0]
|
||||
mock_submit.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_document_success_invokes_submit_ocr():
|
||||
update = _make_message_update(user_id=42, document=_make_telegram_doc())
|
||||
context = MagicMock()
|
||||
tg_file = MagicMock()
|
||||
tg_file.download_to_drive = AsyncMock()
|
||||
context.bot.get_file = AsyncMock(return_value=tg_file)
|
||||
|
||||
with patch("backend.modules.telegram.handlers.receipt_handlers.is_user_linked",
|
||||
new=AsyncMock(return_value=True)), \
|
||||
patch.object(rh, "_submit_ocr_and_preview",
|
||||
new=AsyncMock()) as mock_submit:
|
||||
await rh.handle_document_message(update, context)
|
||||
|
||||
mock_submit.assert_awaited_once()
|
||||
# Third arg is the temp Path; must use the receipt_ prefix.
|
||||
temp_path = mock_submit.await_args.args[2]
|
||||
assert isinstance(temp_path, Path)
|
||||
assert temp_path.name.startswith(rh.TEMP_FILE_PREFIX)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_document_unknown_extension_defaults_to_pdf():
|
||||
"""When file_name has no extension, suffix falls back to '.pdf'."""
|
||||
doc = _make_telegram_doc(file_name="weirdfile")
|
||||
update = _make_message_update(user_id=42, document=doc)
|
||||
context = MagicMock()
|
||||
tg_file = MagicMock()
|
||||
tg_file.download_to_drive = AsyncMock()
|
||||
context.bot.get_file = AsyncMock(return_value=tg_file)
|
||||
|
||||
with patch("backend.modules.telegram.handlers.receipt_handlers.is_user_linked",
|
||||
new=AsyncMock(return_value=True)), \
|
||||
patch.object(rh, "_submit_ocr_and_preview", new=AsyncMock()) as mock_submit:
|
||||
await rh.handle_document_message(update, context)
|
||||
|
||||
temp_path = mock_submit.await_args.args[2]
|
||||
assert temp_path.suffix == ".pdf"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# handle_photo_message (3 tests)
|
||||
# ===========================================================================
|
||||
|
||||
def _make_telegram_photo_list():
|
||||
"""PTB returns a list of PhotoSize from smallest to largest; we use [-1]."""
|
||||
small = MagicMock()
|
||||
small.file_id = "small-id"
|
||||
large = MagicMock()
|
||||
large.file_id = "large-id"
|
||||
return [small, large]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_photo_unlinked_user_replies_login_hint():
|
||||
update = _make_message_update(user_id=1, photo=_make_telegram_photo_list())
|
||||
context = MagicMock()
|
||||
context.bot.get_file = AsyncMock()
|
||||
|
||||
with patch("backend.modules.telegram.handlers.receipt_handlers.is_user_linked",
|
||||
new=AsyncMock(return_value=False)):
|
||||
await rh.handle_photo_message(update, context)
|
||||
|
||||
args, _ = update.message.reply_text.call_args
|
||||
assert "/login" in args[0]
|
||||
context.bot.get_file.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_photo_pending_exists_blocks_new_submission():
|
||||
rh._pending_receipts[42] = {
|
||||
"receipt_dict": {},
|
||||
"temp_path": "/tmp/receipt_x.jpg",
|
||||
"created_at": datetime.now(),
|
||||
}
|
||||
update = _make_message_update(user_id=42, photo=_make_telegram_photo_list())
|
||||
context = MagicMock()
|
||||
context.bot.get_file = AsyncMock()
|
||||
|
||||
with patch("backend.modules.telegram.handlers.receipt_handlers.is_user_linked",
|
||||
new=AsyncMock(return_value=True)):
|
||||
await rh.handle_photo_message(update, context)
|
||||
|
||||
args, _ = update.message.reply_text.call_args
|
||||
assert "așteptare" in args[0]
|
||||
context.bot.get_file.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_photo_success_uses_largest_resolution():
|
||||
photos = _make_telegram_photo_list()
|
||||
update = _make_message_update(user_id=42, photo=photos)
|
||||
context = MagicMock()
|
||||
tg_file = MagicMock()
|
||||
tg_file.download_to_drive = AsyncMock()
|
||||
context.bot.get_file = AsyncMock(return_value=tg_file)
|
||||
|
||||
with patch("backend.modules.telegram.handlers.receipt_handlers.is_user_linked",
|
||||
new=AsyncMock(return_value=True)), \
|
||||
patch.object(rh, "_submit_ocr_and_preview", new=AsyncMock()) as mock_submit:
|
||||
await rh.handle_photo_message(update, context)
|
||||
|
||||
# Largest photo is photos[-1], so file_id="large-id".
|
||||
context.bot.get_file.assert_awaited_once_with("large-id")
|
||||
|
||||
temp_path = mock_submit.await_args.args[2]
|
||||
assert temp_path.suffix == ".jpg"
|
||||
assert temp_path.name.startswith(rh.TEMP_FILE_PREFIX)
|
||||
Reference in New Issue
Block a user