"""Загрузка файлов организации (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 NATSSettings, get_settings from src.models.access import AppUser from src.models.audit_ex import AuditEvent from src.models.consolidation import Consolidation, 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, make_version 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_job_request_subject_comes_from_environment_settings( app: FastAPI, auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage ) -> None: """Review Focus 4: `recognition_job.request_subject` берётся из настроек окружения, а не из констант контракта.""" base = get_settings() nats = NATSSettings( **{ **base.OPENBAO.NATS.model_dump(), "NATS_TABULAR_REQUEST_SUBJECT": "test.files.tabular_uploaded", } ) app.dependency_overrides[get_app_settings] = lambda: base.model_copy( update={"OPENBAO": base.OPENBAO.model_copy(update={"NATS": nats})} ) entity = await _rostov_entity(seeded) client = await auth_client(KA_RF) response = await _post(client, entity.id, ("osv.xls", _xls("subject"))) 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.request_subject == "test.files.tabular_uploaded" @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 == "files.tabular_uploaded" 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, "files.img_uploaded", ) 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, "consolidation_id": None, } 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_consolidation( client: httpx.AsyncClient, consolidation_id: uuid.UUID, entity_id: uuid.UUID, *uploads: Upload ) -> httpx.Response: files = [("files", (name, content, "application/octet-stream")) for name, content in uploads] return await client.post(f"/consolidations/{consolidation_id}/legal-entities/{entity_id}/files", files=files) @pytest.mark.integration async def test_upload_in_consolidation_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_consolidation( client, version.consolidation_id, member.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 file = await seeded.get(StoredFile, uuid.UUID(response.json()["accepted"][0]["file_id"])) assert file is not None assert file.consolidation_id == version.consolidation_id [upload_event] = await _upload_events(seeded) assert upload_event.after_value["consolidation_id"] == str(version.consolidation_id) 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_consolidation_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_consolidation( client, version.consolidation_id, member.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_consolidation(client, version.consolidation_id, member.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_consolidation( client, version.consolidation_id, outsider.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_without_in_work_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_consolidation(client, version.consolidation_id, member.id, ("osv.xls", _xls("cancelled"))) assert response.status_code == HTTP_CONFLICT, response.text assert response.json()["detail"]["code"] == "CONSOLIDATION_NOT_EDITABLE" assert await _counts(seeded) == before assert storage.objects == {} @pytest.mark.integration async def test_upload_in_foreign_consolidation_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_consolidation( client, foreign.consolidation_id, own_entity.id, ("osv.xls", _xls("foreign-version")) ) missing_response = await _post_in_consolidation( client, uuid.uuid4(), own_entity.id, ("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_consolidation( client, version.consolidation_id, member.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_consolidation_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"))) ignored = await client.post( _files_url(member.id), params={"consolidation_version_id": str(version.id)}, files=[("files", ("osv.xls", _xls("ignored-query"), "application/octet-stream"))], ) assert response.status_code == HTTP_CREATED, response.text assert ignored.status_code == HTTP_CREATED, ignored.text bodies = (response.json(), ignored.json()) assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED assert len(storage.objects) == len(bodies) for body in bodies: file = await seeded.get(StoredFile, uuid.UUID(body["accepted"][0]["file_id"])) assert file is not None assert file.consolidation_id is None @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_consolidation( client, version.consolidation_id, member.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.consolidation_id) in message and "version_transition_error" in message for message in messages ) @pytest.mark.integration async def test_upload_moves_only_versions_with_entity( auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage ) -> None: """Две версии консолидации в INITIATED: ЮЛ в периметре только второй — переходит только она (D-4).""" version, entities, initiator = await _version(seeded) consolidation = await seeded.get(Consolidation, version.consolidation_id) assert consolidation is not None other = await make_version( seeded, consolidation=consolidation, version_no=2, members=[entities[2]], initiator=initiator ) client = await auth_client(KA_RF) response = await _post_in_consolidation(client, consolidation.id, entities[2].id, ("osv.xls", _xls("only-v2"))) assert response.status_code == HTTP_CREATED, response.text assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED assert await _version_state(seeded, other.id) is ConsolidationStatus.DOCUMENTS_PROCESSING