"""Тесты эндпоинта чата `POST /api/v1/chat/ask` (#957, Step 1, ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM). Покрывает: • Каждый intent отдаёт корректный шаблонный RU-ответ для фикстуры-отчёта. • Нет рана → report_status='pending' + RU-подсказка «запустите анализ» (READ-ONLY). • unknown-вопрос → меню тем (fallback_reason='intent_unknown'). • llm_used всегда False; grounded_in несёт run_id/schema_version/sections. • 401 без X-Authenticated-User (mini-RBAC-app, как test_rbac.py — реальный app в тестах бьётся мимо Caddy с settings.testing=True, поэтому RBAC проверяем на копии middleware). Стратегия: DB — через app.dependency_overrides[get_db] (MagicMock-сессия); сам отчёт контролируем патчем latest_run_for (никакой прод-БД). """ from __future__ import annotations import re from collections.abc import Awaitable, Callable from typing import Any from unittest.mock import MagicMock, patch import pytest from fastapi import FastAPI, Request from fastapi.responses import JSONResponse, Response from fastapi.testclient import TestClient from app.api.v1 import chat as chat_router from app.core import auth as auth_mod from app.core.config import settings from app.core.db import get_db from app.main import app from app.services.llm.provider import LLMProvider, ProviderResponse, ToolCall _CAD = "66:41:0204016:10" # Разделитель тысяч — неразрывный пробел (зеркало ru-locale, _NBSP в intents.py). _NBSP = " " # ── Фикстуры ───────────────────────────────────────────────────────────────────── def _report() -> dict[str, Any]: """Полный отчёт (форма SiteFinderReport.as_dict()) с узнаваемыми числами.""" return { "schema_version": "1.0", "advisory": True, "exec_summary": { "headline": "Комфорт-класс, 2-3-комнатные", "verdict": "Участок подходит под комфорт-класс.", "key_numbers": {"цена_руб_м2": 250000}, "overall_confidence": "medium", }, "market_now": {"summary": "Рынок активен."}, "future_market": { "forecasts_by_horizon": [{"horizon": 12}, {"horizon": 24}], "future_supply": {"pressure": 0.4}, "summary": "Ожидается дефицит на горизонте 12 мес.", }, "product_tz": { "obj_class": "комфорт", "mix": [{"fmt": "2k"}], "reasons": [{"why": "дефицит формата"}], "summary": "Рекомендуется комфорт-класс.", }, "scenarios": { "by_scenario": {"conservative": {}, "base": {}, "aggressive": {}}, "summary": "Разброс умеренный.", }, "scoring": { "special_indices": { "indices": {"cannibalization": {"value": 0.31, "label": None}} }, }, "confidence": { "level": "medium", "rationale": "Прогноз спроса — прокси.", "factors": {"deals": "ok"}, }, "meta": {"cad_num": _CAD, "schema_version": "1.0"}, } def _make_run(report: dict[str, Any], run_id: int = 42) -> MagicMock: """Замокать Row из latest_run_for (.result — отчёт, .id — run_id).""" run = MagicMock() run.result = report run.id = run_id return run def _client_with_db() -> TestClient: """TestClient с override get_db на MagicMock-сессию (реальная БД не трогается).""" app.dependency_overrides[get_db] = lambda: MagicMock() return TestClient(app) # ── Happy-path: intents возвращают шаблонный ответ ─────────────────────────────── @pytest.mark.parametrize( ("message", "expected_substrings", "expected_sections"), [ ("Дай резюме по участку", ["Комфорт-класс", f"250{_NBSP}000"], ["exec_summary"]), ("Что здесь строить?", ["комфорт"], ["exec_summary", "product_tz"]), ("Почему такой прогноз?", ["горизонт", "прокси"], ["future_market"]), ("Какие риски?", ["каннибализация портфеля", "0,31"], ["scoring"]), ("Покажи сценарии", ["консервативный", "базовый"], ["scenarios"]), ], ) def test_ask_intent_returns_templated_answer( message: str, expected_substrings: list[str], expected_sections: list[str], ) -> None: """Каждый intent → корректный RU-ответ; числа из отчёта; grounded_in заполнен.""" client = _client_with_db() with patch( "app.services.chat.retrieval.latest_run_for", return_value=_make_run(_report()), ): resp = client.post( "/api/v1/chat/ask", json={"cad_num": _CAD, "message": message}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["report_status"] == "ready" assert body["llm_used"] is False assert body["advisory"] is True for sub in expected_substrings: assert sub in body["answer"], f"{sub!r} not in answer: {body['answer']}" assert body["grounded_in"]["run_id"] == 42 assert body["grounded_in"]["schema_version"] == "1.0" for sect in expected_sections: assert sect in body["grounded_in"]["sections"] def test_ask_explicit_intent_is_respected() -> None: """Явный intent уважается поверх текста (риск-текст, но intent=summary).""" client = _client_with_db() with patch( "app.services.chat.retrieval.latest_run_for", return_value=_make_run(_report()), ): resp = client.post( "/api/v1/chat/ask", json={"cad_num": _CAD, "message": "какие риски?", "intent": "summary"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["grounded_in"]["sections"] == ["exec_summary"] def test_ask_answer_always_has_advisory_caveat() -> None: """Каждый ответ заканчивается advisory-оговоркой.""" client = _client_with_db() with patch( "app.services.chat.retrieval.latest_run_for", return_value=_make_run(_report()), ): resp = client.post( "/api/v1/chat/ask", json={"cad_num": _CAD, "message": "резюме"}, ) assert "советующий характер" in resp.json()["answer"] def test_ask_history_accepted_but_unused() -> None: """history принимается (200), но в Step 1 не влияет на ответ (llm_used=False).""" client = _client_with_db() with patch( "app.services.chat.retrieval.latest_run_for", return_value=_make_run(_report()), ): resp = client.post( "/api/v1/chat/ask", json={ "cad_num": _CAD, "message": "резюме", "history": [{"role": "user", "content": "привет"}], }, ) assert resp.status_code == 200, resp.text assert resp.json()["llm_used"] is False # ── unknown intent → меню ──────────────────────────────────────────────────────── def test_ask_unknown_intent_returns_menu() -> None: """Нераспознанный вопрос → меню тем + fallback_reason='intent_unknown'.""" client = _client_with_db() with patch( "app.services.chat.retrieval.latest_run_for", return_value=_make_run(_report()), ): resp = client.post( "/api/v1/chat/ask", json={"cad_num": _CAD, "message": "какая погода в Москве?"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["report_status"] == "ready" assert body["fallback_reason"] == "intent_unknown" assert body["grounded_in"]["sections"] == [] assert "что здесь строить" in body["answer"].lower() # ── pending: рана нет ──────────────────────────────────────────────────────────── def test_ask_no_run_returns_pending() -> None: """Рана нет → report_status='pending' + RU-подсказка «запустите анализ».""" client = _client_with_db() with patch("app.services.chat.retrieval.latest_run_for", return_value=None): resp = client.post( "/api/v1/chat/ask", json={"cad_num": _CAD, "message": "резюме"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["report_status"] == "pending" assert body["grounded_in"] is None assert body["llm_used"] is False assert body["fallback_reason"] == "report_pending" assert "Запустите анализ участка" in body["answer"] def test_ask_db_error_returns_pending_not_500() -> None: """Сбой БД на read-only → pending (не 500), клиент может повторить.""" client = _client_with_db() with patch( "app.services.chat.retrieval.latest_run_for", side_effect=RuntimeError("db down"), ): resp = client.post( "/api/v1/chat/ask", json={"cad_num": _CAD, "message": "резюме"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["report_status"] == "pending" assert body["fallback_reason"] == "report_unavailable" # ── Валидация запроса ──────────────────────────────────────────────────────────── def test_ask_missing_message_422() -> None: """message обязателен → 422.""" client = _client_with_db() resp = client.post("/api/v1/chat/ask", json={"cad_num": _CAD}) assert resp.status_code == 422 def test_ask_empty_cad_num_422() -> None: """Пустой cad_num → 422 (min_length=1).""" client = _client_with_db() resp = client.post("/api/v1/chat/ask", json={"cad_num": "", "message": "резюме"}) assert resp.status_code == 422 # ── RBAC: 401 без X-Authenticated-User ─────────────────────────────────────────── # Реальный app в тестах бьётся мимо Caddy с settings.testing=True → rbac_guard # пропускает. RBAC проверяем на КОПИИ middleware (как tests/test_rbac.py), смонтировав # тот же chat-роутер под /api/v1/chat. _ADMIN_API_RE = re.compile(r"^/api/v1/admin/") _PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"}) def _build_rbac_test_app() -> FastAPI: """FastAPI с копией rbac_guard из app/main.py + смонтированным chat-роутером.""" rbac_app = FastAPI() @rbac_app.middleware("http") async def rbac_guard( # type: ignore[unused-ignore] request: Request, call_next: Callable[[Request], Awaitable[Response]], ) -> Response: path = request.url.path if path in _PUBLIC_PATHS: return await call_next(request) username = request.headers.get("X-Authenticated-User") if not username: return JSONResponse( status_code=401, content={"detail": "no authenticated user (Caddy basic_auth required)"}, ) try: role = auth_mod.get_role(username) except KeyError: return JSONResponse( status_code=403, content={"detail": "user not in roles config"}, ) if _ADMIN_API_RE.match(path) and role != "admin": return JSONResponse(status_code=403, content={"detail": "admin only"}) return await call_next(request) rbac_app.include_router(chat_router.router, prefix="/api/v1/chat", tags=["chat"]) rbac_app.dependency_overrides[get_db] = lambda: MagicMock() return rbac_app def test_ask_no_auth_header_returns_401() -> None: """Без X-Authenticated-User → 401 (rbac_guard, чат смонтирован под /api/v1).""" client = TestClient(_build_rbac_test_app()) resp = client.post("/api/v1/chat/ask", json={"cad_num": _CAD, "message": "резюме"}) assert resp.status_code == 401 assert "no authenticated user" in resp.json()["detail"] def test_ask_unknown_user_returns_403() -> None: """Юзер не в roles.yaml → 403 (rbac_guard).""" client = TestClient(_build_rbac_test_app()) resp = client.post( "/api/v1/chat/ask", json={"cad_num": _CAD, "message": "резюме"}, headers={"X-Authenticated-User": "nobody-unknown"}, ) assert resp.status_code == 403 def test_ask_known_user_passes_rbac() -> None: """Известный юзер (admin) проходит rbac_guard → доходит до хендлера (200).""" client = TestClient(_build_rbac_test_app()) with patch( "app.services.chat.retrieval.latest_run_for", return_value=_make_run(_report()), ): resp = client.post( "/api/v1/chat/ask", json={"cad_num": _CAD, "message": "резюме"}, headers={"X-Authenticated-User": "admin"}, ) assert resp.status_code == 200, resp.text # ── Step 2: ветка llm_enabled (LLM tool-loop через эндпоинт) ───────────────────── # Сеть НЕ дёргается: подменяем _build_default_provider на fake (provider=None путь в # complete), чтобы проверить именно бридж эндпоинта (run_in_threadpool → orchestrate). class _FakeProvider(LLMProvider): """Внешний fake провайдера: 1-й вызов — tool-call, 2-й — финальная проза.""" def __init__(self) -> None: self.calls = 0 @property def is_external(self) -> bool: return True @property def model(self) -> str: return "gpt-4o-mini" def complete( self, messages: list[dict[str, Any]], *, tools: list[dict[str, Any]] | None = None, max_output_tokens: int, ) -> ProviderResponse: self.calls += 1 if self.calls == 1: return ProviderResponse( content=None, tool_calls=[ToolCall(id="c1", name="get_exec_summary", arguments="{}")], model="gpt-4o-mini", ) return ProviderResponse(content="LLM-проза по участку.", model="gpt-4o-mini") def test_ask_llm_enabled_uses_orchestrator(monkeypatch: pytest.MonkeyPatch) -> None: """llm_enabled=True → tool-loop: llm_used=True, ответ LLM, grounded_in.sections верны.""" monkeypatch.setattr(settings, "llm_enabled", True) monkeypatch.setattr(settings, "openai_api_key", "test-fake-key-not-real") # ОДИН инстанс на запрос: его stateful .calls ведёт tool-call → проза между # витками loop'а (новый инстанс на каждый виток сбросил бы счётчик). fake = _FakeProvider() monkeypatch.setattr("app.services.llm.client._build_default_provider", lambda: fake) client = _client_with_db() with patch( "app.services.chat.retrieval.latest_run_for", return_value=_make_run(_report()), ): resp = client.post( "/api/v1/chat/ask", json={"cad_num": _CAD, "message": "что здесь строить?"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["report_status"] == "ready" assert body["llm_used"] is True assert body["answer"] == "LLM-проза по участку." assert body["grounded_in"]["sections"] == ["exec_summary"] assert body["grounded_in"]["run_id"] == 42 def test_ask_llm_enabled_degrades_to_deterministic_on_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: """llm_enabled=True, но провайдера нет (ключ None) → детерм. fallback, llm_used=False.""" monkeypatch.setattr(settings, "llm_enabled", True) monkeypatch.setattr(settings, "openai_api_key", None) client = _client_with_db() with patch( "app.services.chat.retrieval.latest_run_for", return_value=_make_run(_report()), ): resp = client.post( "/api/v1/chat/ask", json={"cad_num": _CAD, "message": "дай резюме"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["llm_used"] is False assert body["fallback_reason"] == "no_api_key" # Детерминированный Step-1 ответ с числом отчёта (вербатим). assert f"250{_NBSP}000" in body["answer"] def test_ask_llm_disabled_no_provider_constructed(monkeypatch: pytest.MonkeyPatch) -> None: """llm_enabled=False (дефолт) → детерм. путь; провайдер НЕ конструируется (tripwire).""" monkeypatch.setattr(settings, "llm_enabled", False) def _tripwire() -> None: raise AssertionError("provider must NOT be built when llm_enabled=False") monkeypatch.setattr("app.services.llm.client._build_default_provider", _tripwire) client = _client_with_db() with patch( "app.services.chat.retrieval.latest_run_for", return_value=_make_run(_report()), ): resp = client.post( "/api/v1/chat/ask", json={"cad_num": _CAD, "message": "дай резюме"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["llm_used"] is False assert f"250{_NBSP}000" in body["answer"]