"""API `/quality-metrics` (design `quality-metrics` §6-§8; AURORA-1759, Task 15). Быстрые тесты (без БД): право `QUALITY_METRICS_VIEW`, валидация query, маршруты в OpenAPI, CSV-ячейки. Интеграционные (`integration`, PostgreSQL): факты пишутся напрямую через `insert_*_fact`, без полного цикла маппинга.""" import csv import datetime import io import uuid from collections.abc import AsyncIterator from types import SimpleNamespace from typing import Any import httpx import pytest from fastapi import FastAPI from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.api.dependencies.auth import current_user from src.api.router import router from src.core.exception_handlers import register_exception_handlers from src.db.db import get_async_session from src.models.audit_ex import AuditEvent from src.models.enums import ( AuditAction, AuditSection, DocumentStatus, DocumentType, QualityCause, QualityFailureKind, QualityOutcome, RecognitionPipeline, ) from src.repositories.quality_facts import insert_failure_fact, insert_intake_fact, insert_outcome_fact from src.repositories.users import UserWithAccess from src.services.quality_metrics_service import EXPORT_COLUMNS, _cell from tests.conftest import AuthClientFactory HTTP_OK, HTTP_FORBIDDEN, HTTP_UNPROCESSABLE = 200, 403, 422 KA_RF = "ka_rf@armka.local" BASE = "/quality-metrics" UTC = datetime.UTC T0 = datetime.datetime(2026, 11, 1, 10, 0, tzinfo=UTC) T1 = T0 + datetime.timedelta(hours=1) T2 = T0 + datetime.timedelta(hours=2) VIEW = "QUALITY_METRICS_VIEW" THREE, FOUR = 3, 4 # --- быстрые тесты -------------------------------------------------------------------------------------------------- async def _forbidden_client(permissions: frozenset[str]) -> AsyncIterator[httpx.AsyncClient]: app = FastAPI() register_exception_handlers(app) app.include_router(router) async def no_session() -> AsyncIterator[None]: yield None async def principal() -> UserWithAccess: return UserWithAccess( user=SimpleNamespace(id=uuid.uuid4()), regional_office=SimpleNamespace(), roles=(), permissions=permissions, # type: ignore[arg-type] ) app.dependency_overrides[get_async_session] = no_session app.dependency_overrides[current_user] = principal async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test/api/v1") as client: yield client @pytest.fixture async def client_without_permission() -> AsyncIterator[httpx.AsyncClient]: async for client in _forbidden_client(frozenset({"DIRECTORY_VIEW"})): yield client @pytest.mark.parametrize( "path", [ "/matrix", "/series?cause=AS_IS", f"/cells/{QualityCause.AS_IS}/documents", "/export", ], ) async def test_quality_metrics_requires_permission(client_without_permission: httpx.AsyncClient, path: str) -> None: response = await client_without_permission.get(BASE + path) assert response.status_code == HTTP_FORBIDDEN assert VIEW in str(response.json()) async def test_quality_metrics_matrix_requires_permission(client_without_permission: httpx.AsyncClient) -> None: response = await client_without_permission.get("/quality-metrics/matrix") assert response.status_code == HTTP_FORBIDDEN @pytest.mark.parametrize( "path", [ "/matrix?date_from=2026-02-01&date_to=2026-01-01", "/matrix?document_type=NOPE", "/series", # cause обязателен "/series?cause=NOPE", "/cells/NOPE/documents", "/cells/AS_IS/documents?limit=0", "/export?format=xml", ], ) async def test_quality_metrics_validates_query(path: str) -> None: async for client in _forbidden_client(frozenset({VIEW})): response = await client.get(BASE + path) assert response.status_code == HTTP_UNPROCESSABLE, response.text def test_quality_metrics_routes_in_openapi() -> None: app = FastAPI() app.include_router(router) paths = app.openapi()["paths"] for path in ("/matrix", "/series", "/cells/{cell}/documents", "/export"): assert f"/api/v1{BASE}{path}" in paths # --- интеграционные тесты ------------------------------------------------------------------------------------------- def _intake_columns(document_id: uuid.UUID, **over: Any) -> dict[str, Any]: columns: dict[str, Any] = { "document_id": document_id, "file_id": uuid.uuid4(), "job_id": uuid.uuid4(), "attempt": 1, "legal_entity_id": uuid.uuid4(), "legal_entity_name": "ООО Тест", "group_id": None, "group_name": None, "document_type": DocumentType.OSV, "pipeline": RecognitionPipeline.image, "is_scan": True, "intake_at": T0, "intake_status": DocumentStatus.RECOGNIZED, "payload": {}, } columns.update(over) return columns async def _doc( session: AsyncSession, outcome: QualityOutcome | None, cause: QualityCause | None, *, at: datetime.datetime = T1, **intake: Any, ) -> uuid.UUID: """Документ с фактом приёма и (если `outcome`) одним фактом исхода.""" document_id = uuid.uuid4() await insert_intake_fact(session, **_intake_columns(document_id, **intake)) if outcome is not None: await insert_outcome_fact( session, document_id=document_id, outcome=outcome, outcome_at=at, primary_cause=cause, payload={} ) await session.flush() return document_id @pytest.mark.integration async def test_quality_metrics_matrix_returns_counts(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.SERVICE_ERROR) await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS) await _doc(seeded, QualityOutcome.AUTO_CONFIRMED, QualityCause.WITHOUT_PARTICIPATION) await _doc(seeded, QualityOutcome.DELETED, None) await _doc(seeded, None, None) # в работе client = await auth_client(KA_RF) response = await client.get(f"{BASE}/matrix") assert response.status_code == HTTP_OK body = response.json() assert body["counts"]["SERVICE_ERROR"] == 1 assert body["counts"]["AS_IS"] == 1 assert body["counts"]["OUR_ERROR"] == 0 assert body["denominator"] == THREE assert body["shares"]["SERVICE_ERROR"] == pytest.approx(1 / 3) assert body["removed_before_confirm"] == 1 assert body["in_progress_count"] == 1 @pytest.mark.integration async def test_quality_metrics_matrix_dedupes_to_latest_outcome_fact( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: document_id = await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS, at=T1) await insert_outcome_fact( seeded, document_id=document_id, outcome=QualityOutcome.SUPERSEDED_AFTER_CONFIRM, outcome_at=T2, primary_cause=None, payload={}, ) await seeded.flush() client = await auth_client(KA_RF) body = (await client.get(f"{BASE}/matrix")).json() assert body["counts"].get("AS_IS", 0) == 0 # устаревший факт не виден assert body["superseded_after_confirm_count"] == 1 assert body["removed_after_confirm"] == 1 assert body["denominator"] == 0 # series и список ячейки видят тот же последний факт series = (await client.get(f"{BASE}/series", params={"cause": "AS_IS"})).json() assert series["points"] == [] cell = (await client.get(f"{BASE}/cells/AS_IS/documents")).json() assert cell["total"] == 0 removed = (await client.get(f"{BASE}/cells/REMOVED_AFTER_CONFIRM/documents")).json() assert removed["total"] == 1 assert removed["items"][0]["previous_outcome"] == "CONFIRMED" export = (await client.get(f"{BASE}/export", params={"format": "json"})).json() assert [r["outcome"] for r in export] == ["SUPERSEDED_AFTER_CONFIRM"] @pytest.mark.integration async def test_quality_metrics_export_writes_audit_event(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: group = uuid.uuid4() await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS, group_id=group, group_name="ГК") await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS) client = await auth_client(KA_RF) response = await client.get(f"{BASE}/export", params={"format": "json", "group_id": str(group)}) assert response.status_code == HTTP_OK events = (await seeded.execute(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).scalars().all() assert len(events) == 1 event = events[0] assert event.section is AuditSection.EXPORT assert event.object_type == "quality_metrics" assert event.user_id is not None assert event.after_value == { "report": "quality_metrics", "format": "json", "filters": {"group_id": str(group)}, "rows": 1, } @pytest.mark.integration async def test_quality_metrics_equal_outcome_at_prefers_after_confirm( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: document_id = await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS, at=T1) await insert_outcome_fact( seeded, document_id=document_id, outcome=QualityOutcome.DELETED_AFTER_CONFIRM, outcome_at=T1, primary_cause=None, payload={}, ) await seeded.flush() body = (await (await auth_client(KA_RF)).get(f"{BASE}/matrix")).json() assert body["deleted_after_confirm_count"] == 1 assert body["counts"]["AS_IS"] == 0 @pytest.mark.integration async def test_quality_metrics_filters_and_series(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: group = uuid.uuid4() await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.OUR_ERROR, group_id=group, group_name="ГК") await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS, group_id=group, group_name="ГК", is_scan=False) await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS, group_id=group, document_type=DocumentType.BALANCE) await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS) # другая ГК await _doc( # другой месяц приёма seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS, group_id=group, intake_at=T0 + datetime.timedelta(days=40) ) client = await auth_client(KA_RF) async def denominator(**params: Any) -> int: return (await client.get(f"{BASE}/matrix", params={"group_id": str(group), **params})).json()["denominator"] assert await denominator() == FOUR assert await denominator(document_type="BALANCE") == 1 assert await denominator(is_scan="false") == 1 assert await denominator(pipeline="tabular") == 0 assert await denominator(date_from="2026-11-01", date_to="2026-11-01") == THREE # границы включительно assert await denominator(date_from="2026-11-02") == 1 series = (await client.get(f"{BASE}/series", params={"group_id": str(group), "cause": "AS_IS"})).json() assert [(p["month"], p["count"], p["denominator"]) for p in series["points"]][0] == ("2026-11-01", 2, 3) assert len(series["points"]) == 2 # noqa: PLR2004 - ноябрь и декабрь @pytest.mark.integration async def test_quality_metrics_technical_failures_by_files( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS) for kind in (QualityFailureKind.SERVICE_ERROR, QualityFailureKind.TIMEOUT): await insert_failure_fact( seeded, job_id=uuid.uuid4(), file_id=uuid.uuid4(), attempt=1, legal_entity_id=uuid.uuid4(), legal_entity_name="ООО Тест", pipeline=RecognitionPipeline.image, is_scan=True, failure_kind=kind, failed_at=T1, ) await seeded.flush() client = await auth_client(KA_RF) failures = (await client.get(f"{BASE}/matrix", params={"document_type": "BALANCE"})).json()["technical_failures"] assert failures == {"failed_files_count": 2, "total_files_count": 3, "share": pytest.approx(2 / 3)} @pytest.mark.integration async def test_quality_metrics_cell_documents(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: for _ in range(3): await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.SERVICE_ERROR, legal_entity_name="ООО Альфа") await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS) in_progress = await _doc(seeded, None, None) client = await auth_client(KA_RF) page = (await client.get(f"{BASE}/cells/SERVICE_ERROR/documents", params={"limit": 2})).json() assert (page["total"], page["limit"], page["offset"], len(page["items"])) == (3, 2, 0, 2) item = page["items"][0] assert item["primary_cause"] == "SERVICE_ERROR" assert item["legal_entity_name"] == "ООО Альфа" assert item["document_link"] is None # рабочего документа нет assert set(item["fixes"]) >= {"value_service", "value_ours", "osv_period_account"} second = (await client.get(f"{BASE}/cells/SERVICE_ERROR/documents", params={"limit": 2, "offset": 2})).json() assert len(second["items"]) == 1 progress = (await client.get(f"{BASE}/cells/IN_PROGRESS/documents")).json() assert [i["document_id"] for i in progress["items"]] == [str(in_progress)] assert progress["items"][0]["outcome"] is None @pytest.mark.integration async def test_quality_metrics_export(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS, legal_entity_name="ООО Бета") await _doc(seeded, None, None) client = await auth_client(KA_RF) response = await client.get(f"{BASE}/export") assert response.status_code == HTTP_OK assert response.headers["content-type"].startswith("text/csv") assert "attachment" in response.headers["content-disposition"] rows = list(csv.DictReader(io.StringIO(response.content.decode("utf-8-sig")))) assert list(rows[0]) == list(EXPORT_COLUMNS) assert len(rows) == 2 # noqa: PLR2004 assert {r["outcome"] for r in rows} == {"CONFIRMED", ""} assert rows[0]["is_scan"] == "true" as_json = await client.get(f"{BASE}/export", params={"format": "json", "date_from": "2026-11-01"}) assert len(as_json.json()) == 2 # noqa: PLR2004 @pytest.mark.parametrize("prefix", ["=", "+", "-", "@", "\t", "\r"]) def test_csv_cell_escapes_formula_strings(prefix: str) -> None: assert _cell(f"{prefix}SUM(A1)") == f"'{prefix}SUM(A1)" def test_csv_cell_keeps_non_strings_and_plain_strings() -> None: assert _cell(-5) == -5 # noqa: PLR2004 assert _cell("ООО Альфа") == "ООО Альфа" assert _cell(datetime.date(2026, 11, 1)) == "2026-11-01" assert _cell(True) == "true"