POST/GET /documents/{id}/comments, счётчик комментариев в карточке файла
и документа одним запросом на пачку документов.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
283 lines
12 KiB
Python
283 lines
12 KiB
Python
"""Чтение файлов организации и карточки файла (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",
|
||
"comments_count": 0,
|
||
}
|
||
]
|
||
|
||
|
||
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"]
|