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:
Andrey Epifancev 2026-10-05 21:35:41 +04:00
parent ab606dc59a
commit 2d69a9e867
14 changed files with 446 additions and 20 deletions

View File

@ -57,3 +57,9 @@ OUTBOX_MAX_REPUBLISH=3
# Импорт справочников из выгрузки ЕЦП CRM: предельный размер файла в байтах (20 МБ), больше — ответ 413
IMPORT_MAX_BYTES=20971520
# Загрузка первичных файлов (ББ/ОФР/ОСВ): предельный размер файла в байтах (50 МБ), больше — ответ 413
UPLOAD_MAX_BYTES=52428800
# Маппинг результата распознавания: период опроса готовых заданий (секунды) и число попыток до отметки ошибки
MAPPING_POLL_SECONDS=5
MAPPING_MAX_ATTEMPTS=3

View File

@ -50,3 +50,8 @@ OUTBOX_MAX_REPUBLISH=3
# Импорт справочников: предельный размер файла (20 МБ), как в .env.example
IMPORT_MAX_BYTES=20971520
# Загрузка первичных файлов и маппинг результатов: значения как в .env.example
UPLOAD_MAX_BYTES=52428800
MAPPING_POLL_SECONDS=5
MAPPING_MAX_ATTEMPTS=3

View File

@ -24,3 +24,4 @@ filterwarnings =
markers =
integration: тесты, которым нужны PostgreSQL/NATS (docker compose); запускаются отдельно от юнит-тестов
fixtures: тесты на реальных данных из ../fixtures (внутренний доступ)
e2e: сквозные тесты Среза 2c на реальных данных (NATS + tabular-parser + MinIO + PostgreSQL); запускаются отдельно

View File

@ -5,6 +5,11 @@
from io import BytesIO
from miniopy_async import Minio
from miniopy_async.error import S3Error
from src.core.storage import ObjectNotFoundError
NO_SUCH_KEY = "NoSuchKey"
class S3Storage:
@ -26,3 +31,17 @@ class S3Storage:
return response
except:
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()

View File

@ -87,6 +87,13 @@ class Settings(BaseSettings):
# Импорт справочников из выгрузки ЕЦП (§13.3): предельный размер файла, больше — 413
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")
@classmethod
def _split_origins(cls, value: Any) -> Any:

