chore: S3 get_bytes, протокол ObjectStorage, фабрики файлов/документов, настройки загрузки и маппинга
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
ab606dc59a
commit
2d69a9e867
@ -57,3 +57,9 @@ OUTBOX_MAX_REPUBLISH=3
|
|||||||
|
|
||||||
# Импорт справочников из выгрузки ЕЦП CRM: предельный размер файла в байтах (20 МБ), больше — ответ 413
|
# Импорт справочников из выгрузки ЕЦП CRM: предельный размер файла в байтах (20 МБ), больше — ответ 413
|
||||||
IMPORT_MAX_BYTES=20971520
|
IMPORT_MAX_BYTES=20971520
|
||||||
|
|
||||||
|
# Загрузка первичных файлов (ББ/ОФР/ОСВ): предельный размер файла в байтах (50 МБ), больше — ответ 413
|
||||||
|
UPLOAD_MAX_BYTES=52428800
|
||||||
|
# Маппинг результата распознавания: период опроса готовых заданий (секунды) и число попыток до отметки ошибки
|
||||||
|
MAPPING_POLL_SECONDS=5
|
||||||
|
MAPPING_MAX_ATTEMPTS=3
|
||||||
|
|||||||
@ -50,3 +50,8 @@ OUTBOX_MAX_REPUBLISH=3
|
|||||||
|
|
||||||
# Импорт справочников: предельный размер файла (20 МБ), как в .env.example
|
# Импорт справочников: предельный размер файла (20 МБ), как в .env.example
|
||||||
IMPORT_MAX_BYTES=20971520
|
IMPORT_MAX_BYTES=20971520
|
||||||
|
|
||||||
|
# Загрузка первичных файлов и маппинг результатов: значения как в .env.example
|
||||||
|
UPLOAD_MAX_BYTES=52428800
|
||||||
|
MAPPING_POLL_SECONDS=5
|
||||||
|
MAPPING_MAX_ATTEMPTS=3
|
||||||
|
|||||||
@ -24,3 +24,4 @@ filterwarnings =
|
|||||||
markers =
|
markers =
|
||||||
integration: тесты, которым нужны PostgreSQL/NATS (docker compose); запускаются отдельно от юнит-тестов
|
integration: тесты, которым нужны PostgreSQL/NATS (docker compose); запускаются отдельно от юнит-тестов
|
||||||
fixtures: тесты на реальных данных из ../fixtures (внутренний доступ)
|
fixtures: тесты на реальных данных из ../fixtures (внутренний доступ)
|
||||||
|
e2e: сквозные тесты Среза 2c на реальных данных (NATS + tabular-parser + MinIO + PostgreSQL); запускаются отдельно
|
||||||
|
|||||||
@ -5,6 +5,11 @@
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from miniopy_async import Minio
|
from miniopy_async import Minio
|
||||||
|
from miniopy_async.error import S3Error
|
||||||
|
|
||||||
|
from src.core.storage import ObjectNotFoundError
|
||||||
|
|
||||||
|
NO_SUCH_KEY = "NoSuchKey"
|
||||||
|
|
||||||
|
|
||||||
class S3Storage:
|
class S3Storage:
|
||||||
@ -26,3 +31,17 @@ class S3Storage:
|
|||||||
return response
|
return response
|
||||||
except:
|
except:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def get_bytes(self, obj_name: str) -> bytes:
|
||||||
|
"""Содержимое объекта целиком; response закрывается и отпускается; `NoSuchKey` → `ObjectNotFoundError`."""
|
||||||
|
try:
|
||||||
|
response = await self.client.get_object(self.bucket, obj_name)
|
||||||
|
except S3Error as error:
|
||||||
|
if error.code == NO_SUCH_KEY:
|
||||||
|
raise ObjectNotFoundError(obj_name) from error
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
return await response.read()
|
||||||
|
finally:
|
||||||
|
response.close()
|
||||||
|
await response.release()
|
||||||
|
|||||||
@ -87,6 +87,13 @@ class Settings(BaseSettings):
|
|||||||
# Импорт справочников из выгрузки ЕЦП (§13.3): предельный размер файла, больше — 413
|
# Импорт справочников из выгрузки ЕЦП (§13.3): предельный размер файла, больше — 413
|
||||||
IMPORT_MAX_BYTES: PositiveInt = 20 * 1024 * 1024
|
IMPORT_MAX_BYTES: PositiveInt = 20 * 1024 * 1024
|
||||||
|
|
||||||
|
# Загрузка первичных файлов (Срез 2c): предельный размер файла, больше — 413
|
||||||
|
UPLOAD_MAX_BYTES: PositiveInt = 50 * 1024 * 1024
|
||||||
|
# Маппинг результата распознавания в `document`/`osv_entry`: период опроса заданий в статусе done/needs_check без
|
||||||
|
# документов и число попыток (0 и отрицательные значения отвергаются при старте)
|
||||||
|
MAPPING_POLL_SECONDS: PositiveInt = 5
|
||||||
|
MAPPING_MAX_ATTEMPTS: PositiveInt = 3
|
||||||
|
|
||||||
@field_validator("CORS_ORIGINS", mode="before")
|
@field_validator("CORS_ORIGINS", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _split_origins(cls, value: Any) -> Any:
|
def _split_origins(cls, value: Any) -> Any:
|
||||||
|
|||||||
23
api/src/core/storage.py
Normal file
23
api/src/core/storage.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
"""Контракт хранилища объектов (S3), которым пользуются сервисы ядра.
|
||||||
|
|
||||||
|
Реализация — `src.core.clients.S3Storage`, заглушка тестов — `tests.conftest.FakeStorage`. Протокол вынесен из
|
||||||
|
`services/directory_import.py`: тот же интерфейс нужен загрузке файлов и маппингу результатов (Срез 2c, D-12).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectNotFoundError(Exception):
|
||||||
|
"""Объекта с таким ключом нет в бакете (S3 `NoSuchKey`)."""
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectStorage(Protocol):
|
||||||
|
"""Что ядру нужно от S3: бакет, запись и чтение объекта целиком."""
|
||||||
|
|
||||||
|
bucket: str
|
||||||
|
|
||||||
|
async def put_object(self, obj_name: str, file_data: bytes) -> None: ...
|
||||||
|
|
||||||
|
async def get_bytes(self, obj_name: str) -> bytes:
|
||||||
|
"""Содержимое объекта; отсутствие объекта — `ObjectNotFoundError`."""
|
||||||
|
...
|
||||||
@ -19,7 +19,7 @@ import unicodedata
|
|||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from typing import Any, Protocol
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@ -30,6 +30,7 @@ from src.api.dependencies.audit import RequestAudit
|
|||||||
from src.common.clock import utc_now
|
from src.common.clock import utc_now
|
||||||
from src.common.exceptions import DomainValidationError
|
from src.common.exceptions import DomainValidationError
|
||||||
from src.core.config import Settings
|
from src.core.config import Settings
|
||||||
|
from src.core.storage import ObjectStorage
|
||||||
from src.domain.directory_import import (
|
from src.domain.directory_import import (
|
||||||
AddMember,
|
AddMember,
|
||||||
CreateEntity,
|
CreateEntity,
|
||||||
@ -74,14 +75,6 @@ MAX_FILENAME_LENGTH = 200 # символов: ключ S3 `uploads/imports/{uui
|
|||||||
MAX_SUFFIX_LENGTH = 16
|
MAX_SUFFIX_LENGTH = 16
|
||||||
|
|
||||||
|
|
||||||
class ObjectStorage(Protocol):
|
|
||||||
"""Что импорту нужно от S3 (`src.core.clients.S3Storage`): бакет и запись объекта."""
|
|
||||||
|
|
||||||
bucket: str
|
|
||||||
|
|
||||||
async def put_object(self, obj_name: str, file_data: bytes) -> Any: ...
|
|
||||||
|
|
||||||
|
|
||||||
def check_size(size: int, limit: int) -> None:
|
def check_size(size: int, limit: int) -> None:
|
||||||
"""413, если файл больше `IMPORT_MAX_BYTES`."""
|
"""413, если файл больше `IMPORT_MAX_BYTES`."""
|
||||||
if size > limit:
|
if size > limit:
|
||||||
|
|||||||
@ -38,6 +38,7 @@ from src.api.router import build_api_router
|
|||||||
from src.core.auth import build_verifier
|
from src.core.auth import build_verifier
|
||||||
from src.core.config import get_settings
|
from src.core.config import get_settings
|
||||||
from src.core.exception_handlers import register_exception_handlers
|
from src.core.exception_handlers import register_exception_handlers
|
||||||
|
from src.core.storage import ObjectNotFoundError
|
||||||
from src.db.db import get_async_session, session_transaction
|
from src.db.db import get_async_session, session_transaction
|
||||||
from src.services.session_audit import SessionAudit
|
from src.services.session_audit import SessionAudit
|
||||||
from tests.fixtures_helpers import fixtures_skip_reason, resolve_fixtures_dir
|
from tests.fixtures_helpers import fixtures_skip_reason, resolve_fixtures_dir
|
||||||
@ -205,7 +206,7 @@ async def auth_client(app: FastAPI, seeded: AsyncSession) -> AsyncIterator[AuthC
|
|||||||
|
|
||||||
|
|
||||||
class FakeStorage:
|
class FakeStorage:
|
||||||
"""Заглушка `S3Storage`: тот же интерфейс `bucket`/`put_object(obj_name, file_data)`, объекты — в словаре."""
|
"""Заглушка `S3Storage` (протокол `ObjectStorage`): `bucket`/`put_object`/`get_bytes`, объекты — в словаре."""
|
||||||
|
|
||||||
def __init__(self, bucket: str = "arm-ka") -> None:
|
def __init__(self, bucket: str = "arm-ka") -> None:
|
||||||
self.bucket = bucket
|
self.bucket = bucket
|
||||||
@ -214,6 +215,12 @@ class FakeStorage:
|
|||||||
async def put_object(self, obj_name: str, file_data: bytes) -> None:
|
async def put_object(self, obj_name: str, file_data: bytes) -> None:
|
||||||
self.objects[obj_name] = file_data
|
self.objects[obj_name] = file_data
|
||||||
|
|
||||||
|
async def get_bytes(self, obj_name: str) -> bytes:
|
||||||
|
try:
|
||||||
|
return self.objects[obj_name]
|
||||||
|
except KeyError:
|
||||||
|
raise ObjectNotFoundError(obj_name) from None
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def committed_sessionmaker() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
|
async def committed_sessionmaker() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
|
||||||
|
|||||||
@ -4,6 +4,9 @@
|
|||||||
`make_user` — справочники Среза 1 (`flush`, без `commit`): откатываются фикстурой `db_session`. Идентификаторы по
|
`make_user` — справочники Среза 1 (`flush`, без `commit`): откатываются фикстурой `db_session`. Идентификаторы по
|
||||||
умолчанию случайные (ИНН, коды, имена филиалов), поэтому фабрики можно вызывать несколько раз в одном тесте.
|
умолчанию случайные (ИНН, коды, имена филиалов), поэтому фабрики можно вызывать несколько раз в одном тесте.
|
||||||
- `make_osv_entry` — минимальная цепочка FK поверх сессии (`flush`, без `commit`): откатывается фикстурой `db_session`.
|
- `make_osv_entry` — минимальная цепочка FK поверх сессии (`flush`, без `commit`): откатывается фикстурой `db_session`.
|
||||||
|
- `make_stored_file`, `make_document`, `make_result_object` — цепочка файла/задания/документа и объект результата в
|
||||||
|
`FakeStorage` для тестов загрузки и маппинга (Срез 2c; `flush`, без `commit`). `make_entries_from_result` создаёт
|
||||||
|
Task 9 (R-2).
|
||||||
- `CommittedJobs` — задания распознавания, ЗАФИКСИРОВАННЫЕ в БД (`commit`): для фоновых процессов обмена, которые
|
- `CommittedJobs` — задания распознавания, ЗАФИКСИРОВАННЫЕ в БД (`commit`): для фоновых процессов обмена, которые
|
||||||
работают в собственных транзакциях и не видят чужую незафиксированную транзакцию. Всё созданное удаляется
|
работают в собственных транзакциях и не видят чужую незафиксированную транзакцию. Всё созданное удаляется
|
||||||
`cleanup()` в порядке FK, включая строки `s3_object` файлов результата, зарегистрированные inbox'ом.
|
`cleanup()` в порядке FK, включая строки `s3_object` файлов результата, зарегистрированные inbox'ом.
|
||||||
@ -11,12 +14,13 @@
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import decimal
|
import decimal
|
||||||
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from src.models.access import AppUser, Role, UserRole
|
from src.models.access import AppUser, Role, UserRole
|
||||||
@ -33,6 +37,7 @@ from src.models.enums import (
|
|||||||
EntityType,
|
EntityType,
|
||||||
FileStatus,
|
FileStatus,
|
||||||
FileType,
|
FileType,
|
||||||
|
PeriodQuarter,
|
||||||
RecognitionPipeline,
|
RecognitionPipeline,
|
||||||
RecognitionStatus,
|
RecognitionStatus,
|
||||||
RelatedGroupKind,
|
RelatedGroupKind,
|
||||||
@ -40,8 +45,11 @@ from src.models.enums import (
|
|||||||
)
|
)
|
||||||
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
|
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
|
||||||
from src.models.osv import OsvEntry
|
from src.models.osv import OsvEntry
|
||||||
|
from tests.conftest import FakeStorage
|
||||||
|
|
||||||
DEFAULT_FILE_TYPES = {RecognitionPipeline.tabular: FileType.XLSX, RecognitionPipeline.image: FileType.PDF}
|
DEFAULT_FILE_TYPES = {RecognitionPipeline.tabular: FileType.XLSX, RecognitionPipeline.image: FileType.PDF}
|
||||||
|
TABULAR_FILE_TYPES = frozenset({FileType.XLS, FileType.XLSX, FileType.XLSB})
|
||||||
|
FACTORY_BUCKET = "arm-ka"
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime.datetime:
|
def _now() -> datetime.datetime:
|
||||||
@ -159,6 +167,116 @@ async def make_user( # noqa: PLR0913 - сигнатура фабрики фик
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def make_stored_file( # noqa: PLR0913 - сигнатура фабрики фиксирована планом
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
entity: LegalEntity,
|
||||||
|
status: FileStatus = FileStatus.UPLOADED,
|
||||||
|
file_type: FileType = FileType.XLS,
|
||||||
|
original_name: str = "osv.xls",
|
||||||
|
content_hash: str | None = None,
|
||||||
|
job_status: RecognitionStatus = RecognitionStatus.queued,
|
||||||
|
s3_key: str | None = None,
|
||||||
|
uploaded_by: uuid.UUID | None = None,
|
||||||
|
) -> tuple[StoredFile, RecognitionJob]:
|
||||||
|
"""`s3_object` (uploads) + `stored_file` + `recognition_job` (attempt 1), `current_recognition_job_id` = задание.
|
||||||
|
|
||||||
|
Конвейер задания выводится из типа файла (XLS/XLSX/XLSB — tabular, иначе image). Данные остаются в транзакции
|
||||||
|
сессии (`flush`).
|
||||||
|
"""
|
||||||
|
now = _now()
|
||||||
|
s3 = S3Object(
|
||||||
|
bucket_role=S3BucketRole.uploads,
|
||||||
|
bucket=FACTORY_BUCKET,
|
||||||
|
key=s3_key or f"uploads/{uuid.uuid4()}/{original_name}",
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
session.add(s3)
|
||||||
|
await session.flush()
|
||||||
|
file = StoredFile(
|
||||||
|
legal_entity_id=entity.id,
|
||||||
|
file_type=file_type,
|
||||||
|
original_name=original_name,
|
||||||
|
s3_object_id=s3.id,
|
||||||
|
content_hash=content_hash,
|
||||||
|
status=status,
|
||||||
|
uploaded_by=uploaded_by,
|
||||||
|
uploaded_at=now,
|
||||||
|
)
|
||||||
|
session.add(file)
|
||||||
|
await session.flush()
|
||||||
|
pipeline = RecognitionPipeline.tabular if file_type in TABULAR_FILE_TYPES else RecognitionPipeline.image
|
||||||
|
job = RecognitionJob(
|
||||||
|
file_id=file.id,
|
||||||
|
pipeline=pipeline,
|
||||||
|
request_subject=f"recognition.{pipeline.value}.request",
|
||||||
|
schema_version="1.0",
|
||||||
|
status=job_status,
|
||||||
|
)
|
||||||
|
session.add(job)
|
||||||
|
await session.flush()
|
||||||
|
file.current_recognition_job_id = job.id
|
||||||
|
await session.flush()
|
||||||
|
return file, job
|
||||||
|
|
||||||
|
|
||||||
|
async def make_document( # noqa: PLR0913 - сигнатура фабрики фиксирована планом
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
file: StoredFile,
|
||||||
|
job: RecognitionJob,
|
||||||
|
account: str = "60",
|
||||||
|
year: int = 2025,
|
||||||
|
quarter: PeriodQuarter = PeriodQuarter.Q1,
|
||||||
|
status: DocumentStatus = DocumentStatus.RECOGNIZED,
|
||||||
|
is_cumulative: bool = True,
|
||||||
|
) -> Document:
|
||||||
|
"""ОСВ-документ по счёту `account` файла `file` (`flush`).
|
||||||
|
|
||||||
|
`job` — задание, породившее документ (у `document` FK на задание нет; параметр держит вызовы единообразными с
|
||||||
|
`make_stored_file`).
|
||||||
|
"""
|
||||||
|
assert job.file_id == file.id # noqa: S101 - защита от перепутанных аргументов в тестах
|
||||||
|
now = _now()
|
||||||
|
document = Document(
|
||||||
|
stored_file_id=file.id,
|
||||||
|
legal_entity_id=file.legal_entity_id,
|
||||||
|
document_type=DocumentType.OSV,
|
||||||
|
osv_account_code=account,
|
||||||
|
period_year=year,
|
||||||
|
period_quarter=quarter,
|
||||||
|
is_cumulative=is_cumulative,
|
||||||
|
status=status,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(document)
|
||||||
|
await session.flush()
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
async def make_result_object(
|
||||||
|
session: AsyncSession, *, job: RecognitionJob, storage: FakeStorage, result: dict[str, Any]
|
||||||
|
) -> S3Object:
|
||||||
|
"""JSON результата — в `storage` под `results/{file_id}/{job_id}/result.json` + `s3_object` + ссылка в задании."""
|
||||||
|
key = f"results/{job.file_id}/{job.id}/result.json"
|
||||||
|
payload = json.dumps(result, ensure_ascii=False).encode("utf-8")
|
||||||
|
await storage.put_object(key, payload)
|
||||||
|
s3 = S3Object(
|
||||||
|
bucket_role=S3BucketRole.results,
|
||||||
|
bucket=storage.bucket,
|
||||||
|
key=key,
|
||||||
|
content_type="application/json",
|
||||||
|
size_bytes=len(payload),
|
||||||
|
created_at=_now(),
|
||||||
|
)
|
||||||
|
session.add(s3)
|
||||||
|
await session.flush()
|
||||||
|
job.result_s3_object_id = s3.id
|
||||||
|
await session.flush()
|
||||||
|
return s3
|
||||||
|
|
||||||
|
|
||||||
async def make_osv_entry(session: AsyncSession, **overrides: Any) -> OsvEntry:
|
async def make_osv_entry(session: AsyncSession, **overrides: Any) -> OsvEntry:
|
||||||
"""Строка ОСВ с минимальной цепочкой FK (ЮЛ, s3_object, stored_file, recognition_job, document); `flush`.
|
"""Строка ОСВ с минимальной цепочкой FK (ЮЛ, s3_object, stored_file, recognition_job, document); `flush`.
|
||||||
|
|
||||||
@ -320,9 +438,17 @@ class CommittedJobs:
|
|||||||
self._extra_s3_ids.append(s3_object_id)
|
self._extra_s3_ids.append(s3_object_id)
|
||||||
|
|
||||||
async def cleanup(self) -> None:
|
async def cleanup(self) -> None:
|
||||||
"""Удаляет всё созданное в порядке FK (файлы результата, на которые ссылаются задания, — после заданий)."""
|
"""Удаляет всё созданное в порядке FK: строки ОСВ и документы файлов → задания → файлы → объекты S3 и ЮЛ."""
|
||||||
async with self._session_factory() as session, session.begin():
|
async with self._session_factory() as session, session.begin():
|
||||||
result_s3_ids: list[uuid.UUID] = []
|
result_s3_ids: list[uuid.UUID] = []
|
||||||
|
if self._file_ids:
|
||||||
|
# задания ссылаются на файл, файл — на текущее задание (use_alter): сначала разрываем обратную ссылку
|
||||||
|
await session.execute(
|
||||||
|
update(StoredFile).where(StoredFile.id.in_(self._file_ids)).values(current_recognition_job_id=None)
|
||||||
|
)
|
||||||
|
documents = select(Document.id).where(Document.stored_file_id.in_(self._file_ids))
|
||||||
|
await session.execute(delete(OsvEntry).where(OsvEntry.document_id.in_(documents)))
|
||||||
|
await session.execute(delete(Document).where(Document.stored_file_id.in_(self._file_ids)))
|
||||||
if self.job_ids:
|
if self.job_ids:
|
||||||
rows = await session.execute(
|
rows = await session.execute(
|
||||||
select(RecognitionJob.result_s3_object_id).where(
|
select(RecognitionJob.result_s3_object_id).where(
|
||||||
@ -330,6 +456,7 @@ class CommittedJobs:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
result_s3_ids = [row[0] for row in rows]
|
result_s3_ids = [row[0] for row in rows]
|
||||||
|
await session.execute(delete(OsvEntry).where(OsvEntry.recognition_job_id.in_(self.job_ids)))
|
||||||
await session.execute(delete(RecognitionJob).where(RecognitionJob.id.in_(self.job_ids)))
|
await session.execute(delete(RecognitionJob).where(RecognitionJob.id.in_(self.job_ids)))
|
||||||
if self._file_ids:
|
if self._file_ids:
|
||||||
await session.execute(delete(StoredFile).where(StoredFile.id.in_(self._file_ids)))
|
await session.execute(delete(StoredFile).where(StoredFile.id.in_(self._file_ids)))
|
||||||
|
|||||||
85
api/tests/test_clients.py
Normal file
85
api/tests/test_clients.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"""`S3Storage.get_bytes` на заглушке Minio (без сети):
|
||||||
|
чтение, закрытие response, `NoSuchKey` → `ObjectNotFoundError`."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from miniopy_async.error import S3Error
|
||||||
|
|
||||||
|
from src.core.clients import S3Storage
|
||||||
|
from src.core.storage import ObjectNotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
class StubResponse:
|
||||||
|
"""Ответ `aiohttp` с флагами закрытия."""
|
||||||
|
|
||||||
|
def __init__(self, data: bytes, *, fail_read: bool = False) -> None:
|
||||||
|
self._data = data
|
||||||
|
self._fail_read = fail_read
|
||||||
|
self.closed = False
|
||||||
|
self.released = False
|
||||||
|
|
||||||
|
async def read(self) -> bytes:
|
||||||
|
if self._fail_read:
|
||||||
|
raise ConnectionError("обрыв чтения")
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
async def release(self) -> None:
|
||||||
|
self.released = True
|
||||||
|
|
||||||
|
|
||||||
|
class StubMinio:
|
||||||
|
def __init__(self, response: StubResponse | None = None, error: Exception | None = None) -> None:
|
||||||
|
self._response = response
|
||||||
|
self._error = error
|
||||||
|
self.calls: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
async def get_object(self, bucket: str, obj_name: str) -> StubResponse:
|
||||||
|
self.calls.append((bucket, obj_name))
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
assert self._response is not None # noqa: S101
|
||||||
|
return self._response
|
||||||
|
|
||||||
|
|
||||||
|
def _s3_error(code: str) -> S3Error:
|
||||||
|
return S3Error(code, "сообщение", None, None, None, MagicMock())
|
||||||
|
|
||||||
|
|
||||||
|
def _storage(minio: StubMinio) -> S3Storage:
|
||||||
|
client: Any = minio
|
||||||
|
return S3Storage(client, "arm-ka")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_bytes_returns_content_and_closes_response() -> None:
|
||||||
|
response = StubResponse(b"payload")
|
||||||
|
minio = StubMinio(response)
|
||||||
|
|
||||||
|
assert await _storage(minio).get_bytes("uploads/a/b.xls") == b"payload"
|
||||||
|
assert minio.calls == [("arm-ka", "uploads/a/b.xls")]
|
||||||
|
assert response.closed is True
|
||||||
|
assert response.released is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_bytes_closes_response_when_read_fails() -> None:
|
||||||
|
response = StubResponse(b"", fail_read=True)
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionError):
|
||||||
|
await _storage(StubMinio(response)).get_bytes("k")
|
||||||
|
assert response.closed is True
|
||||||
|
assert response.released is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_bytes_no_such_key_is_object_not_found() -> None:
|
||||||
|
with pytest.raises(ObjectNotFoundError):
|
||||||
|
await _storage(StubMinio(error=_s3_error("NoSuchKey"))).get_bytes("missing")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_bytes_other_s3_error_is_not_swallowed() -> None:
|
||||||
|
with pytest.raises(S3Error) as caught:
|
||||||
|
await _storage(StubMinio(error=_s3_error("AccessDenied"))).get_bytes("k")
|
||||||
|
assert caught.value.code == "AccessDenied"
|
||||||
@ -15,7 +15,10 @@ API_DIR = Path(__file__).resolve().parent.parent
|
|||||||
def clean_env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch:
|
def clean_env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch:
|
||||||
"""Убирает из окружения процесса переменные, которые перебили бы значения из env-файла."""
|
"""Убирает из окружения процесса переменные, которые перебили бы значения из env-файла."""
|
||||||
for key in list(os.environ):
|
for key in list(os.environ):
|
||||||
if key.startswith(("OPENBAO__", "AUTH_", "OUTBOX_", "ACK_", "SERVER__")) or key in {"NATS_ENABLED", "DEBUG"}:
|
if key.startswith(("OPENBAO__", "AUTH_", "OUTBOX_", "ACK_", "SERVER__", "UPLOAD_", "MAPPING_")) or key in {
|
||||||
|
"NATS_ENABLED",
|
||||||
|
"DEBUG",
|
||||||
|
}:
|
||||||
monkeypatch.delenv(key)
|
monkeypatch.delenv(key)
|
||||||
return monkeypatch
|
return monkeypatch
|
||||||
|
|
||||||
@ -54,6 +57,7 @@ def test_defaults(clean_env: pytest.MonkeyPatch) -> None:
|
|||||||
assert s.AUTH_EMAIL_CLAIM == "email"
|
assert s.AUTH_EMAIL_CLAIM == "email"
|
||||||
assert s.NATS_ENABLED is False
|
assert s.NATS_ENABLED is False
|
||||||
assert (s.OUTBOX_POLL_SECONDS, s.ACK_TIMEOUT_SECONDS, s.OUTBOX_MAX_REPUBLISH) == (5, 30, 3)
|
assert (s.OUTBOX_POLL_SECONDS, s.ACK_TIMEOUT_SECONDS, s.OUTBOX_MAX_REPUBLISH) == (5, 30, 3)
|
||||||
|
assert (s.UPLOAD_MAX_BYTES, s.MAPPING_POLL_SECONDS, s.MAPPING_MAX_ATTEMPTS) == (50 * 1024 * 1024, 5, 3)
|
||||||
assert s.OPENBAO.NATS.NATS_SEED == ""
|
assert s.OPENBAO.NATS.NATS_SEED == ""
|
||||||
assert s.OPENBAO.NATS.NATS_JWT == ""
|
assert s.OPENBAO.NATS.NATS_JWT == ""
|
||||||
assert s.OPENBAO.MINIO.MINIO_SECURE is True
|
assert s.OPENBAO.MINIO.MINIO_SECURE is True
|
||||||
@ -90,6 +94,9 @@ REQUIRED_ENV = {
|
|||||||
("ACK_TIMEOUT_SECONDS", "0"),
|
("ACK_TIMEOUT_SECONDS", "0"),
|
||||||
("ACK_TIMEOUT_SECONDS", "-5"),
|
("ACK_TIMEOUT_SECONDS", "-5"),
|
||||||
("OUTBOX_MAX_REPUBLISH", "-1"),
|
("OUTBOX_MAX_REPUBLISH", "-1"),
|
||||||
|
("UPLOAD_MAX_BYTES", "0"),
|
||||||
|
("MAPPING_POLL_SECONDS", "0"),
|
||||||
|
("MAPPING_MAX_ATTEMPTS", "0"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_exchange_settings_reject_invalid(clean_env: pytest.MonkeyPatch, key: str, value: str) -> None:
|
def test_exchange_settings_reject_invalid(clean_env: pytest.MonkeyPatch, key: str, value: str) -> None:
|
||||||
|
|||||||
@ -1,19 +1,40 @@
|
|||||||
"""Фабрики справочников и фикстуры `app` / `auth_client` / `seeded` (задача 1.3): проверка до API-тестов."""
|
"""Фабрики справочников и фикстуры `app` / `auth_client` / `seeded` (задача 1.3): проверка до API-тестов."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from src.core.storage import ObjectNotFoundError
|
||||||
from src.models.access import UserRole
|
from src.models.access import UserRole
|
||||||
from src.models.enums import EntityType, RelatedGroupKind
|
from src.models.enums import (
|
||||||
from tests.conftest import AuthClientFactory
|
DocumentStatus,
|
||||||
|
DocumentType,
|
||||||
|
EntityType,
|
||||||
|
FileStatus,
|
||||||
|
FileType,
|
||||||
|
PeriodQuarter,
|
||||||
|
RecognitionPipeline,
|
||||||
|
RecognitionStatus,
|
||||||
|
RelatedGroupKind,
|
||||||
|
S3BucketRole,
|
||||||
|
)
|
||||||
|
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
|
||||||
|
from src.models.osv import OsvEntry
|
||||||
|
from tests.conftest import AuthClientFactory, FakeStorage
|
||||||
from tests.factories import (
|
from tests.factories import (
|
||||||
|
CommittedJobs,
|
||||||
make_company_group,
|
make_company_group,
|
||||||
|
make_document,
|
||||||
make_legal_entity,
|
make_legal_entity,
|
||||||
make_membership,
|
make_membership,
|
||||||
make_regional_office,
|
make_regional_office,
|
||||||
make_related_group,
|
make_related_group,
|
||||||
|
make_result_object,
|
||||||
|
make_stored_file,
|
||||||
make_user,
|
make_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -110,3 +131,128 @@ async def test_auth_client_sees_data_created_by_factories(
|
|||||||
response = await client.get("/me")
|
response = await client.get("/me")
|
||||||
assert response.status_code == HTTP_OK, response.text
|
assert response.status_code == HTTP_OK, response.text
|
||||||
assert response.json()["regional_office"]["id"] == str(office.id)
|
assert response.json()["regional_office"]["id"] == str(office.id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_make_stored_file_builds_file_job_chain(db_session: AsyncSession) -> None:
|
||||||
|
entity = await make_legal_entity(db_session)
|
||||||
|
file, job = await make_stored_file(db_session, entity=entity)
|
||||||
|
|
||||||
|
assert file.legal_entity_id == entity.id
|
||||||
|
assert file.status == FileStatus.UPLOADED
|
||||||
|
assert file.file_type == FileType.XLS
|
||||||
|
assert file.original_name == "osv.xls"
|
||||||
|
assert file.content_hash is None
|
||||||
|
assert file.current_recognition_job_id == job.id
|
||||||
|
assert job.file_id == file.id
|
||||||
|
assert job.status == RecognitionStatus.queued
|
||||||
|
assert job.pipeline == RecognitionPipeline.tabular
|
||||||
|
s3 = await db_session.get(S3Object, file.s3_object_id)
|
||||||
|
assert s3 is not None
|
||||||
|
assert s3.bucket_role == S3BucketRole.uploads
|
||||||
|
assert s3.key.endswith("/osv.xls")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_make_stored_file_overrides_and_image_pipeline(db_session: AsyncSession) -> None:
|
||||||
|
entity = await make_legal_entity(db_session)
|
||||||
|
office = await make_regional_office(db_session)
|
||||||
|
user = await make_user(db_session, email="uploader@armka.local", office=office)
|
||||||
|
file, job = await make_stored_file(
|
||||||
|
db_session,
|
||||||
|
entity=entity,
|
||||||
|
status=FileStatus.PROCESSING,
|
||||||
|
file_type=FileType.PDF,
|
||||||
|
original_name="bb.pdf",
|
||||||
|
content_hash="a" * 64,
|
||||||
|
job_status=RecognitionStatus.done,
|
||||||
|
s3_key="uploads/custom/bb.pdf",
|
||||||
|
uploaded_by=user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (file.status, file.file_type, file.content_hash, file.uploaded_by) == (
|
||||||
|
FileStatus.PROCESSING,
|
||||||
|
FileType.PDF,
|
||||||
|
"a" * 64,
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
assert job.pipeline == RecognitionPipeline.image
|
||||||
|
assert job.status == RecognitionStatus.done
|
||||||
|
s3 = await db_session.get(S3Object, file.s3_object_id)
|
||||||
|
assert s3 is not None
|
||||||
|
assert s3.key == "uploads/custom/bb.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_make_document_defaults_and_uniqueness(db_session: AsyncSession) -> None:
|
||||||
|
entity = await make_legal_entity(db_session)
|
||||||
|
file, job = await make_stored_file(db_session, entity=entity)
|
||||||
|
document = await make_document(db_session, file=file, job=job)
|
||||||
|
other = await make_document(db_session, file=file, job=job, account="62", status=DocumentStatus.NEEDS_CHECK)
|
||||||
|
|
||||||
|
assert document.stored_file_id == file.id
|
||||||
|
assert document.legal_entity_id == entity.id
|
||||||
|
assert document.document_type == DocumentType.OSV
|
||||||
|
assert (document.osv_account_code, document.period_year, document.period_quarter) == ("60", 2025, PeriodQuarter.Q1)
|
||||||
|
assert document.status == DocumentStatus.RECOGNIZED
|
||||||
|
assert document.is_cumulative is True
|
||||||
|
assert other.status == DocumentStatus.NEEDS_CHECK
|
||||||
|
assert other.id != document.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_make_result_object_stores_json_and_links_job(db_session: AsyncSession) -> None:
|
||||||
|
entity = await make_legal_entity(db_session)
|
||||||
|
file, job = await make_stored_file(db_session, entity=entity)
|
||||||
|
storage = FakeStorage()
|
||||||
|
result = {"schema_version": "1.0", "значение": "1.50"}
|
||||||
|
|
||||||
|
s3 = await make_result_object(db_session, job=job, storage=storage, result=result)
|
||||||
|
|
||||||
|
key = f"results/{file.id}/{job.id}/result.json"
|
||||||
|
assert s3.key == key
|
||||||
|
assert s3.bucket == storage.bucket
|
||||||
|
assert s3.bucket_role == S3BucketRole.results
|
||||||
|
assert job.result_s3_object_id == s3.id
|
||||||
|
assert json.loads(await storage.get_bytes(key)) == result
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fake_storage_get_bytes_missing_object() -> None:
|
||||||
|
storage = FakeStorage()
|
||||||
|
await storage.put_object("a", b"x")
|
||||||
|
assert await storage.get_bytes("a") == b"x"
|
||||||
|
with pytest.raises(ObjectNotFoundError):
|
||||||
|
await storage.get_bytes("b")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_committed_jobs_cleanup_removes_documents_and_entries(
|
||||||
|
committed_sessionmaker: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
jobs = CommittedJobs(committed_sessionmaker)
|
||||||
|
job_id = await jobs.make_queued_job()
|
||||||
|
try:
|
||||||
|
async with committed_sessionmaker() as session, session.begin():
|
||||||
|
job = await session.get(RecognitionJob, job_id)
|
||||||
|
assert job is not None
|
||||||
|
file = await session.get(StoredFile, job.file_id)
|
||||||
|
assert file is not None
|
||||||
|
file.current_recognition_job_id = job.id
|
||||||
|
document = await make_document(session, file=file, job=job)
|
||||||
|
session.add(
|
||||||
|
OsvEntry(
|
||||||
|
recognition_job_id=job.id,
|
||||||
|
document_id=document.id,
|
||||||
|
owner_legal_entity_id=file.legal_entity_id,
|
||||||
|
row_number=1,
|
||||||
|
closing_debit=Decimal("1.00"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
file_id = file.id
|
||||||
|
finally:
|
||||||
|
await jobs.cleanup()
|
||||||
|
|
||||||
|
async with committed_sessionmaker() as session:
|
||||||
|
assert await session.get(StoredFile, file_id) is None
|
||||||
|
assert not (await session.execute(select(Document).where(Document.stored_file_id == file_id))).all()
|
||||||
|
assert not (await session.execute(select(OsvEntry).where(OsvEntry.recognition_job_id == job_id))).all()
|
||||||
|
|||||||
@ -82,9 +82,9 @@ async def make_result_object(session, *, job: RecognitionJob, storage: FakeStora
|
|||||||
# CommittedJobs.cleanup(): перед stored_file удалять osv_entry, document (по file_id)
|
# CommittedJobs.cleanup(): перед stored_file удалять osv_entry, document (по file_id)
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 1:** тесты: `get_bytes` возвращает байты и закрывает response (заглушка с флагами), NoSuchKey → `ObjectNotFoundError`; фабрики создают цепочку и `cleanup` удаляет документы.
|
- [x] **Step 1:** тесты: `get_bytes` возвращает байты и закрывает response (заглушка с флагами), NoSuchKey → `ObjectNotFoundError`; фабрики создают цепочку и `cleanup` удаляет документы.
|
||||||
- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_factories.py -q`.
|
- [x] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_factories.py -q`.
|
||||||
- [ ] **Step 5: Commit** `chore: S3 get_bytes, протокол ObjectStorage, фабрики файлов/документов, настройки загрузки и маппинга`.
|
- [x] **Step 5: Commit** `chore: S3 get_bytes, протокол ObjectStorage, фабрики файлов/документов, настройки загрузки и маппинга`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
`dbml_parity.DBML_FILE_NAME`, `HEAD` в `test_migrations`, `test_enums`, `test_check_dbml_script` (фиктивная 5.16); нормализация
|
`dbml_parity.DBML_FILE_NAME`, `HEAD` в `test_migrations`, `test_enums`, `test_check_dbml_script` (фиктивная 5.16); нормализация
|
||||||
email в нижний регистр в seed (`_apply_users`) и в `users_admin.create_user/update_user`; тесты seed/admin на регистр.
|
email в нижний регистр в seed (`_apply_users`) и в `users_admin.create_user/update_user`; тесты seed/admin на регистр.
|
||||||
Проверка: `make dbml-check` пуст; `make migration-cycle`.
|
Проверка: `make dbml-check` пуст; `make migration-cycle`.
|
||||||
- [ ] 1.2 **S3 и фабрики.** `S3Storage.get_bytes(key) -> bytes` (+ закрытие response), `ObjectStorage`-протокол с `get_bytes`;
|
- [x] 1.2 **S3 и фабрики.** `S3Storage.get_bytes(key) -> bytes` (+ закрытие response), `ObjectStorage`-протокол с `get_bytes`;
|
||||||
`FakeStorage.get_bytes`; `tests/factories.py`: `make_stored_file(session, *, entity, status, content_hash, job_status)`,
|
`FakeStorage.get_bytes`; `tests/factories.py`: `make_stored_file(session, *, entity, status, content_hash, job_status)`,
|
||||||
`make_document`, `make_entries_from_result(session, file, result_json)`; `CommittedJobs.cleanup()` удаляет `osv_entry`,
|
`make_document`, `make_entries_from_result(session, file, result_json)`; `CommittedJobs.cleanup()` удаляет `osv_entry`,
|
||||||
`document`. Настройки `UPLOAD_MAX_BYTES` (50 МиБ), `MAPPING_POLL_SECONDS` (5), `MAPPING_MAX_ATTEMPTS` (3) + `.env.example`/`.env.test`.
|
`document`. Настройки `UPLOAD_MAX_BYTES` (50 МиБ), `MAPPING_POLL_SECONDS` (5), `MAPPING_MAX_ATTEMPTS` (3) + `.env.example`/`.env.test`.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user