"""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()