T7.0: двухсессионный тест маппинг ∥ перевод версии в DOCUMENTS_PROCESSING (ловит мутант
statuses={DOCUMENTS_PROCESSING}). T7.1 (R-10, design №43): ошибка перехода после загрузки логируется,
принятые файлы и 201 сохраняются. T7.3: лог пропуска без uploaded_by только при наличии NEEDS_CHECK.
T7.4: TransactionFactory импортируется из session_audit. T7.2/T7.5: дополнена строка design №39.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
768 lines
32 KiB
Python
768 lines
32 KiB
Python
"""Загрузка файлов организации (spec primary-files «Загрузка», «Дедуп»; audit-trail «События файлов и документов»).
|
||
|
||
Сценарии поверх фикстуры `app` (транзакция теста с SAVEPOINT): запрос — `begin_nested()`, каждый файл — ещё один
|
||
SAVEPOINT внутри него. Реальная ОСВ и параллельный дубль идут на `committed_*` (временная БД с зафиксированным seed,
|
||
сессия запроса как в проде): публикатор outbox и второй запрос работают в своих транзакциях и видят только
|
||
зафиксированное.
|
||
"""
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import json
|
||
import uuid
|
||
from collections.abc import Sequence
|
||
from datetime import timedelta
|
||
from typing import Any
|
||
|
||
import httpx
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
from loguru import logger
|
||
from sqlalchemy import func, select, update
|
||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||
|
||
from src.api.dependencies.directories import get_app_settings
|
||
from src.core.config import get_settings
|
||
from src.models.access import AppUser
|
||
from src.models.audit_ex import AuditEvent
|
||
from src.models.consolidation import ConsolidationStatusHistory, ConsolidationVersion
|
||
from src.models.directories import LegalEntity, RegionalOffice
|
||
from src.models.enums import (
|
||
AuditAction,
|
||
AuditSection,
|
||
ConsolidationStatus,
|
||
FileStatus,
|
||
FileType,
|
||
PeriodQuarter,
|
||
RecognitionPipeline,
|
||
RecognitionStatus,
|
||
S3BucketRole,
|
||
)
|
||
from src.models.files import RecognitionJob, S3Object, StoredFile
|
||
from src.services.recognition_exchange import publish_pending
|
||
from tests.conftest import AuthClientFactory, FakeStorage
|
||
from tests.factories import make_company_group, make_consolidation, make_legal_entity, make_lock
|
||
from tests.test_recognition_exchange import REQUEST_VALIDATOR, FakeNats
|
||
|
||
HTTP_CREATED = 201
|
||
HTTP_FORBIDDEN = 403
|
||
HTTP_NOT_FOUND = 404
|
||
HTTP_UNPROCESSABLE = 422
|
||
HTTP_UNAVAILABLE = 503
|
||
|
||
KA_RF = "ka_rf@armka.local" # Ростовский филиал
|
||
CLIENT_MANAGER = "client_manager@armka.local" # Краснодарский филиал, право DOCUMENT_EDIT есть
|
||
CURATOR_GO = "curator_go@armka.local" # права DOCUMENT_EDIT нет
|
||
ROSTOV = "Ростовский региональный филиал"
|
||
|
||
REAL_OSV = "ОСВ_66 за 2024.xls"
|
||
REAL_OSV_PATH = ("osv", "rb", "2024-FY", REAL_OSV)
|
||
|
||
BIFF8 = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
|
||
ZIP = b"PK\x03\x04"
|
||
PDF = b"%PDF-1.7\n"
|
||
XLS_TYPE = "application/vnd.ms-excel"
|
||
XLSX_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||
TINY_LIMIT = 10 # байт: «файл 51 МиБ» без 51 МиБ в памяти теста
|
||
|
||
Upload = tuple[str, bytes]
|
||
|
||
|
||
def _files_url(entity_id: uuid.UUID) -> str:
|
||
return f"/legal-entities/{entity_id}/files"
|
||
|
||
|
||
def _xls(tag: str) -> bytes:
|
||
"""Байты с сигнатурой BIFF8 и уникальным хвостом (разный sha256 — не дубль)."""
|
||
return BIFF8 + tag.encode()
|
||
|
||
|
||
async def _post(client: httpx.AsyncClient, entity_id: uuid.UUID, *uploads: Upload) -> httpx.Response:
|
||
files = [("files", (name, content, "application/octet-stream")) for name, content in uploads]
|
||
return await client.post(_files_url(entity_id), files=files)
|
||
|
||
|
||
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 _user_id(session: AsyncSession, email: str) -> uuid.UUID:
|
||
return (await session.execute(select(AppUser.id).where(AppUser.email == email))).scalar_one()
|
||
|
||
|
||
async def _counts(session: AsyncSession) -> dict[str, int]:
|
||
return {
|
||
model.__tablename__: (await session.execute(select(func.count()).select_from(model))).scalar_one()
|
||
for model in (S3Object, StoredFile, RecognitionJob)
|
||
} | {
|
||
"file_upload": (
|
||
await session.execute(
|
||
select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.FILE_UPLOAD)
|
||
)
|
||
).scalar_one()
|
||
}
|
||
|
||
|
||
async def _upload_events(session: AsyncSession) -> Sequence[AuditEvent]:
|
||
stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.FILE_UPLOAD).order_by(AuditEvent.occurred_at)
|
||
return (await session.execute(stmt)).scalars().all()
|
||
|
||
|
||
@pytest.fixture
|
||
def storage(app: FastAPI) -> FakeStorage:
|
||
fake = FakeStorage()
|
||
app.state.storage = fake
|
||
return fake
|
||
|
||
|
||
class FlakyStorage(FakeStorage):
|
||
"""S3, у которого падает запись объектов с заданным суффиксом ключа."""
|
||
|
||
def __init__(self, failing_suffix: str) -> None:
|
||
super().__init__()
|
||
self.failing_suffix = failing_suffix
|
||
|
||
async def put_object(self, obj_name: str, file_data: bytes) -> None:
|
||
if obj_name.endswith(self.failing_suffix):
|
||
raise ConnectionError("S3 недоступно")
|
||
await super().put_object(obj_name, file_data)
|
||
|
||
|
||
class CapturedPublish:
|
||
"""Делегат `FakeNats`: запоминает тела опубликованных запросов."""
|
||
|
||
def __init__(self) -> None:
|
||
self.payloads: list[dict[str, Any]] = []
|
||
|
||
async def publish(self, subject: str, payload: bytes, msg_id: str) -> None: # noqa: ARG002 - протокол издателя
|
||
self.payloads.append(json.loads(payload))
|
||
|
||
|
||
# --- «Реальная ОСВ принята и ушла в outbox» ----------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
@pytest.mark.fixtures
|
||
async def test_real_osv_is_accepted_and_published_from_outbox(
|
||
fixtures_dir: Any,
|
||
committed_client: AuthClientFactory,
|
||
committed_sessionmaker: async_sessionmaker[AsyncSession],
|
||
committed_storage: FakeStorage,
|
||
) -> None:
|
||
content = fixtures_dir.joinpath(*REAL_OSV_PATH).read_bytes()
|
||
async with committed_sessionmaker() as session, session.begin():
|
||
entity = await _rostov_entity(session, "АО Русский Бисквит")
|
||
user_id = await _user_id(session, KA_RF)
|
||
client = await committed_client(KA_RF)
|
||
|
||
response = await _post(client, entity.id, (REAL_OSV, content))
|
||
|
||
assert response.status_code == HTTP_CREATED, response.text
|
||
body = response.json()
|
||
assert body["rejected"] == []
|
||
(accepted,) = body["accepted"]
|
||
file_id, job_id = uuid.UUID(accepted["file_id"]), uuid.UUID(accepted["job_id"])
|
||
assert accepted["name"] == REAL_OSV
|
||
key = f"uploads/{file_id}/{job_id}/{REAL_OSV}"
|
||
assert committed_storage.objects == {key: content}
|
||
|
||
async with committed_sessionmaker() as session:
|
||
file = await session.get(StoredFile, file_id)
|
||
job = await session.get(RecognitionJob, job_id)
|
||
assert file is not None and job is not None
|
||
s3 = await session.get(S3Object, file.s3_object_id)
|
||
assert s3 is not None
|
||
sha256 = hashlib.sha256(content).hexdigest()
|
||
assert (file.status, file.file_type, file.content_hash, file.version_no) == (
|
||
FileStatus.UPLOADED,
|
||
FileType.XLS,
|
||
sha256,
|
||
1,
|
||
)
|
||
assert (file.legal_entity_id, file.uploaded_by, file.original_name) == (entity.id, user_id, REAL_OSV)
|
||
assert file.current_recognition_job_id == job.id
|
||
assert (job.status, job.pipeline, job.max_attempts, job.file_id) == (
|
||
RecognitionStatus.queued,
|
||
RecognitionPipeline.tabular,
|
||
3,
|
||
file_id,
|
||
)
|
||
assert (s3.bucket_role, s3.key, s3.sha256, s3.size_bytes) == (S3BucketRole.uploads, key, sha256, len(content))
|
||
|
||
capture = CapturedPublish()
|
||
report = await publish_pending(
|
||
committed_sessionmaker,
|
||
FakeNats(delegate=capture),
|
||
ack_timeout=timedelta(seconds=30),
|
||
max_republish=3,
|
||
)
|
||
|
||
assert report.published == 1
|
||
(request,) = capture.payloads
|
||
REQUEST_VALIDATOR.validate(request)
|
||
assert request["s3"] == {"bucket": committed_storage.bucket, "key": key}
|
||
assert (request["job_id"], request["file_id"], request["file_type"]) == (str(job_id), str(file_id), "XLS")
|
||
assert request["original_name"] == REAL_OSV
|
||
|
||
|
||
# --- поля записей, имя файла, аудит ------------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_hostile_name_gives_flat_key_and_full_records(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||
) -> None:
|
||
"""Review Focus 4: кириллица, пробелы, `..` и обратные слэши — ключ S3 без путей, имя без каталогов."""
|
||
entity = await _rostov_entity(seeded)
|
||
user_id = await _user_id(seeded, KA_RF)
|
||
client = await auth_client(KA_RF)
|
||
content = _xls("hostile")
|
||
|
||
response = await _post(client, entity.id, ("../..\\ОСВ 60 (2).xls", content))
|
||
|
||
assert response.status_code == HTTP_CREATED, response.text
|
||
(accepted,) = response.json()["accepted"]
|
||
file_id, job_id = uuid.UUID(accepted["file_id"]), uuid.UUID(accepted["job_id"])
|
||
assert accepted["name"] == "ОСВ 60 (2).xls"
|
||
key = f"uploads/{file_id}/{job_id}/ОСВ 60 (2).xls"
|
||
assert list(storage.objects) == [key]
|
||
assert ".." not in key
|
||
assert "\\" not in key
|
||
assert key.count("/") == 3 # noqa: PLR2004 - uploads/{file_id}/{job_id}/{имя}
|
||
|
||
file = await seeded.get(StoredFile, file_id)
|
||
job = await seeded.get(RecognitionJob, job_id)
|
||
assert file is not None and job is not None
|
||
s3 = await seeded.get(S3Object, file.s3_object_id)
|
||
assert s3 is not None
|
||
sha256 = hashlib.sha256(content).hexdigest()
|
||
assert file.original_name == "ОСВ 60 (2).xls"
|
||
assert (file.status, file.version_no, file.content_hash, file.size_bytes) == (
|
||
FileStatus.UPLOADED,
|
||
1,
|
||
sha256,
|
||
len(content),
|
||
)
|
||
assert file.uploaded_by == user_id
|
||
assert file.uploaded_at is not None
|
||
assert file.current_recognition_job_id == job_id
|
||
assert job.request_subject == "recognition.tabular.request"
|
||
assert job.schema_version == "1.0"
|
||
assert (job.status, job.attempt, job.max_attempts) == (RecognitionStatus.queued, 1, 3)
|
||
assert job.requested_at is not None
|
||
assert (s3.bucket_role, s3.bucket, s3.key, s3.content_type) == (
|
||
S3BucketRole.uploads,
|
||
storage.bucket,
|
||
key,
|
||
XLS_TYPE,
|
||
)
|
||
assert (s3.sha256, s3.size_bytes, s3.created_by) == (sha256, len(content), f"user:{user_id}")
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_image_file_goes_to_image_pipeline_with_one_attempt(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||
) -> None:
|
||
entity = await _rostov_entity(seeded)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await _post(client, entity.id, ("ББ 2024.pdf", PDF + b"scan"))
|
||
|
||
assert response.status_code == HTTP_CREATED, response.text
|
||
job = await seeded.get(RecognitionJob, uuid.UUID(response.json()["accepted"][0]["job_id"]))
|
||
assert job is not None
|
||
assert (job.pipeline, job.max_attempts, job.request_subject) == (
|
||
RecognitionPipeline.image,
|
||
1,
|
||
"recognition.image.request",
|
||
)
|
||
assert len(storage.objects) == 1
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_two_files_write_two_upload_events(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||
) -> None:
|
||
"""audit-trail «Загрузка двух файлов»: два `FILE_UPLOAD` с `object_id` каждого файла."""
|
||
entity = await _rostov_entity(seeded)
|
||
user_id = await _user_id(seeded, KA_RF)
|
||
client = await auth_client(KA_RF)
|
||
first, second = _xls("first"), ZIP + b"second"
|
||
|
||
response = await _post(client, entity.id, ("ОСВ 60.xls", first), ("ОСВ 62.xlsx", second))
|
||
|
||
assert response.status_code == HTTP_CREATED, response.text
|
||
accepted = response.json()["accepted"]
|
||
assert [item["name"] for item in accepted] == ["ОСВ 60.xls", "ОСВ 62.xlsx"]
|
||
events = await _upload_events(seeded)
|
||
assert len(events) == len(accepted)
|
||
by_object = {event.object_id: event for event in events}
|
||
for item, content, file_type in ((accepted[0], first, "XLS"), (accepted[1], second, "XLSX")):
|
||
event = by_object[uuid.UUID(item["file_id"])]
|
||
assert (event.section, event.object_type, event.user_id) == (
|
||
AuditSection.PRIMARY_DOCUMENTS,
|
||
"stored_file",
|
||
user_id,
|
||
)
|
||
assert event.after_value == {
|
||
"original_name": item["name"],
|
||
"content_hash": hashlib.sha256(content).hexdigest(),
|
||
"job_id": item["job_id"],
|
||
"file_type": file_type,
|
||
}
|
||
assert len(storage.objects) == len(accepted)
|
||
|
||
|
||
# --- «Смесь принятых и отклонённых», «Расширение не совпадает с содержимым» ---------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_mixed_request_accepts_valid_and_rejects_others(
|
||
app: FastAPI, auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||
) -> None:
|
||
app.dependency_overrides[get_app_settings] = lambda: get_settings().model_copy(
|
||
update={"UPLOAD_MAX_BYTES": TINY_LIMIT}
|
||
)
|
||
entity = await _rostov_entity(seeded)
|
||
client = await auth_client(KA_RF)
|
||
before = await _counts(seeded)
|
||
xlsx = ZIP + b"ok" # 6 байт — в пределах лимита
|
||
big = BIFF8 + b"overflow" # больше лимита — как файл 51 МиБ при 50 МиБ
|
||
|
||
response = await _post(client, entity.id, ("osv.xlsx", xlsx), ("report.docx", ZIP + b"d"), ("big.xls", big))
|
||
|
||
assert response.status_code == HTTP_CREATED, response.text
|
||
body = response.json()
|
||
assert [item["name"] for item in body["accepted"]] == ["osv.xlsx"]
|
||
assert {item["name"]: item["code"] for item in body["rejected"]} == {
|
||
"report.docx": "UNSUPPORTED_FILE",
|
||
"big.xls": "FILE_TOO_LARGE",
|
||
}
|
||
assert all(item["message"] and item["file_id"] is None for item in body["rejected"])
|
||
after = await _counts(seeded)
|
||
assert {name: after[name] - before[name] for name in after} == {
|
||
"s3_object": 1,
|
||
"stored_file": 1,
|
||
"recognition_job": 1,
|
||
"file_upload": 1,
|
||
}
|
||
(key,) = storage.objects
|
||
assert key.endswith("/osv.xlsx")
|
||
s3 = (await seeded.execute(select(S3Object).where(S3Object.key == key))).scalar_one()
|
||
assert s3.content_type == XLSX_TYPE
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_signature_mismatch_is_rejected_with_422(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||
) -> None:
|
||
entity = await _rostov_entity(seeded)
|
||
client = await auth_client(KA_RF)
|
||
before = await _counts(seeded)
|
||
|
||
response = await _post(client, entity.id, ("osv.xlsx", _xls("biff")))
|
||
|
||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||
assert response.json()["accepted"] == []
|
||
(rejected,) = response.json()["rejected"]
|
||
assert (rejected["name"], rejected["code"]) == ("osv.xlsx", "UNSUPPORTED_FILE")
|
||
assert await _counts(seeded) == before
|
||
assert storage.objects == {}
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_empty_filename_is_unsupported(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||
) -> None:
|
||
"""Review Focus 4: имя `..` (после очистки — пустое) — `UNSUPPORTED_FILE`, а не файл без имени."""
|
||
entity = await _rostov_entity(seeded)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await _post(client, entity.id, ("..", _xls("noname")))
|
||
|
||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||
(rejected,) = response.json()["rejected"]
|
||
assert (rejected["name"], rejected["code"]) == ("", "UNSUPPORTED_FILE")
|
||
assert storage.objects == {}
|
||
|
||
|
||
# --- «Чужое ЮЛ», права, S3 --------------------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_foreign_entity_is_404_like_missing(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||
) -> None:
|
||
entity = await _rostov_entity(seeded)
|
||
client = await auth_client(CLIENT_MANAGER)
|
||
before = await _counts(seeded)
|
||
|
||
foreign = await _post(client, entity.id, ("osv.xls", _xls("foreign")))
|
||
missing = await _post(client, uuid.uuid4(), ("osv.xls", _xls("missing")))
|
||
|
||
assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND, foreign.text
|
||
assert foreign.json() == missing.json()
|
||
assert foreign.json()["detail"]["code"] == "NOT_FOUND"
|
||
assert await _counts(seeded) == before
|
||
assert storage.objects == {}
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_upload_requires_document_edit(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||
) -> None:
|
||
entity = await _rostov_entity(seeded)
|
||
client = await auth_client(CURATOR_GO)
|
||
|
||
response = await _post(client, entity.id, ("osv.xls", _xls("curator")))
|
||
|
||
assert response.status_code == HTTP_FORBIDDEN, response.text
|
||
assert "DOCUMENT_EDIT" in response.json()["detail"]
|
||
assert storage.objects == {}
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_without_storage_is_503(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||
entity = await _rostov_entity(seeded) # `app.state.storage` фикстуры `app` — None
|
||
client = await auth_client(KA_RF)
|
||
before = await _counts(seeded)
|
||
|
||
response = await _post(client, entity.id, ("osv.xls", _xls("nostorage")))
|
||
|
||
assert response.status_code == HTTP_UNAVAILABLE, response.text
|
||
assert response.json()["detail"]["code"] == "STORAGE_UNAVAILABLE"
|
||
assert await _counts(seeded) == before
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_storage_failure_rejects_only_that_file(
|
||
app: FastAPI, auth_client: AuthClientFactory, seeded: AsyncSession
|
||
) -> None:
|
||
"""Отказ S3 на одном файле откатывает только его SAVEPOINT: принятый файл и его записи остаются."""
|
||
storage = FlakyStorage(failing_suffix="/broken.xls")
|
||
app.state.storage = storage
|
||
entity = await _rostov_entity(seeded)
|
||
client = await auth_client(KA_RF)
|
||
before = await _counts(seeded)
|
||
|
||
response = await _post(client, entity.id, ("good.xls", _xls("good")), ("broken.xls", _xls("broken")))
|
||
|
||
assert response.status_code == HTTP_CREATED, response.text
|
||
body = response.json()
|
||
assert [item["name"] for item in body["accepted"]] == ["good.xls"]
|
||
(rejected,) = body["rejected"]
|
||
assert (rejected["name"], rejected["code"]) == ("broken.xls", "STORAGE_UNAVAILABLE")
|
||
after = await _counts(seeded)
|
||
assert {name: after[name] - before[name] for name in after} == {
|
||
"s3_object": 1,
|
||
"stored_file": 1,
|
||
"recognition_job": 1,
|
||
"file_upload": 1,
|
||
}
|
||
assert [key.rsplit("/", 1)[1] for key in storage.objects] == ["good.xls"]
|
||
|
||
|
||
# --- «Дедуп по содержимому» --------------------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_duplicate_in_same_entity_is_rejected_other_entity_accepts(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||
) -> None:
|
||
entity = await _rostov_entity(seeded)
|
||
other = await _rostov_entity(seeded, "ООО Другое")
|
||
client = await auth_client(KA_RF)
|
||
content = _xls("dup")
|
||
first = await _post(client, entity.id, ("ОСВ 60.xls", content))
|
||
assert first.status_code == HTTP_CREATED, first.text
|
||
first_id = first.json()["accepted"][0]["file_id"]
|
||
before = await _counts(seeded)
|
||
|
||
again = await _post(client, entity.id, ("ОСВ 60 копия.xls", content))
|
||
|
||
assert again.status_code == HTTP_UNPROCESSABLE, again.text
|
||
(rejected,) = again.json()["rejected"]
|
||
assert (rejected["code"], rejected["file_id"], rejected["name"]) == ("DUPLICATE_FILE", first_id, "ОСВ 60 копия.xls")
|
||
assert await _counts(seeded) == before
|
||
assert len(storage.objects) == 1
|
||
|
||
elsewhere = await _post(client, other.id, ("ОСВ 60.xls", content))
|
||
assert elsewhere.status_code == HTTP_CREATED, elsewhere.text
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_superseded_file_does_not_block_same_content(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||
) -> None:
|
||
entity = await _rostov_entity(seeded)
|
||
client = await auth_client(KA_RF)
|
||
content = _xls("superseded")
|
||
first = await _post(client, entity.id, ("ОСВ 60.xls", content))
|
||
first_id = uuid.UUID(first.json()["accepted"][0]["file_id"])
|
||
await seeded.execute(update(StoredFile).where(StoredFile.id == first_id).values(status=FileStatus.SUPERSEDED))
|
||
|
||
again = await _post(client, entity.id, ("ОСВ 60.xls", content))
|
||
|
||
assert again.status_code == HTTP_CREATED, again.text
|
||
assert again.json()["accepted"][0]["file_id"] != str(first_id)
|
||
assert len(storage.objects) == 2 # noqa: PLR2004 - старый и новый объект
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_concurrent_duplicates_accept_only_one(
|
||
committed_client: AuthClientFactory,
|
||
committed_sessionmaker: async_sessionmaker[AsyncSession],
|
||
committed_storage: FakeStorage,
|
||
) -> None:
|
||
"""Двойной клик: два запроса с одним содержимым одновременно — принят один, второй `DUPLICATE_FILE`."""
|
||
async with committed_sessionmaker() as session, session.begin():
|
||
entity = await _rostov_entity(session)
|
||
first_client, second_client = await committed_client(KA_RF), await committed_client(KA_RF)
|
||
content = _xls("race")
|
||
|
||
responses = await asyncio.gather(
|
||
_post(first_client, entity.id, ("ОСВ 60.xls", content)),
|
||
_post(second_client, entity.id, ("ОСВ 60.xls", content)),
|
||
)
|
||
|
||
assert sorted(response.status_code for response in responses) == [HTTP_CREATED, HTTP_UNPROCESSABLE]
|
||
accepted = next(r for r in responses if r.status_code == HTTP_CREATED).json()["accepted"][0]
|
||
rejected = next(r for r in responses if r.status_code == HTTP_UNPROCESSABLE).json()["rejected"][0]
|
||
assert (rejected["code"], rejected["file_id"]) == ("DUPLICATE_FILE", accepted["file_id"])
|
||
async with committed_sessionmaker() as session:
|
||
files = (await session.execute(select(func.count()).select_from(StoredFile))).scalar_one()
|
||
assert files == 1
|
||
assert len(committed_storage.objects) == 1
|
||
|
||
|
||
# --- загрузка в контексте версии консолидации (spec primary-files «Загрузка в контексте версии») ---------------
|
||
|
||
HTTP_CONFLICT = 409
|
||
KRASNODAR = "Краснодарский региональный филиал"
|
||
|
||
|
||
async def _group_with_members(
|
||
session: AsyncSession, *, office_name: str = ROSTOV, members: int = 2
|
||
) -> tuple[Any, list[LegalEntity]]:
|
||
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == office_name))).scalar_one()
|
||
group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office)
|
||
entities = [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(members)]
|
||
return group, entities
|
||
|
||
|
||
async def _version(
|
||
session: AsyncSession,
|
||
*,
|
||
status: ConsolidationStatus = ConsolidationStatus.INITIATED,
|
||
office_name: str = ROSTOV,
|
||
) -> tuple[ConsolidationVersion, list[LegalEntity], Any]:
|
||
"""Версия 2024 Q3 ГК филиала `office_name` с двумя членами; третье ЮЛ той же ГК — вне периметра."""
|
||
group, entities = await _group_with_members(session, office_name=office_name, members=3)
|
||
initiator = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one()
|
||
_, version = await make_consolidation(
|
||
session,
|
||
group=group,
|
||
year=2024,
|
||
quarter=PeriodQuarter.Q3,
|
||
members=entities[:2],
|
||
initiator=initiator,
|
||
status=status,
|
||
)
|
||
return version, entities, initiator
|
||
|
||
|
||
async def _version_state(session: AsyncSession, version_id: uuid.UUID) -> ConsolidationStatus:
|
||
stmt = select(ConsolidationVersion.status).where(ConsolidationVersion.id == version_id)
|
||
return (await session.execute(stmt.execution_options(populate_existing=True))).scalar_one()
|
||
|
||
|
||
async def _post_in_version(
|
||
client: httpx.AsyncClient, entity_id: uuid.UUID, version_id: uuid.UUID, *uploads: Upload
|
||
) -> httpx.Response:
|
||
files = [("files", (name, content, "application/octet-stream")) for name, content in uploads]
|
||
return await client.post(_files_url(entity_id), params={"consolidation_version_id": str(version_id)}, files=files)
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_upload_in_version_moves_initiated_to_documents_processing(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||
) -> None:
|
||
version, (member, *_), _ = await _version(seeded)
|
||
user_id = await _user_id(seeded, KA_RF)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("in-version")))
|
||
|
||
assert response.status_code == HTTP_CREATED, response.text
|
||
assert len(response.json()["accepted"]) == 1
|
||
assert len(storage.objects) == 1
|
||
assert await _version_state(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING
|
||
history = (
|
||
(
|
||
await seeded.execute(
|
||
select(ConsolidationStatusHistory)
|
||
.where(ConsolidationStatusHistory.version_id == version.id)
|
||
.order_by(ConsolidationStatusHistory.changed_at.desc())
|
||
)
|
||
)
|
||
.scalars()
|
||
.first()
|
||
)
|
||
assert history is not None
|
||
assert (history.from_status, history.to_status) == (
|
||
ConsolidationStatus.INITIATED,
|
||
ConsolidationStatus.DOCUMENTS_PROCESSING,
|
||
)
|
||
assert history.changed_by == user_id
|
||
[event] = (
|
||
(await seeded.execute(select(AuditEvent).where(AuditEvent.action == AuditAction.CONSOLIDATION_STATUS_CHANGE)))
|
||
.scalars()
|
||
.all()
|
||
)
|
||
assert event.user_id == user_id
|
||
assert event.consolidation_version_id == version.id
|
||
|
||
|
||
@pytest.mark.integration
|
||
@pytest.mark.parametrize("status", [ConsolidationStatus.DOCUMENTS_PROCESSING, ConsolidationStatus.DATA_CORRECTION])
|
||
async def test_upload_in_version_keeps_other_in_work_status(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage, status: ConsolidationStatus
|
||
) -> None:
|
||
version, (member, *_), _ = await _version(seeded, status=status)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls(f"keep-{status.value}")))
|
||
|
||
assert response.status_code == HTTP_CREATED, response.text
|
||
assert await _version_state(seeded, version.id) is status
|
||
assert len(storage.objects) == 1
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_upload_with_all_files_rejected_does_not_move_version(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||
) -> None:
|
||
version, (member, *_), _ = await _version(seeded)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await _post_in_version(client, member.id, version.id, ("notes.docx", ZIP + b"x"))
|
||
|
||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||
assert response.json()["accepted"] == []
|
||
assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED
|
||
assert storage.objects == {}
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_upload_for_entity_outside_perimeter_is_422_and_writes_nothing(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||
) -> None:
|
||
version, entities, _ = await _version(seeded)
|
||
outsider = entities[2]
|
||
client = await auth_client(KA_RF)
|
||
before = await _counts(seeded)
|
||
|
||
response = await _post_in_version(client, outsider.id, version.id, ("osv.xls", _xls("outsider")))
|
||
|
||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||
assert response.json()["detail"]["code"] == "ENTITY_NOT_IN_PERIMETER"
|
||
assert await _counts(seeded) == before
|
||
assert storage.objects == {}
|
||
assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_upload_in_cancelled_version_is_409(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||
) -> None:
|
||
version, (member, *_), _ = await _version(seeded, status=ConsolidationStatus.CANCELLED)
|
||
client = await auth_client(KA_RF)
|
||
before = await _counts(seeded)
|
||
|
||
response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("cancelled")))
|
||
|
||
assert response.status_code == HTTP_CONFLICT, response.text
|
||
assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE"
|
||
assert await _counts(seeded) == before
|
||
assert storage.objects == {}
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_upload_in_foreign_version_is_404_like_missing(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||
) -> None:
|
||
foreign, _, _ = await _version(seeded, office_name=KRASNODAR)
|
||
own_entity = await _rostov_entity(seeded, "ООО Своё")
|
||
client = await auth_client(KA_RF)
|
||
before = await _counts(seeded)
|
||
|
||
foreign_response = await _post_in_version(client, own_entity.id, foreign.id, ("osv.xls", _xls("foreign-version")))
|
||
missing_response = await _post_in_version(client, own_entity.id, uuid.uuid4(), ("osv.xls", _xls("missing-version")))
|
||
|
||
assert foreign_response.status_code == missing_response.status_code == HTTP_NOT_FOUND, foreign_response.text
|
||
assert foreign_response.json() == missing_response.json()
|
||
assert foreign_response.json()["detail"]["code"] == "NOT_FOUND"
|
||
assert await _counts(seeded) == before
|
||
assert storage.objects == {}
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_upload_does_not_require_version_lock(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||
) -> None:
|
||
"""Блокировка версии для загрузки не нужна (spec): версию держит другой пользователь, загрузка проходит."""
|
||
version, (member, *_), _ = await _version(seeded)
|
||
holder = (await seeded.execute(select(AppUser).where(AppUser.email == CLIENT_MANAGER))).scalar_one()
|
||
await make_lock(seeded, version=version, user=holder)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("under-lock")))
|
||
|
||
assert response.status_code == HTTP_CREATED, response.text
|
||
assert await _version_state(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING
|
||
assert len(storage.objects) == 1
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_upload_without_version_does_not_touch_versions(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||
) -> None:
|
||
version, (member, *_), _ = await _version(seeded)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await _post(client, member.id, ("osv.xls", _xls("no-version")))
|
||
|
||
assert response.status_code == HTTP_CREATED, response.text
|
||
assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED
|
||
assert len(storage.objects) == 1
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_upload_keeps_accepted_files_when_version_transition_fails(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""R-10 (design №43): приём файлов важнее побочного статуса. Переход версии падает исключением — ответ 201, принятый
|
||
файл сохранён, версия остаётся `INITIATED`, ошибка — в логе."""
|
||
|
||
async def broken_transition(*args: Any, **kwargs: Any) -> None:
|
||
raise RuntimeError("переход недоступен")
|
||
|
||
monkeypatch.setattr("src.services.consolidation_status.transition", broken_transition)
|
||
version, (member, *_), _ = await _version(seeded)
|
||
client = await auth_client(KA_RF)
|
||
messages: list[str] = []
|
||
sink = logger.add(lambda message: messages.append(str(message)), level="ERROR")
|
||
try:
|
||
response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("transition-fails")))
|
||
finally:
|
||
logger.remove(sink)
|
||
|
||
assert response.status_code == HTTP_CREATED, response.text
|
||
[accepted] = response.json()["accepted"]
|
||
assert (await seeded.get(StoredFile, uuid.UUID(accepted["file_id"]))) is not None
|
||
assert len(storage.objects) == 1
|
||
assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED
|
||
assert any(str(version.id) in message and "version_transition_error" in message for message in messages)
|