325 lines
13 KiB
Python
325 lines
13 KiB
Python
"""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
|