DealDocumentScreening/tests/unit/test_bot_client.py
2026-08-12 21:29:36 +03:00

277 lines
8.8 KiB
Python

"""Bot ApiClient + delivery-helpers unit tests (respx-mocked, no network).
Covers the api response contract the bot relies on:
- POST /api/v1/auth/telegram/bot -> {access_token, ...}
- GET /api/v1/me -> {telegram_id, credits_left} (Bearer user JWT)
- POST /api/v1/documents -> 202 {document_id, correlation_id, credits_left}
| 402 | 400 | other (Bearer user JWT)
- GET /api/v1/reports/{id} -> 202-ish {document_id, status, stage}
| 200 {..., markdown, filename} when done
| 404 (Bearer user JWT)
Plus the disclaimer guarantee and error-class mapping (docs/ARCHITECTURE.md §8, §17).
"""
from __future__ import annotations
import httpx
import pytest
import respx
from contract_check.bot.client import (
ApiClient,
ApiError,
NoCreditsError,
UnsupportedFormatError,
ensure_disclaimer,
)
from contract_check.bot.config import BotSettings
BASE = "http://api:8000"
_FAKE_JWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDAiLCJ0ZWxlZ3JhbV9pZCI6NDIsInR5cGUiOiJhY2Nlc3MiLCJleHAiOjk5OTk5OTk5OTksImlhdCI6MSwiYXVkIjoiY29udHJhY3QtY2hlY2sifQ.test-signature"
def _settings() -> BotSettings:
return BotSettings(
bot_token="123:abc",
api_url=f"{BASE}",
bot_service_token="secret-token",
)
def _client() -> ApiClient:
c = ApiClient(_settings())
c._client = httpx.AsyncClient(
base_url=BASE,
)
return c
def _login(api: ApiClient, telegram_id: int) -> None:
"""Seed a fake JWT into the bot cache so user-scoped calls succeed."""
api._token_cache[telegram_id] = _FAKE_JWT
@respx.mock
async def test_login_exchanges_telegram_id_for_jwt() -> None:
route = respx.post(f"{BASE}/api/v1/auth/telegram/bot").mock(
return_value=httpx.Response(
200,
json={
"access_token": _FAKE_JWT,
"token_type": "bearer",
"expires_in": 86400,
"user_id": "00000000-0000-0000-0000-000000000000",
"telegram_id": 42,
},
)
)
api = _client()
await api.login(42, "cid-login")
assert route.called
req = route.calls.last.request
assert req.headers["Authorization"] == "Bearer secret-token"
assert req.headers["X-Correlation-ID"] == "cid-login"
assert api._token_cache.get(42) == _FAKE_JWT
await api.aclose()
def test_ensure_disclaimer_appends_when_missing() -> None:
out = ensure_disclaimer("# Отчёт\nбез дисклеймера")
assert "Дисклеймер" in out
assert out.startswith("# Отчёт")
def test_ensure_disclaimer_idempotent_when_present() -> None:
md = "# Отчёт\n\n> ⚠️ **Дисклеймер.** Уже есть."
assert ensure_disclaimer(md) == md
@pytest.mark.parametrize(
"env_value,json_logs",
[("dev", False), ("prod", True), ("staging", True)],
)
def test_bot_settings_json_logs(env_value: str, json_logs: bool) -> None:
s = BotSettings(
bot_token="t",
api_url=BASE,
bot_service_token="x",
env=env_value,
)
assert s.json_logs is json_logs
@respx.mock
async def test_get_credits_returns_balance() -> None:
respx.get(f"{BASE}/api/v1/me").mock(
return_value=httpx.Response(200, json={"telegram_id": 42, "credits_left": 7})
)
api = _client()
_login(api, 42)
credits = await api.get_credits(42, "cid-1")
assert credits == 7
request = respx.calls.last.request
assert request.headers["Authorization"] == f"Bearer {_FAKE_JWT}"
assert request.headers["X-Correlation-ID"] == "cid-1"
assert "telegram_id" not in request.url.params
await api.aclose()
@respx.mock
async def test_get_credits_raises_on_error() -> None:
respx.get(f"{BASE}/api/v1/me").mock(return_value=httpx.Response(500, json={"detail": "boom"}))
api = _client()
_login(api, 42)
with pytest.raises(ApiError) as exc:
await api.get_credits(42, "cid")
assert exc.value.status_code == 500
assert "boom" in exc.value.detail
await api.aclose()
@respx.mock
async def test_upload_document_success() -> None:
route = respx.post(f"{BASE}/api/v1/documents").mock(
return_value=httpx.Response(
202,
json={
"document_id": "doc-1",
"correlation_id": "corr-1",
"credits_left": 4,
},
)
)
api = _client()
_login(api, 42)
result = await api.upload_document(42, "cid-2", "contract.pdf", b"data", "application/pdf")
assert result.document_id == "doc-1"
assert result.correlation_id == "corr-1"
assert result.credits_left == 4
assert route.called
req = route.calls.last.request
assert "telegram_id" not in req.url.params
assert req.headers["X-Correlation-ID"] == "cid-2"
assert req.headers["Authorization"] == f"Bearer {_FAKE_JWT}"
await api.aclose()
@respx.mock
async def test_upload_document_no_credits_maps_to_402() -> None:
respx.post(f"{BASE}/api/v1/documents").mock(
return_value=httpx.Response(402, json={"detail": "no credits available"})
)
api = _client()
_login(api, 42)
with pytest.raises(NoCreditsError) as exc:
await api.upload_document(42, "cid", "c.pdf", b"d", "application/pdf")
assert exc.value.status_code == 402
await api.aclose()
@respx.mock
async def test_upload_document_bad_format_maps_to_400() -> None:
respx.post(f"{BASE}/api/v1/documents").mock(
return_value=httpx.Response(400, json={"detail": "unsupported format '.exe'"})
)
api = _client()
_login(api, 42)
with pytest.raises(UnsupportedFormatError):
await api.upload_document(42, "cid", "c.exe", b"d", "application/octet-stream")
await api.aclose()
@respx.mock
async def test_upload_document_other_error_maps_to_apierror() -> None:
respx.post(f"{BASE}/api/v1/documents").mock(
return_value=httpx.Response(503, text="service unavailable")
)
api = _client()
_login(api, 42)
with pytest.raises(ApiError) as exc:
await api.upload_document(42, "cid", "c.pdf", b"d", "application/pdf")
assert exc.value.status_code == 503
await api.aclose()
@respx.mock
async def test_get_report_pending_returns_status_and_stage() -> None:
respx.get(f"{BASE}/api/v1/reports/doc-1").mock(
return_value=httpx.Response(
200,
json={"document_id": "doc-1", "status": "extracting", "stage": "extract"},
)
)
api = _client()
_login(api, 42)
report = await api.get_report(42, "cid", "doc-1")
assert report.status == "extracting"
assert report.stage == "extract"
assert report.markdown is None
req = respx.calls.last.request
assert "telegram_id" not in req.url.params
assert req.headers["X-Correlation-ID"] == "cid"
assert req.headers["Authorization"] == f"Bearer {_FAKE_JWT}"
await api.aclose()
@respx.mock
async def test_get_report_done_returns_markdown() -> None:
respx.get(f"{BASE}/api/v1/reports/doc-1").mock(
return_value=httpx.Response(
200,
json={
"document_id": "doc-1",
"status": "done",
"filename": "contract.pdf",
"markdown": "# Отчёт\n\n> ⚠️ **Дисклеймер.** готовый.",
"findings": [],
"model_used": "qwen2.5:14b",
"prompt_tokens": 100,
"eval_tokens": 200,
"latency_ms": 1234,
},
)
)
api = _client()
_login(api, 42)
report = await api.get_report(42, "cid", "doc-1")
assert report.status == "done"
assert report.markdown is not None
assert report.filename == "contract.pdf"
await api.aclose()
@respx.mock
async def test_get_report_not_found_raises_apierror() -> None:
respx.get(f"{BASE}/api/v1/reports/doc-x").mock(
return_value=httpx.Response(404, json={"detail": "report not found"})
)
api = _client()
_login(api, 42)
with pytest.raises(ApiError) as exc:
await api.get_report(42, "cid", "doc-x")
assert exc.value.status_code == 404
await api.aclose()
async def test_client_not_started_raises() -> None:
api = ApiClient(_settings())
with pytest.raises(RuntimeError):
_ = api.client
@respx.mock
async def test_failed_report_payload_has_no_markdown() -> None:
respx.get(f"{BASE}/api/v1/reports/doc-1").mock(
return_value=httpx.Response(
200,
json={"document_id": "doc-1", "status": "failed", "stage": "analyze"},
)
)
api = _client()
_login(api, 42)
report = await api.get_report(42, "cid", "doc-1")
assert report.status == "failed"
assert report.markdown is None
await api.aclose()