arm-core/api/tests/test_api_files_read.py
Andrey Epifancev 20983fb3d8 feat(files): API файлов, документов и строк ОСВ; подтверждение документа
GET /legal-entities/{id}/files (include_superseded, по убыванию uploaded_at),
GET /files/{id}, GET /documents/{id} (group_schema листа, warnings — R-6),
GET /documents/{id}/entries (limit ≤ 2000, по умолчанию 500),
POST /documents/{id}/confirm (DOCUMENT_EDIT; блокировка файла, затем документа;
409 FILE_SUPERSEDED / ALREADY_CONFIRMED / DOCUMENT_NOT_CONFIRMABLE; аудит
DOCUMENT_CONFIRM). Видимость — через ЮЛ-владельца, чужое — 404 как несуществующее.
Список файлов без N+1: файлы, текущие задания и документы — пакетными запросами.
JobBrief.mapping_error — причина ERROR файла по маппингу.

Тесты: сценарии documents-api, гонка подтверждения с автозаменой (Review Focus 5),
make_entries_from_result (R-2), реальная проверка уникальности документа (T2.2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 23:34:35 +04:00

282 lines
12 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.

"""Чтение файлов организации и карточки файла (spec documents-api «Файлы организации и карточка файла»; design D-11).
Сценарии поверх фикстуры `app` (транзакция теста с SAVEPOINT на запрос): файлы, задания и документы создаются
фабриками в той же транзакции. Видимость — через ЮЛ-владельца: ЮЛ Ростовского филиала видит `ka_rf`, не видит
`client_manager` (Краснодарский филиал); `curator_go` без `DOCUMENT_VIEW`.
"""
import datetime
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
import pytest
from sqlalchemy import event, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.directories import LegalEntity, RegionalOffice
from src.models.enums import DocumentStatus, FileStatus, PeriodQuarter, RecognitionStatus
from src.models.files import RecognitionJob, StoredFile
from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_document, make_legal_entity, make_stored_file
HTTP_OK = 200
HTTP_FORBIDDEN = 403
HTTP_NOT_FOUND = 404
KA_RF = "ka_rf@armka.local" # Ростовский филиал, DOCUMENT_VIEW + DOCUMENT_EDIT
CLIENT_MANAGER = "client_manager@armka.local" # Краснодарский филиал
CURATOR_GO = "curator_go@armka.local" # головной офис, права DOCUMENT_VIEW нет
ROSTOV = "Ростовский региональный филиал"
T0 = datetime.datetime(2026, 10, 1, 9, 0, tzinfo=datetime.UTC)
pytestmark = pytest.mark.integration
async def _rostov_entity(session: AsyncSession, name: str = "ООО Чтение файлов") -> LegalEntity:
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
group = await make_company_group(session, name=f"ГК {name}", office=office)
return await make_legal_entity(session, name=name, group=group)
async def _file( # noqa: PLR0913 - каждое поле файла задаётся тестом явно
session: AsyncSession,
entity: LegalEntity,
*,
minutes: int,
status: FileStatus = FileStatus.PROCESSED,
job_status: RecognitionStatus = RecognitionStatus.done,
metrics: dict[str, Any] | None = None,
) -> tuple[StoredFile, RecognitionJob]:
file, job = await make_stored_file(
session, entity=entity, status=status, job_status=job_status, original_name=f"osv-{minutes}.xls"
)
file.uploaded_at = T0 + datetime.timedelta(minutes=minutes)
job.metrics = metrics
await session.flush()
return file, job
def _files_url(entity_id: uuid.UUID) -> str:
return f"/legal-entities/{entity_id}/files"
@contextmanager
def _statements(session: AsyncSession) -> Iterator[list[str]]:
"""SQL-операторы, выполненные на соединении теста (запросы приложения идут в ту же транзакцию)."""
bind: Any = session.bind
connection = bind.sync_connection
statements: list[str] = []
def before_cursor_execute(*args: Any) -> None:
statements.append(args[2])
event.listen(connection, "before_cursor_execute", before_cursor_execute)
try:
yield statements
finally:
event.remove(connection, "before_cursor_execute", before_cursor_execute)
# --- «Список после обработки» ---------------------------------------------------------------------------------------
async def test_list_processed_with_document_and_error(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
entity = await _rostov_entity(seeded)
processed, processed_job = await _file(
seeded,
entity,
minutes=1,
metrics={"warnings": ["sheet=Лист_1 hierarchy=outline"], "mapping_warnings": ["sheet=Лист_1: сироты"]},
)
processed_job.finished_at = T0
document = await make_document(
seeded, file=processed, job=processed_job, account="66", year=2024, quarter=PeriodQuarter.Q4
)
document.locator = "sheet:Лист_1"
failed, failed_job = await _file(
seeded, entity, minutes=2, status=FileStatus.ERROR, job_status=RecognitionStatus.error
)
failed_job.error_code = "PARSE_ERROR"
failed_job.error_message = "лист не распознан"
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.get(_files_url(entity.id))
assert response.status_code == HTTP_OK, response.text
body = response.json()
assert (body["total"], body["limit"], body["offset"]) == (2, 50, 0)
newest, oldest = body["items"] # по убыванию uploaded_at
assert (newest["id"], oldest["id"]) == (str(failed.id), str(processed.id))
assert newest["status"] == "ERROR"
assert newest["documents"] == []
assert newest["job"]["error_code"] == "PARSE_ERROR"
assert newest["job"]["error_message"] == "лист не распознан"
assert newest["job"]["status"] == "error"
assert oldest["status"] == "PROCESSED"
assert oldest["legal_entity_id"] == str(entity.id)
assert oldest["original_name"] == "osv-1.xls"
assert oldest["job"] == {
"id": str(processed_job.id),
"status": "done",
"error_code": None,
"error_message": None,
"mapping_error": None,
"warnings": ["sheet=Лист_1 hierarchy=outline"],
"mapping_warnings": ["sheet=Лист_1: сироты"],
"finished_at": "2026-10-01T09:00:00Z",
}
assert oldest["documents"] == [
{
"id": str(document.id),
"document_type": "OSV",
"osv_account_code": "66",
"period_year": 2024,
"period_quarter": "Q4",
"is_cumulative": True,
"status": "RECOGNIZED",
"period_check": None,
"counterparty_check": None,
"locator": "sheet:Лист_1",
}
]
async def test_list_hides_superseded_by_default(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
entity = await _rostov_entity(seeded)
new, _ = await _file(seeded, entity, minutes=2)
old, _ = await _file(seeded, entity, minutes=1, status=FileStatus.SUPERSEDED)
old.superseded_by_file_id = new.id
await seeded.flush()
client = await auth_client(KA_RF)
default = (await client.get(_files_url(entity.id))).json()
full = (await client.get(_files_url(entity.id), params={"include_superseded": "true"})).json()
assert [item["id"] for item in default["items"]] == [str(new.id)]
assert default["total"] == 1
assert [item["id"] for item in full["items"]] == [str(new.id), str(old.id)]
assert full["items"][1]["superseded_by_file_id"] == str(new.id)
assert full["total"] == 2 # noqa: PLR2004
async def test_list_pagination(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
entity = await _rostov_entity(seeded)
files = [(await _file(seeded, entity, minutes=minute))[0] for minute in range(3)]
client = await auth_client(KA_RF)
body = (await client.get(_files_url(entity.id), params={"limit": 1, "offset": 1})).json()
assert (body["total"], body["limit"], body["offset"]) == (3, 1, 1)
assert [item["id"] for item in body["items"]] == [str(files[1].id)]
async def test_list_has_no_n_plus_one(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""Число SQL-запросов списка не зависит от числа файлов и документов на странице."""
client = await auth_client(KA_RF)
assert (await client.get("/me")).status_code == HTTP_OK # первый запрос пишет LOGIN — вне сравнения
one = await _rostov_entity(seeded, "ООО Один файл")
file, job = await _file(seeded, one, minutes=1)
await make_document(seeded, file=file, job=job)
many = await _rostov_entity(seeded, "ООО Много файлов")
for minute in range(4):
file, job = await _file(seeded, many, minutes=minute)
await make_document(seeded, file=file, job=job, account="60")
await make_document(seeded, file=file, job=job, account="62")
with _statements(seeded) as single:
assert (await client.get(_files_url(one.id))).status_code == HTTP_OK
with _statements(seeded) as multiple:
response = await client.get(_files_url(many.id))
assert response.status_code == HTTP_OK
assert len(response.json()["items"]) == 4 # noqa: PLR2004
assert len(multiple) == len(single), [statement[:80] for statement in multiple]
# --- карточка файла --------------------------------------------------------------------------------------------------
async def test_file_card_equals_list_item(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
entity = await _rostov_entity(seeded)
file, job = await _file(
seeded, entity, minutes=1, status=FileStatus.ERROR, metrics={"mapping_error": "в результате нет документов ОСВ"}
)
client = await auth_client(KA_RF)
card = await client.get(f"/files/{file.id}")
listed = (await client.get(_files_url(entity.id))).json()["items"][0]
assert card.status_code == HTTP_OK, card.text
assert card.json() == listed
assert card.json()["job"]["id"] == str(job.id)
assert card.json()["job"]["mapping_error"] == "в результате нет документов ОСВ"
assert card.json()["job"]["warnings"] == []
assert card.json()["job"]["mapping_warnings"] == []
async def test_file_card_without_current_job(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
entity = await _rostov_entity(seeded)
file, _ = await _file(seeded, entity, minutes=1, status=FileStatus.UPLOADED)
file.current_recognition_job_id = None
await seeded.flush()
client = await auth_client(KA_RF)
body = (await client.get(f"/files/{file.id}")).json()
assert body["job"] is None
assert body["documents"] == []
# --- видимость и права -----------------------------------------------------------------------------------------------
async def test_foreign_and_missing_file_are_same_404(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
entity = await _rostov_entity(seeded)
file, _ = await _file(seeded, entity, minutes=1)
client = await auth_client(CLIENT_MANAGER) # Краснодарский филиал
foreign = await client.get(f"/files/{file.id}")
missing = await client.get(f"/files/{uuid.uuid4()}")
assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND
assert foreign.json() == missing.json()
assert foreign.json()["detail"]["code"] == "NOT_FOUND"
async def test_files_of_foreign_entity_are_404(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
entity = await _rostov_entity(seeded)
await _file(seeded, entity, minutes=1)
client = await auth_client(CLIENT_MANAGER)
foreign = await client.get(_files_url(entity.id))
missing = await client.get(_files_url(uuid.uuid4()))
assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND
assert foreign.json() == missing.json()
async def test_reading_files_requires_document_view(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
entity = await _rostov_entity(seeded)
file, _ = await _file(seeded, entity, minutes=1)
client = await auth_client(CURATOR_GO) # ГО видит всё, но права DOCUMENT_VIEW нет
assert (await client.get(_files_url(entity.id))).status_code == HTTP_FORBIDDEN
assert (await client.get(f"/files/{file.id}")).status_code == HTTP_FORBIDDEN
async def test_unconfirmed_document_status_is_listed(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""Статус документа в карточке файла — как в БД (`NEEDS_CHECK` виден аналитику до подтверждения)."""
entity = await _rostov_entity(seeded)
file, job = await _file(seeded, entity, minutes=1)
await make_document(seeded, file=file, job=job, status=DocumentStatus.NEEDS_CHECK)
client = await auth_client(KA_RF)
body = (await client.get(f"/files/{file.id}")).json()
assert [document["status"] for document in body["documents"]] == ["NEEDS_CHECK"]