diff --git a/api/src/api/endpoints/files/read.py b/api/src/api/endpoints/files/read.py index 0ffe6c4..3a1eaa4 100644 --- a/api/src/api/endpoints/files/read.py +++ b/api/src/api/endpoints/files/read.py @@ -1,7 +1,9 @@ """`GET /legal-entities/{legal_entity_id}/files`, `GET /files/{file_id}` — файлы организации и карточка файла -(spec documents-api «Файлы организации и карточка файла»; design D-11). +(spec documents-api «Файлы организации и карточка файла»; design D-11); `DELETE /files/{file_id}` — удаление файла +(spec primary-files «Удаление файла»; design D-10). -Право `DOCUMENT_VIEW` проверяется до видимости; ЮЛ или файл вне видимости — тот же 404, что несуществующий. +Право `DOCUMENT_VIEW` (удаление — `DOCUMENT_EDIT`) проверяется до видимости; ЮЛ или файл вне видимости — тот же 404, +что несуществующий. Список — по убыванию `uploaded_at`, заменённые (`SUPERSEDED`) — только при `include_superseded=true`. """ @@ -18,9 +20,18 @@ from src.schemas.files import FileOut router = APIRouter() VIEW = [Depends(require("DOCUMENT_VIEW"))] +EDIT = [Depends(require("DOCUMENT_EDIT"))] NOT_FOUND: dict[int | str, dict[str, Any]] = { status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"} } +DELETE_RESPONSES: dict[int | str, dict[str, Any]] = { + **NOT_FOUND, + status.HTTP_409_CONFLICT: { + "model": ErrorDetail, + "description": "FILE_HAS_CONFIRMED_DOCUMENTS (есть подтверждённый документ) или FILE_SUPERSEDES_OTHERS " + "(файл заменил более ранние версии)", + }, +} class FileListQuery(PageParams): @@ -53,3 +64,15 @@ async def list_files( async def get_file(file_id: uuid.UUID, service: PrimaryFilesServiceDep) -> FileOut: """Карточка файла — как элемент списка файлов организации.""" return await service.get_file(file_id) + + +@router.delete( + "/files/{file_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=EDIT, + responses=DELETE_RESPONSES, + summary="Удалить файл", +) +async def delete_file(file_id: uuid.UUID, service: PrimaryFilesServiceDep) -> None: + """Удаляет строки ОСВ, документы, задания и файл; объекту S3 ставится `expires_at`; аудит `FILE_DELETE`.""" + await service.delete(file_id) diff --git a/api/src/repositories/files.py b/api/src/repositories/files.py index 97c7ddb..aa538f2 100644 --- a/api/src/repositories/files.py +++ b/api/src/repositories/files.py @@ -7,17 +7,18 @@ SQL без бизнес-правил; видимость — через ЮЛ-в Функции не делают `commit`: транзакцией запроса управляет `SessionDep`. """ +import datetime import uuid from collections.abc import Sequence from typing import Any -from sqlalchemy import Select, func, select +from sqlalchemy import Select, delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from src.domain.policy import VisibilityScope, legal_entities_visible from src.models.directories import LegalEntity from src.models.enums import FileStatus -from src.models.files import Document, RecognitionJob, StoredFile +from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry @@ -127,3 +128,53 @@ async def lock_document(session: AsyncSession, document_id: uuid.UUID) -> Docume select(Document).where(Document.id == document_id).with_for_update().execution_options(populate_existing=True) ) return (await session.execute(stmt)).scalar_one_or_none() + + +async def lock_jobs(session: AsyncSession, file_id: uuid.UUID) -> list[RecognitionJob]: + """`SELECT recognition_job … FOR UPDATE` всех заданий файла (по `id`) с перечитыванием. Порядок блокировок обмена и + маппинга — «задание → файл», поэтому вызывать до `lock_file`.""" + stmt = ( + select(RecognitionJob) + .where(RecognitionJob.file_id == file_id) + .order_by(RecognitionJob.id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return list((await session.execute(stmt)).scalars().all()) + + +async def lock_file_documents(session: AsyncSession, file_id: uuid.UUID) -> list[Document]: + """`SELECT document … FOR UPDATE` всех документов файла (по `id`) с перечитыванием; после `lock_file`.""" + stmt = ( + select(Document) + .where(Document.stored_file_id == file_id) + .order_by(Document.id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return list((await session.execute(stmt)).scalars().all()) + + +async def supersedes_others(session: AsyncSession, file_id: uuid.UUID) -> bool: + """На файл ссылаются `superseded_by_file_id` других файлов (файл заменил другие версии).""" + stmt = select(select(StoredFile.id).where(StoredFile.superseded_by_file_id == file_id).exists()) + return bool((await session.execute(stmt)).scalar_one()) + + +async def delete_file_rows(session: AsyncSession, file: StoredFile) -> None: + """Удаляет строки ОСВ, документы, все задания и сам файл. FK-цикл `stored_file.current_recognition_job_id` ↔ + `recognition_job.file_id` разрывается обнулением ссылки до удаления заданий. Блокировки — у вызывающего.""" + file_id = file.id + file.current_recognition_job_id = None + await session.flush() + document_ids = select(Document.id).where(Document.stored_file_id == file_id) + await session.execute(delete(OsvEntry).where(OsvEntry.document_id.in_(document_ids))) + await session.execute(delete(Document).where(Document.stored_file_id == file_id)) + await session.execute(delete(RecognitionJob).where(RecognitionJob.file_id == file_id)) + await session.execute(delete(StoredFile).where(StoredFile.id == file_id)) + + +async def expire_s3_objects(session: AsyncSession, object_ids: Sequence[uuid.UUID], *, now: datetime.datetime) -> None: + """`s3_object.expires_at = now` (ретеншн бакета заберёт объект; физически объект не удаляется — OQ-22).""" + if object_ids: + await session.execute(update(S3Object).where(S3Object.id.in_(object_ids)).values(expires_at=now)) diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index fce173f..3ede2d3 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -35,7 +35,14 @@ from src.core.storage import ObjectStorage from src.domain.file_types import MAX_ATTEMPTS, DetectedFile, detect_file from src.domain.policy import VisibilityScope from src.models.directories import LegalEntity -from src.models.enums import AuditAction, AuditSection, FileStatus, RecognitionStatus, S3BucketRole +from src.models.enums import ( + AuditAction, + AuditSection, + DocumentStatus, + FileStatus, + RecognitionStatus, + S3BucketRole, +) from src.models.files import RecognitionJob, S3Object, StoredFile from src.repositories import directories as directories_repo from src.repositories import files as files_repo @@ -51,6 +58,8 @@ FILE_TOO_LARGE = "FILE_TOO_LARGE" STORAGE_UNAVAILABLE = "STORAGE_UNAVAILABLE" STORAGE_UNAVAILABLE_MESSAGE = "хранилище файлов недоступно" FILE_NOT_FOUND = "файл не найден" # одинаково для чужого и несуществующего +FILE_HAS_CONFIRMED_DOCUMENTS = "FILE_HAS_CONFIRMED_DOCUMENTS" +FILE_SUPERSEDES_OTHERS = "FILE_SUPERSEDES_OTHERS" class StorageUnavailableError(DomainError): @@ -155,6 +164,48 @@ class PrimaryFilesService: (card,) = await self._cards([file]) return card + async def delete(self, file_id: uuid.UUID) -> None: + """Удалить видимый файл (spec primary-files «Удаление файла»; audit-trail `FILE_DELETE`; design D-10). + + Порядок блокировок — как в обмене и маппинге: задания файла `FOR UPDATE`, затем `stored_file`, затем документы + (confirm берёт «файл → документ», автозамена — advisory → старые файлы по id). Состояние перечитывается + под блокировками, и 409 проверяются после них: `FILE_HAS_CONFIRMED_DOCUMENTS` — есть документ `CONFIRMED`, + `FILE_SUPERSEDES_OTHERS` — на файл ссылаются `superseded_by_file_id` (цепочка версий не рвётся). Файл, + исчезнувший, пока ждали блокировку, — 404. + + Удаляются `osv_entry`, `document`, все задания и `stored_file`; `s3_object` файла и результатов заданий + остаются, им ставится `expires_at = now`. ASSUMPTION(OQ-22): физически объекты S3 не удаляются — их убирает + ретеншн бакета. `before_value` аудита — карточка файла.""" + visible = await files_repo.get_file(self._session, self._scope, file_id) + if visible is None: + raise NotFoundError(FILE_NOT_FOUND) + jobs = await files_repo.lock_jobs(self._session, file_id) + file = await files_repo.lock_file(self._session, file_id) + if file is None: # удалён параллельно, пока ждали блокировку + raise NotFoundError(FILE_NOT_FOUND) + documents = await files_repo.lock_file_documents(self._session, file_id) + if any(document.status is DocumentStatus.CONFIRMED for document in documents): + raise ConflictError( + FILE_HAS_CONFIRMED_DOCUMENTS, "файл нельзя удалить: в нём есть подтверждённые документы" + ) + if await files_repo.supersedes_others(self._session, file_id): + raise ConflictError( + FILE_SUPERSEDES_OTHERS, + "файл нельзя удалить: он заменил более ранние версии, цепочка версий сохраняется", + ) + current = next((job for job in jobs if job.id == file.current_recognition_job_id), None) + before = file_out(file, current, documents).model_dump(mode="json") + object_ids = [file.s3_object_id, *(job.result_s3_object_id for job in jobs if job.result_s3_object_id)] + await files_repo.delete_file_rows(self._session, file) + await files_repo.expire_s3_objects(self._session, object_ids, now=utc_now()) + await self._audit.record( + action=AuditAction.FILE_DELETE, + section=AuditSection.PRIMARY_DOCUMENTS, + object_type=OBJECT_TYPE, + object_id=file_id, + before_value=before, + ) + async def _cards(self, files: list[StoredFile]) -> list[FileOut]: jobs = await files_repo.current_jobs(self._session, files) documents = await files_repo.documents_by_file(self._session, [file.id for file in files]) diff --git a/api/tests/test_api_files_delete.py b/api/tests/test_api_files_delete.py new file mode 100644 index 0000000..5d39bdb --- /dev/null +++ b/api/tests/test_api_files_delete.py @@ -0,0 +1,362 @@ +"""Удаление файла `DELETE /files/{file_id}` (spec primary-files «Удаление файла»; audit-trail `FILE_DELETE`; +design D-10). + +Основные сценарии — поверх фикстуры `app` (транзакция теста). Гонки с подтверждением и циклом маппинга — на +`committed_*` (временная БД, сессия запроса как в проде). +""" + +import asyncio +import json +import uuid +from pathlib import Path +from typing import Any + +import pytest +from sqlalchemy import func, select, text, update +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.core.config import get_settings +from src.models.audit_ex import AuditEvent +from src.models.directories import LegalEntity, RegionalOffice +from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus, RecognitionStatus +from src.models.files import Document, RecognitionJob, S3Object, StoredFile +from src.models.osv import OsvEntry +from src.services.result_mapping import process_once +from tests.conftest import AuthClientFactory, FakeStorage +from tests.factories import ( + make_company_group, + make_document, + make_entries_from_result, + make_legal_entity, + make_result_object, + make_stored_file, +) + +HTTP_NO_CONTENT = 204 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +HTTP_CONFLICT = 409 + +KA_RF = "ka_rf@armka.local" # Ростовский филиал, DOCUMENT_VIEW + DOCUMENT_EDIT +RKA = "rka@armka.local" # Ростовский филиал, только DOCUMENT_VIEW +CLIENT_MANAGER = "client_manager@armka.local" # Краснодарский филиал +ROSTOV = "Ростовский региональный филиал" +MINIMAL_RESULT = Path(__file__).resolve().parents[1] / "contracts" / "examples" / "result-osv-minimal.json" +LOCK_WAIT_SECONDS = 10.0 + +pytestmark = pytest.mark.integration + + +async def _rostov_entity(session: AsyncSession, name: str = "ООО Удаление") -> LegalEntity: + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group = await make_company_group(session, name=f"ГК {name}", office=office) + return await make_legal_entity(session, name=name, group=group) + + +def _minimal() -> dict[str, Any]: + return json.loads(MINIMAL_RESULT.read_text("utf-8")) + + +async def _count(session: AsyncSession, model: type, *where: Any) -> int: + return (await session.execute(select(func.count()).select_from(model).where(*where))).scalar_one() + + +async def _delete_events(session: AsyncSession) -> list[AuditEvent]: + rows = await session.execute(select(AuditEvent).where(AuditEvent.action == AuditAction.FILE_DELETE)) + return list(rows.scalars()) + + +# --- успешное удаление ---------------------------------------------------------------------------------------------- + + +async def test_delete_error_file(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """«Удаление ошибочного файла»: 204, записей нет, `expires_at` у объектов, аудит с карточкой в `before_value`.""" + entity = await _rostov_entity(seeded) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.error + ) + job.error_code = "PARSE_ERROR" + result_object = await make_result_object(seeded, job=job, storage=FakeStorage(), result={}) + file_id, job_id, s3_id = file.id, job.id, file.s3_object_id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + assert response.content == b"" + assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 0 + assert await _count(seeded, RecognitionJob, RecognitionJob.file_id == file_id) == 0 + for object_id in (s3_id, result_object.id): # строки s3_object остаются, им ставится expires_at + s3 = await seeded.get(S3Object, object_id, populate_existing=True) + assert s3 is not None + assert s3.expires_at is not None + [event] = await _delete_events(seeded) + assert event.section is AuditSection.PRIMARY_DOCUMENTS + assert (event.object_type, event.object_id) == ("stored_file", file_id) + assert event.user_id is not None + assert event.before_value is not None + assert event.before_value["id"] == str(file_id) + assert event.before_value["status"] == "ERROR" + assert event.before_value["job"]["id"] == str(job_id) + assert event.before_value["job"]["error_code"] == "PARSE_ERROR" + assert event.after_value is None + + +async def test_delete_processed_file_removes_rows(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Файл `PROCESSED` без подтверждения: удаляются строки ОСВ, документы, задания и файл.""" + entity = await _rostov_entity(seeded) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + documents = await make_entries_from_result(seeded, file, job, _minimal()) + assert documents + entry_count = await _count(seeded, OsvEntry, OsvEntry.recognition_job_id == job.id) + assert entry_count > 0 + file_id, job_id = file.id, job.id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + assert await _count(seeded, OsvEntry, OsvEntry.recognition_job_id == job_id) == 0 + assert await _count(seeded, Document, Document.stored_file_id == file_id) == 0 + assert await _count(seeded, RecognitionJob, RecognitionJob.id == job_id) == 0 + assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 0 + [event] = await _delete_events(seeded) + assert event.before_value is not None + assert [item["id"] for item in event.before_value["documents"]] == [str(d.id) for d in documents] + + +@pytest.mark.parametrize("status", [FileStatus.UPLOADED, FileStatus.PROCESSING, FileStatus.SUPERSEDED]) +async def test_delete_file_in_other_statuses( + seeded: AsyncSession, auth_client: AuthClientFactory, status: FileStatus +) -> None: + """Файл в `UPLOADED`/`PROCESSING` и `SUPERSEDED` (ни на что не влияет) удаляется.""" + entity = await _rostov_entity(seeded) + file, _ = await make_stored_file(seeded, entity=entity, status=status) + file_id = file.id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 0 + + +async def test_delete_superseded_file_keeps_new_one(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + entity = await _rostov_entity(seeded) + new, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED) + old, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.SUPERSEDED) + old.superseded_by_file_id = new.id + await seeded.flush() + old_id, new_id = old.id, new.id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{old_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + assert await _count(seeded, StoredFile, StoredFile.id == new_id) == 1 + + +# --- отказы --------------------------------------------------------------------------------------------------------- + + +async def test_confirmed_document_blocks_delete(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + entity = await _rostov_entity(seeded) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + await make_document(seeded, file=file, job=job, account="60", status=DocumentStatus.NEEDS_CHECK) + await make_document(seeded, file=file, job=job, account="62", status=DocumentStatus.CONFIRMED) + file_id, job_id = file.id, job.id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_CONFLICT, response.text + assert response.json()["detail"]["code"] == "FILE_HAS_CONFIRMED_DOCUMENTS" + assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 1 + assert await _count(seeded, RecognitionJob, RecognitionJob.id == job_id) == 1 + assert await _count(seeded, Document, Document.stored_file_id == file_id) == 2 # noqa: PLR2004 + assert await _delete_events(seeded) == [] + s3 = await seeded.get(S3Object, file.s3_object_id, populate_existing=True) + assert s3 is not None + assert s3.expires_at is None + + +async def test_file_that_superseded_others_is_not_deletable( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + entity = await _rostov_entity(seeded) + new, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED) + old, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.SUPERSEDED) + old.superseded_by_file_id = new.id + await seeded.flush() + new_id = new.id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{new_id}") + + assert response.status_code == HTTP_CONFLICT, response.text + assert response.json()["detail"]["code"] == "FILE_SUPERSEDES_OTHERS" + assert await _count(seeded, StoredFile, StoredFile.id == new_id) == 1 + assert await _delete_events(seeded) == [] + + +async def test_foreign_and_missing_file_are_same_404(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + entity = await _rostov_entity(seeded) + file, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.ERROR) + file_id = file.id + client = await auth_client(CLIENT_MANAGER) # Краснодарский филиал: ЮЛ Ростова не видит, DOCUMENT_EDIT есть + + foreign = await client.delete(f"/files/{file_id}") + missing = await client.delete(f"/files/{uuid.uuid4()}") + + assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND + assert foreign.json() == missing.json() + assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 1 + assert await _delete_events(seeded) == [] + + +async def test_delete_requires_document_edit(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + entity = await _rostov_entity(seeded) + file, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.ERROR) + file_id = file.id + client = await auth_client(RKA) # свой филиал, только DOCUMENT_VIEW + + assert (await client.get(f"/files/{file_id}")).status_code == 200 # noqa: PLR2004 + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_FORBIDDEN + assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 1 + + +async def test_repeated_delete_and_read_after_delete_are_404( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + entity = await _rostov_entity(seeded) + file, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.ERROR) + file_id = file.id + client = await auth_client(KA_RF) + + assert (await client.delete(f"/files/{file_id}")).status_code == HTTP_NO_CONTENT + again = await client.delete(f"/files/{file_id}") + read = await client.get(f"/files/{file_id}") + + assert again.status_code == read.status_code == HTTP_NOT_FOUND + assert len(await _delete_events(seeded)) == 1 + + +# --- гонки (временная БД) ------------------------------------------------------------------------------------------- + + +async def _committed_file( + maker: async_sessionmaker[AsyncSession], storage: FakeStorage | None = None +) -> tuple[uuid.UUID, uuid.UUID, uuid.UUID]: + """Файл `PROCESSING` ЮЛ Ростовского филиала с документом `NEEDS_CHECK` (и результатом в `storage`, если задан); + `(file_id, job_id, document_id)`.""" + async with maker() as session, session.begin(): + entity = await _rostov_entity(session, "ООО Гонка удаления") + file, job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSING, job_status=RecognitionStatus.done + ) + document = await make_document(session, file=file, job=job, status=DocumentStatus.NEEDS_CHECK) + if storage is not None: + await make_result_object( + session, + job=job, + storage=storage, + result=_minimal() | {"job_id": str(job.id), "file_id": str(file.id)}, + ) + return file.id, job.id, document.id + + +async def _wait_for_lock_waiter(maker: async_sessionmaker[AsyncSession]) -> None: + deadline = asyncio.get_running_loop().time() + LOCK_WAIT_SECONDS + while asyncio.get_running_loop().time() < deadline: + async with maker() as session: + waiting = ( + await session.execute( + text( + "SELECT count(*) FROM pg_stat_activity " + "WHERE datname = current_database() AND wait_event_type = 'Lock'" + ) + ) + ).scalar_one() + if waiting: + return + await asyncio.sleep(0.05) + raise AssertionError("удаление не встало в ожидание блокировки файла") + + +async def test_delete_rereads_state_after_locks( + committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory +) -> None: + """Подтверждение держит файл под `FOR UPDATE` и фиксирует документ `CONFIRMED`; удаление, начатое в это время, + ждёт блокировку, перечитывает документы и отвечает 409 — ничего не удалено.""" + file_id, job_id, document_id = await _committed_file(committed_sessionmaker) + client = await committed_client(KA_RF) + + async with committed_sessionmaker() as confirmer, confirmer.begin(): + await confirmer.execute(select(StoredFile.id).where(StoredFile.id == file_id).with_for_update()) + deletion = asyncio.create_task(client.delete(f"/files/{file_id}")) + await _wait_for_lock_waiter(committed_sessionmaker) + assert not deletion.done() + await confirmer.execute( + update(Document).where(Document.id == document_id).values(status=DocumentStatus.CONFIRMED) + ) + response = await asyncio.wait_for(deletion, LOCK_WAIT_SECONDS) + + assert response.status_code == HTTP_CONFLICT, response.text + assert response.json()["detail"]["code"] == "FILE_HAS_CONFIRMED_DOCUMENTS" + async with committed_sessionmaker() as session: + assert await _count(session, StoredFile, StoredFile.id == file_id) == 1 + assert await _count(session, RecognitionJob, RecognitionJob.id == job_id) == 1 + assert await _delete_events(session) == [] + + +async def test_delete_of_file_removed_while_waiting_is_404( + committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory +) -> None: + """Файл удалён параллельной транзакцией, пока удаление ждало блокировку: 404, не 500.""" + file_id, job_id, _ = await _committed_file(committed_sessionmaker) + client = await committed_client(KA_RF) + + async with committed_sessionmaker() as other, other.begin(): + await other.execute(select(RecognitionJob.id).where(RecognitionJob.id == job_id).with_for_update()) + deletion = asyncio.create_task(client.delete(f"/files/{file_id}")) + await _wait_for_lock_waiter(committed_sessionmaker) + file = await other.get(StoredFile, file_id) + assert file is not None + file.current_recognition_job_id = None + await other.flush() + await other.execute(text("DELETE FROM document WHERE stored_file_id = :id"), {"id": file_id}) + await other.execute(text("DELETE FROM recognition_job WHERE file_id = :id"), {"id": file_id}) + await other.execute(text("DELETE FROM stored_file WHERE id = :id"), {"id": file_id}) + response = await asyncio.wait_for(deletion, LOCK_WAIT_SECONDS) + + assert response.status_code == HTTP_NOT_FOUND, response.text + async with committed_sessionmaker() as session: + assert await _delete_events(session) == [] + + +async def test_mapping_loop_ignores_deleted_file( + committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory +) -> None: + """Файл `PROCESSING` с готовым результатом удалён до цикла маппинга: `process_once` не падает, кандидатов нет, + документы и строки для файла не создаются; объект результата получил `expires_at`.""" + storage = FakeStorage() + file_id, job_id, _ = await _committed_file(committed_sessionmaker, storage) + client = await committed_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + assert await process_once(committed_sessionmaker, storage, get_settings()) is False + async with committed_sessionmaker() as session: + assert await _count(session, StoredFile, StoredFile.id == file_id) == 0 + assert await _count(session, Document, Document.stored_file_id == file_id) == 0 + assert await _count(session, OsvEntry, OsvEntry.recognition_job_id == job_id) == 0 + result_key = f"results/{file_id}/{job_id}/result.json" + expires = (await session.execute(select(S3Object.expires_at).where(S3Object.key == result_key))).scalar_one() + assert expires is not None diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 231f6b8..4fa95cb 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -46,7 +46,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = { f"{A}/roles/{{code}}/permissions": {"put"}, # primary-files, documents-api (Срез 2c) "/api/v1/legal-entities/{legal_entity_id}/files": {"get", "post"}, - "/api/v1/files/{file_id}": {"get"}, + "/api/v1/files/{file_id}": {"get", "delete"}, "/api/v1/documents/{document_id}": {"get"}, "/api/v1/documents/{document_id}/entries": {"get"}, "/api/v1/documents/{document_id}/confirm": {"post"}, diff --git a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md index 7c543d1..0bd0873 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md +++ b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md @@ -291,9 +291,9 @@ class DocumentService: async def delete(self, file_id) -> None # get_file(scope) → 404; есть document CONFIRMED → ConflictError("FILE_HAS_CONFIRMED_DOCUMENTS"); EXISTS stored_file WHERE superseded_by_file_id = id → ConflictError("FILE_SUPERSEDES_OTHERS"); before = FileOut-словарь; DELETE osv_entry (по документам файла), document, recognition_job WHERE file_id (сначала file.current_recognition_job_id = NULL + flush — FK цикл), stored_file; s3_object.expires_at = now (ASSUMPTION(OQ-22)); audit FILE_DELETE before_value=before ``` -- [ ] **Step 1: Тесты:** сценарии спеки (ошибочный файл → 204, записей нет, `expires_at`, аудит; `CONFIRMED` → 409; файл, заменивший другой → 409; `SUPERSEDED` → 204; чужой → 404; `PROCESSED` без подтверждения → 204 со строками). -- [ ] **Step 2–4:** падает → реализация → тесты, `make check`. -- [ ] **Step 5: Commit** `feat(files): удаление файла с ограничениями, expires_at у объекта S3, FILE_DELETE`. +- [x] **Step 1: Тесты:** сценарии спеки (ошибочный файл → 204, записей нет, `expires_at`, аудит; `CONFIRMED` → 409; файл, заменивший другой → 409; `SUPERSEDED` → 204; чужой → 404; `PROCESSED` без подтверждения → 204 со строками). +- [x] **Step 2–4:** падает → реализация → тесты, `make check`. +- [x] **Step 5: Commit** `feat(files): удаление файла с ограничениями, expires_at у объекта S3, FILE_DELETE`. --- diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md index 57b8859..02eaae3 100644 --- a/openspec/changes/slice-2c-osv-ingest/tasks.md +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -53,7 +53,7 @@ - [x] 4.1 **API чтения и подтверждение.** `repositories/files.py`, `services/documents.py`, `schemas/documents.py`, эндпоинты `GET /legal-entities/{id}/files`, `GET /files/{id}`, `GET /documents/{id}`, `GET /documents/{id}/entries`, `POST /documents/{id}/confirm`; policy через ЮЛ-владельца. Тесты: сценарии спеки `documents-api`; `test_openapi_routes`. -- [ ] 4.2 **Удаление файла.** `DELETE /files/{id}`: проверки `CONFIRMED`/`superseded_by`, удаление строк/документов/заданий/файла, +- [x] 4.2 **Удаление файла.** `DELETE /files/{id}`: проверки `CONFIRMED`/`superseded_by`, удаление строк/документов/заданий/файла, `s3_object.expires_at`, аудит `FILE_DELETE`. Тесты: сценарии спеки. ## 5. Сквозной прогон и документы