test: синтетические тесты уточнения шапки ОСВ и пакетного повтора (ревью #161, AURORA-1442)

PATCH osv-header без fixtures: порядок ошибок, no-op, original_values, сброс
подтверждения и отвязка, аудит, DUPLICATE_ACCOUNT, неизменность osv_entry.
Пакет: смешанный (OCR и исчерпанный лимит в skipped), NO_ACK на пределе.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 17:13:41 +04:00
parent 5c70fb5387
commit b32383eff7
2 changed files with 335 additions and 0 deletions

View File

@ -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]

View File

@ -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