From b32383eff7d0700c5b9e04d62fa36d4a877320c8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 17:13:41 +0400 Subject: [PATCH] =?UTF-8?q?test:=20=D1=81=D0=B8=D0=BD=D1=82=D0=B5=D1=82?= =?UTF-8?q?=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B5=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=8B=20=D1=83=D1=82=D0=BE=D1=87=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B8=20=D0=9E=D0=A1=D0=92=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=BD=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D0=B0=20(=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E=20#161,=20AURORA-1442)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PATCH osv-header без fixtures: порядок ошибок, no-op, original_values, сброс подтверждения и отвязка, аудит, DUPLICATE_ACCOUNT, неизменность osv_entry. Пакет: смешанный (OCR и исчерпанный лимит в skipped), NO_ACK на пределе. Co-Authored-By: Claude Opus 5.5 --- api/tests/test_api_file_retry.py | 55 ++++ api/tests/test_api_osv_header_synthetic.py | 280 +++++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 api/tests/test_api_osv_header_synthetic.py diff --git a/api/tests/test_api_file_retry.py b/api/tests/test_api_file_retry.py index 5d4c423..dcecf1b 100644 --- a/api/tests/test_api_file_retry.py +++ b/api/tests/test_api_file_retry.py @@ -288,3 +288,58 @@ async def test_batch_rechecks_version_after_waiting_for_job( assert result == "VERSION_NOT_EDITABLE" else: assert result.accepted == [] and result.skipped == [] + + +async def test_batch_mixed_package_skips_ocr_and_exhausted_files( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Смешанный пакет: подходящий файл принят, OCR в ERROR и исчерпанный лимит попыток — в skipped с кодами.""" + version, inside, _, _ = await _version(seeded, members=1, locked_by=None) + good, good_job = await make_stored_file( + seeded, entity=inside[0], status=FileStatus.ERROR, job_status=RecognitionStatus.timeout + ) + good_job.error_code, good_job.max_attempts = "TIMEOUT", 3 + ocr, ocr_job = await make_stored_file( + seeded, entity=inside[0], status=FileStatus.ERROR, job_status=RecognitionStatus.timeout + ) + ocr_job.pipeline, ocr_job.error_code, ocr_job.max_attempts = RecognitionPipeline.image, "TIMEOUT", 3 + spent, spent_job = await make_stored_file( + seeded, entity=inside[0], status=FileStatus.ERROR, job_status=RecognitionStatus.timeout + ) + spent_job.error_code, spent_job.max_attempts, spent_job.attempt = "TIMEOUT", 3, 3 + for item in (good, ocr, spent): + item.consolidation_id = version.consolidation_id + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.post(f"/consolidations/versions/{version.id}/files/retry") + assert response.status_code == HTTPStatus.ACCEPTED, response.text + body = response.json() + assert [row["file_id"] for row in body["accepted"]] == [str(good.id)] + skipped = {row["file_id"]: row["code"] for row in body["skipped"]} + assert skipped == {str(ocr.id): "FILE_NOT_RETRYABLE", str(spent.id): "MAX_ATTEMPTS_REACHED"} + for skipped_file, skipped_job in ((ocr, ocr_job), (spent, spent_job)): + await seeded.refresh(skipped_file) + assert skipped_file.current_recognition_job_id == skipped_job.id and skipped_file.status == FileStatus.ERROR + events = await seeded.scalars( + select(AuditEvent).where(AuditEvent.object_id == skipped_file.id, AuditEvent.action == "FILE_REPROCESS") + ) + assert list(events.all()) == [] + + +async def test_batch_no_ack_at_attempt_limit_is_max_attempts_reached( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """NO_ACK (dlq) на пределе попыток в пакете даёт MAX_ATTEMPTS_REACHED, новое задание не создаётся.""" + version, inside, _, _ = await _version(seeded, members=1, locked_by=None) + file, job = await make_stored_file( + seeded, entity=inside[0], status=FileStatus.ERROR, job_status=RecognitionStatus.dlq + ) + job.error_code, job.max_attempts, job.attempt = "NO_ACK", 3, 3 + file.consolidation_id = version.consolidation_id + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.post(f"/consolidations/versions/{version.id}/files/retry") + assert response.status_code == HTTPStatus.ACCEPTED, response.text + assert response.json() == {"accepted": [], "skipped": [{"file_id": str(file.id), "code": "MAX_ATTEMPTS_REACHED"}]} + jobs = await seeded.scalars(select(RecognitionJob).where(RecognitionJob.file_id == file.id)) + assert [item.id for item in jobs.all()] == [job.id] diff --git a/api/tests/test_api_osv_header_synthetic.py b/api/tests/test_api_osv_header_synthetic.py new file mode 100644 index 0000000..67e704b --- /dev/null +++ b/api/tests/test_api_osv_header_synthetic.py @@ -0,0 +1,280 @@ +"""Правка шапки ОСВ на синтетических документах из фабрик (без golden-файлов), §9.5 п.12–13.""" + +import datetime +from http import HTTPStatus + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.audit_ex import AuditEvent +from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationVersion +from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus +from src.models.files import Document, StoredFile +from src.models.osv import OsvEntry +from tests.conftest import AuthClientFactory +from tests.factories import make_document, make_osv_entry, make_stored_file, make_version +from tests.test_api_consolidation_members import _user, _version +from tests.test_api_files_read import KA_RF, _rostov_entity + +pytestmark = pytest.mark.integration + +ORIGINAL = {"period_year": 2025, "period_quarter": "Q1", "is_cumulative": True, "osv_account_code": "60"} + + +async def _events(session: AsyncSession, document: Document) -> list[AuditEvent]: + rows = await session.scalars( + select(AuditEvent).where(AuditEvent.object_id == document.id, AuditEvent.action == "RECOGNIZED_DATA_EDIT") + ) + return list(rows.all()) + + +async def _bound(session: AsyncSession, document: Document) -> set[object]: + rows = await session.scalars( + select(ConsolidationDocument.consolidation_version_id).where(ConsolidationDocument.document_id == document.id) + ) + return set(rows.all()) + + +async def _simple(session: AsyncSession, status: DocumentStatus = DocumentStatus.RECOGNIZED) -> Document: + entity = await _rostov_entity(session) + file, job = await make_stored_file(session, entity=entity, status=FileStatus.PROCESSED) + return await make_document(session, file=file, job=job, status=status) + + +async def test_conflict_order_type_then_superseded(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """DOCUMENT_NOT_OSV раньше FILE_SUPERSEDED, а тот раньше проверок периода и счёта.""" + entity = await _rostov_entity(seeded) + file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.SUPERSEDED) + balance = await make_document(seeded, file=file, job=job) + balance.document_type = DocumentType.BALANCE + balance.osv_account_code = None + osv = await make_document(seeded, file=file, job=job, account="62") + await seeded.flush() + client = await auth_client(KA_RF) + bad_body = {"period_quarter": "Q9", "osv_account_code": " "} + response = await client.patch(f"/documents/{balance.id}/osv-header", json=bad_body) + assert response.status_code == HTTPStatus.CONFLICT + assert response.json()["detail"]["code"] == "DOCUMENT_NOT_OSV" + response = await client.patch(f"/documents/{osv.id}/osv-header", json=bad_body) + assert response.status_code == HTTPStatus.CONFLICT + assert response.json()["detail"]["code"] == "FILE_SUPERSEDED" + await seeded.refresh(osv) + assert osv.osv_account_code == "62" and osv.original_values is None + + +async def test_conflict_order_period_account_duplicate_version( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Порядок: INVALID_PERIOD, INVALID_ACCOUNT_CODE, DUPLICATE_ACCOUNT, VERSION_NOT_EDITABLE; запись после всех.""" + version, members, _, _ = await _version(seeded, members=1, locked_by=None) + file, job = await make_stored_file(seeded, entity=members[0], status=FileStatus.PROCESSED) + doc = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED) + twin = await make_document(seeded, file=file, job=job, account="62.01") + consolidation = await seeded.get(Consolidation, version.consolidation_id) + assert consolidation is not None + verified = await make_version( + seeded, + consolidation=consolidation, + version_no=2, + members=members, + initiator=await _user(seeded, KA_RF), + status=ConsolidationStatus.VERIFIED, + ) + seeded.add_all( + [ConsolidationDocument(consolidation_version_id=v.id, document_id=doc.id) for v in (version, verified)] + ) + await seeded.flush() + client = await auth_client(KA_RF) + url = f"/documents/{doc.id}/osv-header" + + # период проверяется раньше счёта и коллизии + response = await client.patch( + url, json={"period_quarter": "Q1", "is_cumulative": False, "osv_account_code": "62.1"} + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "INVALID_PERIOD" + # пустой счёт — раньше коллизии и версий + response = await client.patch(url, json={"osv_account_code": " "}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "INVALID_ACCOUNT_CODE" + # коллизия по нормализованному счёту раньше VERSION_NOT_EDITABLE + response = await client.patch(url, json={"osv_account_code": "62.1"}) + assert response.status_code == HTTPStatus.CONFLICT + assert response.json()["detail"]["code"] == "DUPLICATE_ACCOUNT" + assert response.json()["detail"]["document_id"] == str(twin.id) + # без коллизии — запрещающая версия + response = await client.patch(url, json={"osv_account_code": "70"}) + assert response.status_code == HTTPStatus.CONFLICT + assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE" + assert response.json()["detail"]["version_ids"] == [str(verified.id)] + + await seeded.refresh(doc) + assert doc.osv_account_code == "60" and doc.status is DocumentStatus.CONFIRMED + assert doc.original_values is None and doc.is_modified is False + assert await _bound(seeded, doc) == {version.id, verified.id} + assert await _events(seeded, doc) == [] + + +async def test_noop_patch_changes_nothing(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Совпадающая шапка: 200, без аудита, original_values, сброса подтверждения и modified_by/at.""" + version, members, _, _ = await _version(seeded, members=1, locked_by=None) + file, job = await make_stored_file(seeded, entity=members[0], status=FileStatus.PROCESSED) + doc = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED) + seeded.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=doc.id)) + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{doc.id}/osv-header", json=ORIGINAL) + assert response.status_code == HTTPStatus.OK, response.text + body = response.json() + assert body["status"] == "CONFIRMED" and body["is_modified"] is False and body["original_values"] is None + await seeded.refresh(doc) + assert doc.modified_by is None and doc.modified_at is None + assert await _bound(seeded, doc) == {version.id} + assert await _events(seeded, doc) == [] + + +async def test_original_saved_once_and_is_modified_resets(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Снимок original_values — при первой правке и больше не меняется; is_modified зависит от отличия от снимка.""" + doc = await _simple(seeded) + client = await auth_client(KA_RF) + url = f"/documents/{doc.id}/osv-header" + user = await _user(seeded, KA_RF) + + body = (await client.patch(url, json={"osv_account_code": "62"})).json() + assert body["osv_account_code"] == "62" and body["is_modified"] is True + assert body["original_values"] == ORIGINAL + body = (await client.patch(url, json={"osv_account_code": "76"})).json() + assert body["osv_account_code"] == "76" and body["is_modified"] is True + assert body["original_values"] == ORIGINAL + + body = (await client.patch(url, json={"period_year": 2024, "period_quarter": "Q3", "is_cumulative": False})).json() + assert (body["period_year"], body["period_quarter"], body["is_cumulative"]) == (2024, "Q3", False) + assert body["original_values"] == ORIGINAL + + # возврат всех четырёх полей к снимку + response = await client.patch(url, json=ORIGINAL) + assert response.status_code == HTTPStatus.OK, response.text + body = response.json() + assert body["is_modified"] is False and body["original_values"] == ORIGINAL + await seeded.refresh(doc) + assert doc.modified_by == user.id and doc.modified_at is not None + # по одному событию на фактическую правку (4), старые не переписываются + events = await _events(seeded, doc) + assert len(events) == len(("62", "76", "период", "возврат")) + afters = [e.after_value or {} for e in events] + assert sorted(a["osv_account_code"] for a in afters if "osv_account_code" in a) == ["60", "62", "76"] + + # частичный возврат: только счёт — снова изменён + body = (await client.patch(url, json={"period_year": 2023})).json() + assert body["is_modified"] is True and body["original_values"] == ORIGINAL + + +async def test_confirmed_reset_unbinds_only_working_versions_with_single_audit( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """CONFIRMED -> NEEDS_CHECK, снятие привязок «в работе», CANCELLED остаётся, одно событие аудита.""" + version, members, _, _ = await _version(seeded, members=1, locked_by=None) + file, job = await make_stored_file(seeded, entity=members[0], status=FileStatus.PROCESSED) + doc = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED) + user = await _user(seeded, KA_RF) + confirmed_at = datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.UTC) + doc.confirmed_by, doc.confirmed_at = user.id, confirmed_at + consolidation = await seeded.get(Consolidation, version.consolidation_id) + assert consolidation is not None + second = await make_version( + seeded, + consolidation=consolidation, + version_no=2, + members=members, + initiator=user, + status=ConsolidationStatus.INITIATED, + ) + cancelled = await make_version( + seeded, + consolidation=consolidation, + version_no=3, + members=members, + initiator=user, + status=ConsolidationStatus.CANCELLED, + ) + seeded.add_all( + [ConsolidationDocument(consolidation_version_id=v.id, document_id=doc.id) for v in (version, second, cancelled)] + ) + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{doc.id}/osv-header", json={"osv_account_code": "62"}) + assert response.status_code == HTTPStatus.OK, response.text + assert response.json()["status"] == "NEEDS_CHECK" + + await seeded.refresh(doc) + assert doc.status is DocumentStatus.NEEDS_CHECK and doc.confirmed_by is None and doc.confirmed_at is None + assert await _bound(seeded, doc) == {cancelled.id} + statuses = { + v.id: v.status + for v in ( + await seeded.scalars( + select(ConsolidationVersion).where(ConsolidationVersion.id.in_([version.id, second.id, cancelled.id])) + ) + ).all() + } + assert statuses == { + version.id: ConsolidationStatus.DATA_CORRECTION, + second.id: ConsolidationStatus.INITIATED, + cancelled.id: ConsolidationStatus.CANCELLED, + } + + (event,) = await _events(seeded, doc) + assert event.user_id == user.id and event.object_type == "document" + assert event.before_value["osv_account_code"] == "60" + assert event.before_value["status"] == "CONFIRMED" + assert event.before_value["confirmed_by"] == str(user.id) + assert event.before_value["confirmed_at"] is not None + assert event.after_value["osv_account_code"] == "62" + assert event.after_value["status"] == "NEEDS_CHECK" + assert event.after_value["confirmed_by"] is None and event.after_value["confirmed_at"] is None + assert sorted(event.after_value["unbound_version_ids"]) == sorted([str(version.id), str(second.id)]) + + +async def test_unconfirmed_document_keeps_status_and_audit_has_no_reset_fields( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Неподтверждённый документ сохраняет статус; в аудите нет status и unbound_version_ids.""" + doc = await _simple(seeded, DocumentStatus.NEEDS_CHECK) + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{doc.id}/osv-header", json={"osv_account_code": "62"}) + assert response.status_code == HTTPStatus.OK, response.text + assert response.json()["status"] == "NEEDS_CHECK" + (event,) = await _events(seeded, doc) + assert event.before_value == {"osv_account_code": "60"} + assert event.after_value == {"osv_account_code": "62"} + + +async def test_duplicate_account_after_normalization_and_entries_untouched( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """DUPLICATE_ACCOUNT по нормализованному счёту; успешная правка не меняет osv_entry.""" + entity = await _rostov_entity(seeded) + file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED) + doc = await make_document(seeded, file=file, job=job) + twin = await make_document(seeded, file=file, job=job, account="60.01") + entry = await make_osv_entry( + seeded, recognition_job_id=job.id, document_id=doc.id, owner_legal_entity_id=entity.id, row_number=1 + ) + before = {c.name: getattr(entry, c.name) for c in OsvEntry.__table__.columns} + count = await seeded.scalar(select(func.count()).select_from(OsvEntry)) + client = await auth_client(KA_RF) + url = f"/documents/{doc.id}/osv-header" + response = await client.patch(url, json={"osv_account_code": "60.1"}) + assert response.status_code == HTTPStatus.CONFLICT + assert response.json()["detail"]["code"] == "DUPLICATE_ACCOUNT" + assert response.json()["detail"]["document_id"] == str(twin.id) + await seeded.refresh(doc) + assert doc.osv_account_code == "60" and doc.original_values is None + + response = await client.patch(url, json={"osv_account_code": "61"}) + assert response.status_code == HTTPStatus.OK, response.text + await seeded.refresh(entry) + assert {c.name: getattr(entry, c.name) for c in OsvEntry.__table__.columns} == before + assert await seeded.scalar(select(func.count()).select_from(OsvEntry)) == count + assert (await seeded.get(StoredFile, file.id)) is not None