23
api/src/core/storage.py Normal file
View 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`."""
...

View File

@ -19,7 +19,7 @@ import unicodedata
import uuid
from collections import defaultdict
from pathlib import PurePosixPath
from typing import Any, Protocol
from typing import Any
from fastapi import HTTPException, status
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.exceptions import DomainValidationError
from src.core.config import Settings
from src.core.storage import ObjectStorage
from src.domain.directory_import import (
AddMember,
CreateEntity,
@ -74,14 +75,6 @@ MAX_FILENAME_LENGTH = 200 # символов: ключ S3 `uploads/imports/{uui
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:
"""413, если файл больше `IMPORT_MAX_BYTES`."""
if size > limit:

View File

@ -38,6 +38,7 @@ from src.api.router import build_api_router
from src.core.auth import build_verifier
from src.core.config import get_settings
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.services.session_audit import SessionAudit
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:
"""Заглушка `S3Storage`: тот же интерфейс `bucket`/`put_object(obj_name, file_data)`, объекты — в словаре."""
"""Заглушка `S3Storage` (протокол `ObjectStorage`): `bucket`/`put_object`/`get_bytes`, объекты — в словаре."""
def __init__(self, bucket: str = "arm-ka") -> None:
self.bucket = bucket
@ -214,6 +215,12 @@ class FakeStorage:
async def put_object(self, obj_name: str, file_data: bytes) -> None:
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
async def committed_sessionmaker() -> AsyncIterator[async_sessionmaker[AsyncSession]]:

View File

@ -4,6 +4,9 @@
`make_user` — справочники Среза 1 (`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`): для фоновых процессов обмена, которые
работают в собственных транзакциях и не видят чужую незафиксированную транзакцию. Всё созданное удаляется
`cleanup()` в порядке FK, включая строки `s3_object` файлов результата, зарегистрированные inbox'ом.
@ -11,12 +14,13 @@
import datetime
import decimal
import json
import secrets
import uuid
from collections.abc import Sequence
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 src.models.access import AppUser, Role, UserRole
@ -33,6 +37,7 @@ from src.models.enums import (
EntityType,
FileStatus,
FileType,
PeriodQuarter,
RecognitionPipeline,
RecognitionStatus,
RelatedGroupKind,
@ -40,8 +45,11 @@ from src.models.enums import (
)
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
from src.models.osv import OsvEntry
from tests.conftest import FakeStorage
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:
@ -159,6 +167,116 @@ async def make_user( # noqa: PLR0913 - сигнатура фабрики фик
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:
"""Строка ОСВ с минимальной цепочкой FK (ЮЛ, s3_object, stored_file, recognition_job, document); `flush`.
@ -320,9 +438,17 @@ class CommittedJobs:
self._extra_s3_ids.append(s3_object_id)
async def cleanup(self) -> None:
"""Удаляет всё созданное в порядке FK (файлы результата, на которые ссылаются задания, — после заданий)."""
"""Удаляет всё созданное в порядке FK: строки ОСВ и документы файлов → задания → файлы → объекты S3 и ЮЛ."""
async with self._session_factory() as session, session.begin():
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:
rows = await session.execute(
select(RecognitionJob.result_s3_object_id).where(
@ -330,6 +456,7 @@ class CommittedJobs:
)
)
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)))
if self._file_ids:
await session.execute(delete(StoredFile).where(StoredFile.id.in_(self._file_ids)))

85
api/tests/test_clients.py Normal file
View 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"

View File

@ -15,7 +15,10 @@ API_DIR = Path(__file__).resolve().parent.parent
def clean_env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch:
"""Убирает из окружения процесса переменные, которые перебили бы значения из env-файла."""
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)
return monkeypatch
@ -54,6 +57,7 @@ def test_defaults(clean_env: pytest.MonkeyPatch) -> None:
assert s.AUTH_EMAIL_CLAIM == "email"
assert s.NATS_ENABLED is False
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_JWT == ""
assert s.OPENBAO.MINIO.MINIO_SECURE is True
@ -90,6 +94,9 @@ REQUIRED_ENV = {
("ACK_TIMEOUT_SECONDS", "0"),
("ACK_TIMEOUT_SECONDS", "-5"),
("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:

View File

@ -1,19 +1,40 @@
"""Фабрики справочников и фикстуры `app` / `auth_client` / `seeded` (задача 1.3): проверка до API-тестов."""
import json
from decimal import Decimal
import pytest
from sqlalchemy import select
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.enums import EntityType, RelatedGroupKind
from tests.conftest import AuthClientFactory
from src.models.enums import (
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 (
CommittedJobs,
make_company_group,
make_document,
make_legal_entity,
make_membership,
make_regional_office,
make_related_group,
make_result_object,
make_stored_file,
make_user,
)
@ -110,3 +131,128 @@ async def test_auth_client_sees_data_created_by_factories(
response = await client.get("/me")
assert response.status_code == HTTP_OK, response.text
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()

View File

@ -82,9 +82,9 @@ async def make_result_object(session, *, job: RecognitionJob, storage: FakeStora
# CommittedJobs.cleanup(): перед stored_file удалять osv_entry, document (по file_id)
```
- [ ] **Step 1:** тесты: `get_bytes` возвращает байты и закрывает response (заглушка с флагами), NoSuchKey → `ObjectNotFoundError`; фабрики создают цепочку и `cleanup` удаляет документы.
- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_factories.py -q`.
- [ ] **Step 5: Commit** `chore: S3 get_bytes, протокол ObjectStorage, фабрики файлов/документов, настройки загрузки и маппинга`.
- [x] **Step 1:** тесты: `get_bytes` возвращает байты и закрывает response (заглушка с флагами), NoSuchKey → `ObjectNotFoundError`; фабрики создают цепочку и `cleanup` удаляет документы.
- [x] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_factories.py -q`.
- [x] **Step 5: Commit** `chore: S3 get_bytes, протокол ObjectStorage, фабрики файлов/документов, настройки загрузки и маппинга`.
---

View File

@ -11,7 +11,7 @@
`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 на регистр.
Проверка: `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)`,
`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`.