arm-core/api/tests/test_api_quality_metrics.py
2026-11-07 02:43:36 +04:00

325 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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.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.enums import (
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
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(),
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_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