277 lines
8.8 KiB
Python
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()
|