arm-scan-gateway/api/tests/unit/test_worker.py
Andrey Epifancev ea49abd03b Срез 0 [5.3]: воркер шлюза (src/core/worker.py) и unit-тесты сценариев spec
on_request проверяет тело (SCHEMA_MISMATCH без ack, R-16), дедуплицирует по job_id
(задания в работе + LRU DONE_CACHE_SIZE, R-17) и запускает задачу: ack до чтения S3 и
до ожидания слота, семафор SCAN_MAX_INFLIGHT, handle. handle: S3 (один бакет, R-19) ->
горячая папка через asyncio.to_thread (R-15) -> опрос до min(reply_deadline, старт +
SCAN_TIMEOUT_SECONDS) (R-14, R-20) -> raw/{job_id}.json до нормализации (R-18) ->
нормализатор -> results/{file_id}/{job_id}/result.json -> конверт. Таблица ошибок D-7,
публикация через NatsGateway с повторами, PublishFailed логируется. Текст ошибки
очищается от одиночных суррогатов и ограничен 4000 символами (T10.1, T10.2).

Тесты: фейки S3/NATS с общим журналом событий, настоящий LocalHotFolder и process_once
эмулятора; каждый конверт проверяется JSON Schema контракта. Фикстура log_lines в
conftest перехватывает loguru. src.core.worker и src.core.health — в строгом mypy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 03:48:37 +04:00

900 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Воркер шлюза (`src.core.worker`, задача 5.3): сценарии spec image-recognition-gateway, D-2, D-3, D-7, R-14..R-20.
Без Docker и сети: S3 — словарь в памяти (ошибки — классы `src.core.clients`), NATS — настоящий `NatsGateway` поверх
фейкового клиента nats-py (тело, `Nats-Msg-Id`, повторы и `PublishFailed` — как в проде), горячая папка — настоящий
`LocalHotFolder` на `tmp_path` под шпионом, ИС Сканирование — `process_once` эмулятора (задержка 0) в фоновой задаче.
Общий журнал событий (`events`) упорядочивает публикации, чтения S3 и запись во входящий каталог.
Каждый опубликованный конверт проверяется JSON Schema `recognition-result-message.schema.json`.
"""
import asyncio
import json
import shutil
import uuid
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
from jsonschema import Draft202012Validator, FormatChecker
from nats import errors as nats_errors
from src.contract import (
NATS_MSG_ID_HEADER,
RESULT_SUBJECT,
ErrorCode,
MessageError,
ResultStatus,
StatementDocument,
)
from src.core.clients import JSON_CONTENT_TYPE, OCTET_STREAM, S3ObjectNotFoundError, S3WriteError
from src.core.config import Settings, get_settings
from src.core.nats import NatsGateway
from src.core.worker import MAX_ERROR_MESSAGE_CHARS, Worker
from src.gateway.hotfolder import LocalHotFolder
from src.normalizer import NOT_IMPLEMENTED_MESSAGE, NormalizeOutcome, NullNormalizer
from src.scan_emulator import process_once
API_DIR = Path(__file__).resolve().parents[2]
CONTRACTS_DIR = API_DIR / "contracts"
# `<корень scan-gateway>/../fixtures/ocr` (workspace и worktree `.wt/<срез>/`), как в тестах эмулятора
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
BUCKET = "arm-ka"
PDF_BYTES = b"%PDF-1.4\n% scan-gateway test\n\x00\x01\x02binary"
MESSAGE_VALIDATOR = Draft202012Validator(
json.loads((CONTRACTS_DIR / "recognition-result-message.schema.json").read_text(encoding="utf-8")),
format_checker=FormatChecker(),
)
RESULT_VALIDATOR = Draft202012Validator(
json.loads((CONTRACTS_DIR / "recognition-result.schema.json").read_text(encoding="utf-8")),
format_checker=FormatChecker(),
)
STATEMENT_EXAMPLE = json.loads((CONTRACTS_DIR / "examples" / "result-statement-minimal.json").read_text("utf-8"))
# Синтетический успешный ответ ИС (R-10): форма `fixtures/ocr/aep_5.json`, `error = null`, непустой `data_ocr`
SUCCESS_ANSWER: dict[str, Any] = {
"file_uid": "ok_doc",
"error": None,
"data_ocr": {
"label": {"form_name": "Бухгалтерский баланс", "confidence": 98.0},
"organization": {"form_name": "ООО Ромашка", "confidence": 97.5},
"inn": {"form_name": "7700000000", "confidence": 99.0},
"units": {"form_name": "тыс. руб.", "confidence": 95.0},
"date": {"form_name": "30.09.2025", "confidence": 96.0},
"table_structure": [{"line_code": "1600", "line_name": "БАЛАНС", "values": ["5678", "-12.3"]}],
},
}
# Логи воркера перехватываются (`log_lines` из conftest): вывод pytest чистый
pytestmark = pytest.mark.usefixtures("log_lines")
# --- фейки --------------------------------------------------------------------------------------------------------
class FakeStorage:
"""S3 в памяти с интерфейсом `S3Storage`: get_bytes / put_bytes / put_json и ошибками `src.core.clients`."""
def __init__(self, events: list[tuple[str, str]], bucket: str = BUCKET) -> None:
self.events = events
self.bucket = bucket
self.objects: dict[tuple[str, str], bytes] = {}
self.content_types: dict[tuple[str, str], str] = {}
self.get_calls: list[tuple[str, str]] = []
self.fail_put_prefixes: tuple[str, ...] = ()
async def get_bytes(self, key: str, *, bucket: str | None = None) -> bytes:
bucket_name = bucket or self.bucket
self.get_calls.append((bucket_name, key))
self.events.append(("s3_get", key))
try:
return self.objects[(bucket_name, key)]
except KeyError:
raise S3ObjectNotFoundError(
f"S3: объект {bucket_name}/{key} не прочитан (NoSuchKey)", bucket=bucket_name, key=key
) from None
async def put_bytes(self, key: str, data: bytes, content_type: str = OCTET_STREAM) -> None:
if self.fail_put_prefixes and key.startswith(self.fail_put_prefixes):
raise S3WriteError(
f"S3: объект {self.bucket}/{key} не записан (ConnectionError)", bucket=self.bucket, key=key
)
self.objects[(self.bucket, key)] = data
self.content_types[(self.bucket, key)] = content_type
self.events.append(("s3_put", key))
async def put_json(self, key: str, obj: Any) -> None:
await self.put_bytes(key, json.dumps(obj, ensure_ascii=False).encode("utf-8"), JSON_CONTENT_TYPE)
class FakeNatsClient:
"""Подмена `nats.aio.client.Client` для настоящего `NatsGateway`: записывает публикации в общий журнал."""
def __init__(self, events: list[tuple[str, str]]) -> None:
self.events = events
self.is_connected = False
self.is_closed = False
self.published: list[tuple[str, bytes, dict[str, str]]] = []
self.fail = False
self.block: asyncio.Event | None = None
async def connect(self, *args: Any, **kwargs: Any) -> None:
self.is_connected = True
async def publish(self, subject: str, payload: bytes = b"", headers: dict[str, str] | None = None) -> None:
if self.block is not None:
await self.block.wait()
if self.fail:
raise nats_errors.ConnectionClosedError
assert headers is not None
self.published.append((subject, payload, headers))
self.events.append(("publish", headers[NATS_MSG_ID_HEADER]))
async def flush(self, timeout: float = 10) -> None:
return None
async def drain(self) -> None:
self.is_connected = False
self.is_closed = True
async def close(self) -> None:
self.is_connected = False
self.is_closed = True
class SpyHotFolder:
"""`LocalHotFolder` под шпионом: журнал записей во входящий каталог, сценарии сбоев `poll`."""
def __init__(self, inner: LocalHotFolder, events: list[tuple[str, str]]) -> None:
self.inner = inner
self.events = events
self.puts: list[str] = []
self.polls = 0
self.poll_errors: list[BaseException] = []
self.poll_always: BaseException | None = None
self.poll_script: list[bytes | None] = []
def put(self, name: str, data: bytes) -> None:
self.inner.put(name, data)
self.puts.append(name)
self.events.append(("put", name))
def poll(self, job_id: str) -> bytes | None:
self.polls += 1
if self.poll_always is not None:
raise self.poll_always
if self.poll_errors:
raise self.poll_errors.pop(0)
if self.poll_script:
return self.poll_script.pop(0)
return self.inner.poll(job_id)
def healthy(self) -> bool:
return self.inner.healthy()
class RaisingNormalizer:
def normalize(self, raw: object) -> NormalizeOutcome:
raise RuntimeError("нормализатор сломан")
class StubNormalizer:
"""Возвращает заданный исход (done / needs_check / error) — в Срезе 0 успешный путь достижим только так."""
def __init__(self, outcome: NormalizeOutcome) -> None:
self.outcome = outcome
self.calls: list[object] = []
def normalize(self, raw: object) -> NormalizeOutcome:
self.calls.append(raw)
return self.outcome
# --- окружение теста ----------------------------------------------------------------------------------------------
@dataclass
class Env:
settings: Settings
root: Path
fixtures: Path
events: list[tuple[str, str]]
storage: FakeStorage
client: FakeNatsClient
gateway: NatsGateway
hotfolder: SpyHotFolder
worker: Worker
local: LocalHotFolder = field(init=False)
def __post_init__(self) -> None:
self.local = self.hotfolder.inner
# запрос -------------------------------------------------------------------------------------------------------
def request( # noqa: PLR0913 - параметры запроса для сценариев
self,
*,
original_name: str = "ok_doc.pdf",
deadline_s: float = 60,
attempt: int = 1,
bucket: str = BUCKET,
data: bytes | None = PDF_BYTES,
job_id: str | None = None,
) -> dict[str, Any]:
job = job_id or str(uuid.uuid4())
file_id = str(uuid.uuid4())
key = f"uploads/{file_id}/{job}/source.bin"
if data is not None:
self.storage.objects[(bucket, key)] = data
deadline = datetime.now(UTC) + timedelta(seconds=deadline_s)
return {
"schema_version": "1.0",
"job_id": job,
"file_id": file_id,
"pipeline": "image",
"file_type": "PDF",
"original_name": original_name,
"s3": {"bucket": bucket, "key": key},
"reply_deadline": deadline.isoformat().replace("+00:00", "Z"),
"attempt": attempt,
}
async def deliver(self, body: dict[str, Any] | bytes) -> None:
data = body if isinstance(body, bytes) else json.dumps(body, ensure_ascii=False).encode("utf-8")
await self.worker.on_request(FakeMsg(data))
async def run(self, body: dict[str, Any], timeout: float = 5) -> dict[str, Any]:
"""Доставить запрос, дождаться окончания всех заданий, вернуть единственный итоговый конверт задания."""
await self.deliver(body)
await asyncio.wait_for(self.worker.wait_idle(), timeout)
results = self.results(body["job_id"])
assert len(results) == 1, results
return results[0][1]
# публикации ---------------------------------------------------------------------------------------------------
def published(self) -> list[tuple[str, dict[str, Any]]]:
"""(Nats-Msg-Id, тело) всех публикаций; каждое тело проверено схемой конверта."""
out = []
for subject, payload, headers in self.client.published:
assert subject == RESULT_SUBJECT
body = json.loads(payload.decode("utf-8"))
MESSAGE_VALIDATOR.validate(body)
assert body["service"]["name"] == "scan-gateway"
assert body["schema_version"] == "1.0"
out.append((headers[NATS_MSG_ID_HEADER], body))
return out
def acks(self, job_id: str) -> list[str]:
return [mid for mid, body in self.published() if body["job_id"] == job_id and body["status"] == "processing"]
def results(self, job_id: str) -> list[tuple[str, dict[str, Any]]]:
return [
(mid, body) for mid, body in self.published() if body["job_id"] == job_id and body["status"] != "processing"
]
def inbox(self) -> list[str]:
if not self.local.input_dir.is_dir():
return []
return sorted(p.name for p in self.local.input_dir.iterdir() if not p.name.startswith("."))
def answer(self, job_id: str, content: bytes) -> None:
path = self.local.answer_path(job_id)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
class FakeMsg:
def __init__(self, data: bytes) -> None:
self.data = data
self.subject = "recognition.image.request"
self.headers: dict[str, str] | None = None
EnvFactory = Callable[..., Awaitable[Env]]
@pytest.fixture
async def make_env(tmp_path: Path) -> AsyncIterator[EnvFactory]:
created: list[Env] = []
async def build(
*,
normalizer: Any = None,
root_exists: bool = True,
clock: Callable[[], datetime] | None = None,
**overrides: Any,
) -> Env:
root = tmp_path / f"hotfolder-{len(created)}"
if root_exists:
root.mkdir()
fixtures = tmp_path / "fixtures"
fixtures.mkdir(exist_ok=True)
(fixtures / "ok_doc.json").write_text(json.dumps(SUCCESS_ANSWER, ensure_ascii=False), encoding="utf-8")
settings = get_settings().model_copy(
update={
"SCAN_HOTFOLDER_PATH": str(root),
"SCAN_POLL_SECONDS": 0.01,
"SCAN_TIMEOUT_SECONDS": 5,
"PUBLISH_RETRIES": 1,
**overrides,
}
)
events: list[tuple[str, str]] = []
storage = FakeStorage(events)
client = FakeNatsClient(events)
gateway = NatsGateway(settings, client_factory=lambda: client, retry_delay=0)
await gateway.connect()
hotfolder = SpyHotFolder(LocalHotFolder(root, settings.SCAN_INPUT_DIR, settings.SCAN_OUTPUT_DIR), events)
kwargs: dict[str, Any] = {}
if clock is not None:
kwargs["clock"] = clock
worker = Worker(
settings,
storage=storage,
publisher=gateway,
hotfolder=hotfolder,
normalizer=normalizer or NullNormalizer(),
**kwargs,
)
worker.start()
env = Env(settings, root, fixtures, events, storage, client, gateway, hotfolder, worker)
created.append(env)
return env
yield build
for env in created:
await env.worker.stop()
@asynccontextmanager
async def emulator(env: Env, fixtures: Path | None = None) -> AsyncIterator[None]:
"""ИС Сканирование: `process_once` (задержка 0) каждые 5 мс, пока открыт контекст."""
async def loop() -> None:
while True:
process_once(env.root, fixtures or env.fixtures, 0)
await asyncio.sleep(0.005)
task = asyncio.create_task(loop())
try:
yield
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task
async def eventually(predicate: Callable[[], bool], timeout: float = 3.0) -> None:
deadline = asyncio.get_running_loop().time() + timeout
while not predicate():
if asyncio.get_running_loop().time() > deadline:
raise AssertionError("условие не выполнилось вовремя")
await asyncio.sleep(0.005)
def assert_error(body: dict[str, Any], code: ErrorCode, *, raw: bool) -> None:
assert body["status"] == "error"
assert body["error"]["code"] == code.value
assert body["error"]["message"]
if raw:
assert body["raw_s3_key"] == f"raw/{body['job_id']}.json"
else:
assert "raw_s3_key" not in body
assert "result_s3" not in body
# --- подтверждение запроса до обработки ---------------------------------------------------------------------------
async def test_ack_is_published_before_s3_read_and_before_file_appears(make_env: EnvFactory) -> None:
env = await make_env()
request = env.request()
async with emulator(env):
await env.run(request)
job_id = request["job_id"]
name = f"{job_id}_ok_doc.pdf"
kinds = [event for event in env.events if event[1] in (f"ack:{job_id}:1", request["s3"]["key"], name)]
assert kinds == [("publish", f"ack:{job_id}:1"), ("s3_get", request["s3"]["key"]), ("put", name)]
async def test_file_in_inbox_is_byte_for_byte_s3_object(make_env: EnvFactory) -> None:
env = await make_env()
request = env.request(original_name="aep_5.pdf")
await env.deliver(request)
path = env.local.input_dir / f"{request['job_id']}_aep_5.pdf"
await eventually(path.exists)
assert path.read_bytes() == PDF_BYTES
await env.worker.stop()
async def test_repeated_request_gets_second_ack_and_one_file(make_env: EnvFactory) -> None:
env = await make_env()
request = env.request(original_name="aep_5.pdf")
await env.deliver(request)
job_id = request["job_id"]
await eventually(lambda: len(env.hotfolder.puts) == 1)
await env.deliver({**request, "attempt": 2}) # ядро не дождалось ack и повторило запрос
await eventually(lambda: len(env.acks(job_id)) == 2)
await asyncio.sleep(0.05)
assert env.acks(job_id) == [f"ack:{job_id}:1", f"ack:{job_id}:2"]
assert env.inbox() == [f"{job_id}_aep_5.pdf"]
assert env.hotfolder.puts == [f"{job_id}_aep_5.pdf"]
assert len(env.storage.get_calls) == 1
async def test_repeated_request_after_result_gets_ack_without_reprocessing(make_env: EnvFactory) -> None:
env = await make_env()
request = env.request()
async with emulator(env):
await env.run(request)
await env.deliver(request)
await asyncio.wait_for(env.worker.wait_idle(), 5)
job_id = request["job_id"]
assert env.acks(job_id) == [f"ack:{job_id}:1", f"ack:{job_id}:1"]
assert len(env.results(job_id)) == 1
assert env.hotfolder.puts == [f"{job_id}_ok_doc.pdf"]
async def test_done_cache_is_bounded_lru(make_env: EnvFactory) -> None:
env = await make_env(DONE_CACHE_SIZE=2)
requests = [env.request() for _ in range(3)]
async with emulator(env):
for request in requests:
await env.run(request)
# первый job_id вытеснен из LRU размера 2 — его повтор обрабатывается заново (в проде — 1000 заданий назад)
await env.deliver(requests[0])
await asyncio.wait_for(env.worker.wait_idle(), 5)
# третий ещё в кэше — повтор только с ack
await env.deliver(requests[2])
await asyncio.wait_for(env.worker.wait_idle(), 5)
assert len(env.results(requests[0]["job_id"])) == 2
assert len(env.results(requests[2]["job_id"])) == 1
assert len(env.acks(requests[2]["job_id"])) == 2
async def test_on_request_returns_without_waiting_for_publication(make_env: EnvFactory) -> None:
env = await make_env()
env.client.block = asyncio.Event()
request = env.request()
await asyncio.wait_for(env.deliver(request), 0.2) # публикация ack висит, а колбэк уже вернулся (T11.4)
assert env.client.published == []
env.client.block.set()
async with emulator(env):
await asyncio.wait_for(env.worker.wait_idle(), 5)
assert len(env.results(request["job_id"])) == 1
# --- результат по контракту ---------------------------------------------------------------------------------------
async def test_success_answer_gives_not_implemented_with_raw_key(make_env: EnvFactory) -> None:
env = await make_env()
request = env.request(attempt=3)
async with emulator(env):
body = await env.run(request)
job_id = request["job_id"]
assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True)
assert body["error"]["message"] == NOT_IMPLEMENTED_MESSAGE
assert body["file_id"] == request["file_id"]
assert [mid for mid, _ in env.published()] == [f"ack:{job_id}:3", f"result:{job_id}:3"]
raw = env.storage.objects[(BUCKET, f"raw/{job_id}.json")]
assert raw == env.local.answer_path(job_id).read_bytes() # как есть, байт в байт
assert json.loads(raw) == {**SUCCESS_ANSWER, "file_uid": job_id}
assert env.storage.content_types[(BUCKET, f"raw/{job_id}.json")] == JSON_CONTENT_TYPE
# сырой ответ сохранён раньше публикации результата
assert env.events.index(("s3_put", f"raw/{job_id}.json")) < env.events.index(("publish", f"result:{job_id}:3"))
assert env.inbox() == [f"{job_id}_ok_doc.pdf.done"]
@pytest.mark.fixtures
async def test_real_fixture_error_is_passed_through(make_env: EnvFactory, tmp_path: Path) -> None:
if not REAL_FIXTURES.is_dir():
pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
real = tmp_path / "real-fixtures"
real.mkdir()
for source in REAL_FIXTURES.glob("*.json"):
shutil.copy(source, real / source.name)
expected = json.loads((real / "aep_5.json").read_text(encoding="utf-8"))["error"]
env = await make_env()
request = env.request(original_name="aep_5.PDF")
async with emulator(env, real):
body = await env.run(request)
assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True)
assert body["error"]["message"] == expected["message"]
assert (BUCKET, f"raw/{request['job_id']}.json") in env.storage.objects
async def test_unknown_name_gets_emulator_invalid_document(make_env: EnvFactory) -> None:
env = await make_env()
request = env.request(original_name="unknown.pdf")
async with emulator(env):
body = await env.run(request)
assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True)
assert "unknown.pdf" in body["error"]["message"]
@pytest.mark.parametrize("status", [ResultStatus.DONE, ResultStatus.NEEDS_CHECK])
async def test_successful_outcome_writes_result_file(make_env: EnvFactory, status: ResultStatus) -> None:
document = StatementDocument.model_validate(STATEMENT_EXAMPLE["documents"][0])
normalizer = StubNormalizer(NormalizeOutcome(status=status, documents=[document]))
env = await make_env(normalizer=normalizer)
request = env.request()
async with emulator(env):
body = await env.run(request)
job_id, file_id = request["job_id"], request["file_id"]
key = f"results/{file_id}/{job_id}/result.json"
assert body["status"] == status.value
assert body["result_s3"] == {"bucket": BUCKET, "key": key}
assert body["result_schema_version"] == "1.0"
assert body["raw_s3_key"] == f"raw/{job_id}.json"
assert "error" not in body
result = json.loads(env.storage.objects[(BUCKET, key)].decode("utf-8"))
RESULT_VALIDATOR.validate(result)
assert result["status"] == status.value
assert result["job_id"] == job_id
assert result["file_id"] == file_id
assert result["service"]["name"] == "scan-gateway"
assert result["raw_s3_key"] == f"raw/{job_id}.json"
assert result["documents"] == STATEMENT_EXAMPLE["documents"]
assert env.storage.content_types[(BUCKET, key)] == JSON_CONTENT_TYPE
assert normalizer.calls == [{**SUCCESS_ANSWER, "file_uid": job_id}]
assert env.events.index(("s3_put", key)) < env.events.index(("publish", f"result:{job_id}:1"))
async def test_result_file_write_failure_is_unknown_error_with_raw_key(make_env: EnvFactory) -> None:
document = StatementDocument.model_validate(STATEMENT_EXAMPLE["documents"][0])
env = await make_env(normalizer=StubNormalizer(NormalizeOutcome(status=ResultStatus.DONE, documents=[document])))
env.storage.fail_put_prefixes = ("results/",)
request = env.request()
async with emulator(env):
body = await env.run(request)
assert_error(body, ErrorCode.UNKNOWN_ERROR, raw=True)
assert "результат" in body["error"]["message"]
# --- сырой ответ и нормализатор -----------------------------------------------------------------------------------
async def test_normalizer_exception_is_unknown_error_with_raw_saved(make_env: EnvFactory) -> None:
env = await make_env(normalizer=RaisingNormalizer())
request = env.request()
async with emulator(env):
body = await env.run(request)
assert_error(body, ErrorCode.UNKNOWN_ERROR, raw=True)
assert "нормализатор" in body["error"]["message"]
assert (BUCKET, f"raw/{request['job_id']}.json") in env.storage.objects
async def test_raw_write_failure_is_unknown_error_without_raw_key(make_env: EnvFactory) -> None:
normalizer = StubNormalizer(
NormalizeOutcome(status=ResultStatus.ERROR, error=MessageError(code="UNREADABLE", message="x"))
)
env = await make_env(normalizer=normalizer)
env.storage.fail_put_prefixes = ("raw/",)
request = env.request()
async with emulator(env):
body = await env.run(request)
assert_error(body, ErrorCode.UNKNOWN_ERROR, raw=False)
assert "сырой ответ" in body["error"]["message"]
assert normalizer.calls == [] # без сохранённого сырого ответа нормализатор не вызывается (D-4)
async def test_answer_not_json_is_invalid_format_and_raw_stored_as_is(make_env: EnvFactory) -> None:
env = await make_env()
request = env.request()
job_id = request["job_id"]
garbage = b"\xff\xfe<html>not json"
await env.deliver(request)
await eventually(lambda: len(env.hotfolder.puts) == 1)
env.answer(job_id, garbage)
await asyncio.wait_for(env.worker.wait_idle(), 5)
[(_, body)] = env.results(job_id)
assert_error(body, ErrorCode.INVALID_FORMAT, raw=True)
assert env.storage.objects[(BUCKET, f"raw/{job_id}.json")] == garbage
assert env.storage.content_types[(BUCKET, f"raw/{job_id}.json")] == OCTET_STREAM
async def test_partially_written_answer_is_reread(make_env: EnvFactory) -> None:
"""ИС пишет ответ не атомарно (SMB): недописанный JSON перечитывается через период опроса."""
env = await make_env()
full = json.dumps({**SUCCESS_ANSWER, "file_uid": "x"}).encode()
env.hotfolder.poll_script = [None, full[:10], full]
body = await env.run(env.request())
assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True)
assert env.storage.objects[(BUCKET, f"raw/{body['job_id']}.json")] == full
# --- таймаут ------------------------------------------------------------------------------------------------------
async def test_timeout_by_scan_timeout_keeps_file_and_never_resends(make_env: EnvFactory) -> None:
env = await make_env(SCAN_TIMEOUT_SECONDS=0.2)
request = env.request(original_name="aep_5.pdf", deadline_s=60)
started = asyncio.get_running_loop().time()
body = await env.run(request)
elapsed = asyncio.get_running_loop().time() - started
assert_error(body, ErrorCode.TIMEOUT, raw=False)
assert 0.15 < elapsed < 2
assert env.inbox() == [f"{request['job_id']}_aep_5.pdf"] # входящий файл остаётся
assert env.hotfolder.puts == [f"{request['job_id']}_aep_5.pdf"] # повторной отправки нет
assert env.hotfolder.polls >= 5
async def test_timeout_by_reply_deadline(make_env: EnvFactory) -> None:
env = await make_env(SCAN_TIMEOUT_SECONDS=300)
request = env.request(deadline_s=0.2)
started = asyncio.get_running_loop().time()
body = await env.run(request)
assert_error(body, ErrorCode.TIMEOUT, raw=False)
assert asyncio.get_running_loop().time() - started < 2
assert len(env.hotfolder.puts) == 1
async def test_deadline_passed_before_sending_gives_timeout_without_file(make_env: EnvFactory) -> None:
env = await make_env()
request = env.request(deadline_s=-1)
body = await env.run(request)
assert_error(body, ErrorCode.TIMEOUT, raw=False)
assert env.hotfolder.puts == []
assert env.storage.get_calls == []
assert env.acks(request["job_id"]) == [f"ack:{request['job_id']}:1"]
async def test_deadline_uses_injected_clock(make_env: EnvFactory) -> None:
"""Дедлайн считается по инжектируемым часам (R-20): часы «убежали» на час — таймаут на первом же опросе."""
shift = {"value": timedelta(0)}
def clock() -> datetime:
return datetime.now(UTC) + shift["value"]
env = await make_env(clock=clock, SCAN_TIMEOUT_SECONDS=60)
request = env.request(deadline_s=120)
await env.deliver(request)
await eventually(lambda: len(env.hotfolder.puts) == 1)
shift["value"] = timedelta(hours=1)
await asyncio.wait_for(env.worker.wait_idle(), 2)
[(_, body)] = env.results(request["job_id"])
assert_error(body, ErrorCode.TIMEOUT, raw=False)
# --- ошибки транспорта --------------------------------------------------------------------------------------------
async def test_missing_hotfolder_root_is_recognition_failed(make_env: EnvFactory) -> None:
env = await make_env(root_exists=False)
request = env.request()
body = await env.run(request)
assert_error(body, ErrorCode.RECOGNITION_FAILED, raw=False)
assert "горячая папка недоступна" in body["error"]["message"]
assert not env.root.exists() # корень не создаётся (R-5)
assert env.hotfolder.healthy() is False
async def test_unusable_original_name_is_recognition_failed(make_env: EnvFactory) -> None:
env = await make_env()
body = await env.run(env.request(original_name=".."))
assert_error(body, ErrorCode.RECOGNITION_FAILED, raw=False)
assert "недопустимое имя файла" in body["error"]["message"]
assert env.hotfolder.puts == []
async def test_transient_poll_errors_do_not_fail_the_job(make_env: EnvFactory) -> None:
env = await make_env()
env.hotfolder.poll_errors = [OSError(5, "STATUS_SHARING_VIOLATION"), ConnectionResetError("smb")]
async with emulator(env):
body = await env.run(env.request())
assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True)
async def test_poll_failing_until_deadline_is_recognition_failed(make_env: EnvFactory) -> None:
env = await make_env(SCAN_TIMEOUT_SECONDS=0.1)
env.hotfolder.poll_always = OSError(13, "STATUS_ACCESS_DENIED")
body = await env.run(env.request())
assert_error(body, ErrorCode.RECOGNITION_FAILED, raw=False)
assert "горячая папка недоступна" in body["error"]["message"]
async def test_poll_failing_sometimes_then_silence_is_timeout(make_env: EnvFactory) -> None:
env = await make_env(SCAN_TIMEOUT_SECONDS=0.1)
env.hotfolder.poll_errors = [OSError(13, "STATUS_ACCESS_DENIED")]
body = await env.run(env.request())
assert_error(body, ErrorCode.TIMEOUT, raw=False)
async def test_missing_s3_object_is_file_read_error(make_env: EnvFactory) -> None:
env = await make_env()
request = env.request(data=None)
body = await env.run(request)
assert_error(body, ErrorCode.FILE_READ_ERROR, raw=False)
assert request["s3"]["key"] in body["error"]["message"]
assert env.hotfolder.puts == []
assert env.acks(request["job_id"]) == [f"ack:{request['job_id']}:1"]
async def test_foreign_bucket_is_file_read_error_without_reading(make_env: EnvFactory) -> None:
env = await make_env()
request = env.request(bucket="other-bucket")
body = await env.run(request)
assert_error(body, ErrorCode.FILE_READ_ERROR, raw=False)
assert "other-bucket" in body["error"]["message"]
assert env.storage.get_calls == []
assert env.acks(request["job_id"]) == [f"ack:{request['job_id']}:1"]
async def test_exception_outside_steps_is_unknown_error(make_env: EnvFactory) -> None:
class BadOutcomeNormalizer:
def normalize(self, raw: object) -> NormalizeOutcome:
return "не исход" # type: ignore[return-value]
env = await make_env(normalizer=BadOutcomeNormalizer())
async with emulator(env):
body = await env.run(env.request())
assert_error(body, ErrorCode.UNKNOWN_ERROR, raw=True)
# --- SCHEMA_MISMATCH без ack (R-16) -------------------------------------------------------------------------------
def _mutations() -> list[tuple[str, Callable[[dict[str, Any]], dict[str, Any]]]]:
return [
("major_2", lambda r: {**r, "schema_version": "2.0"}),
("pipeline_tabular", lambda r: {**r, "pipeline": "tabular", "file_type": "XLSX"}),
("no_s3", lambda r: {k: v for k, v in r.items() if k != "s3"}),
("bad_file_type", lambda r: {**r, "file_type": "DOCX"}),
("naive_deadline", lambda r: {**r, "reply_deadline": "2026-10-03T12:45:00"}),
("attempt_zero", lambda r: {**r, "attempt": 0}),
("empty_name", lambda r: {**r, "original_name": ""}),
]
@pytest.mark.parametrize(("case", "mutate"), _mutations(), ids=[case for case, _ in _mutations()])
async def test_invalid_request_is_schema_mismatch_without_ack(
make_env: EnvFactory, case: str, mutate: Callable[[dict[str, Any]], dict[str, Any]]
) -> None:
env = await make_env()
request = env.request()
body = mutate(request)
await env.deliver(body)
await asyncio.wait_for(env.worker.wait_idle(), 5)
job_id = request["job_id"]
published = env.published()
assert env.acks(job_id) == []
attempt = body["attempt"] if body["attempt"] >= 1 else 1
assert [mid for mid, _ in published] == [f"result:{job_id}:{attempt}"]
assert_error(published[0][1], ErrorCode.SCHEMA_MISMATCH, raw=False)
assert published[0][1]["file_id"] == request["file_id"]
assert env.storage.get_calls == []
assert env.hotfolder.puts == []
@pytest.mark.parametrize(
"data",
[
b"not json at all",
b"\xff\xfe\x00broken utf-8",
b"[1, 2, 3]",
b'"string"',
b"[" * 100_000, # глубокая вложенность: RecursionError парсера не роняет воркер
json.dumps({"schema_version": "1.0", "job_id": "not-a-uuid", "file_id": str(uuid.uuid4())}).encode(),
json.dumps({"schema_version": "1.0", "job_id": str(uuid.uuid4())}).encode(),
json.dumps({"job_id": 5, "file_id": None}).encode(),
],
ids=["text", "bad_utf8", "array", "string", "deep", "bad_job_id", "no_file_id", "wrong_types"],
)
async def test_unparsable_request_without_ids_is_dropped(
make_env: EnvFactory, data: bytes, log_lines: list[str]
) -> None:
env = await make_env()
await env.deliver(data)
await asyncio.wait_for(env.worker.wait_idle(), 5)
assert env.client.published == []
assert env.hotfolder.puts == []
assert any("SCHEMA_MISMATCH" in line for line in log_lines)
@pytest.mark.parametrize(("attempt", "expected"), [(3, 3), (True, 1), ("2", 1), (-1, 1), (2**40, 1)])
async def test_schema_mismatch_attempt_from_body(make_env: EnvFactory, attempt: Any, expected: int) -> None:
env = await make_env()
request = env.request()
await env.deliver({**request, "schema_version": "2.0", "attempt": attempt})
await asyncio.wait_for(env.worker.wait_idle(), 5)
assert [mid for mid, _ in env.published()] == [f"result:{request['job_id']}:{expected}"]
async def test_schema_mismatch_does_not_register_job_id(make_env: EnvFactory) -> None:
"""Исправленный запрос с тем же job_id после SCHEMA_MISMATCH обрабатывается (это не повтор)."""
env = await make_env()
request = env.request()
await env.deliver({**request, "schema_version": "2.0"})
async with emulator(env):
await env.deliver(request)
await asyncio.wait_for(env.worker.wait_idle(), 5)
assert len(env.hotfolder.puts) == 1
# --- публикация, размер и кодировка -------------------------------------------------------------------------------
async def test_publish_failure_is_logged_and_worker_keeps_working(make_env: EnvFactory, log_lines: list[str]) -> None:
env = await make_env()
env.client.fail = True
first = env.request()
async with emulator(env):
await env.deliver(first)
await asyncio.wait_for(env.worker.wait_idle(), 5)
assert env.client.published == []
assert any(line.startswith("ERROR|") and first["job_id"] in line for line in log_lines)
env.client.fail = False
second = env.request()
body = await env.run(second)
assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True)
assert len(env.hotfolder.puts) == 2
async def test_huge_error_message_is_truncated(make_env: EnvFactory) -> None:
huge = "я" * 2_000_000
normalizer = StubNormalizer(
NormalizeOutcome(status=ResultStatus.ERROR, error=MessageError(code="INVALID_DOCUMENT", message=huge))
)
env = await make_env(normalizer=normalizer)
async with emulator(env):
body = await env.run(env.request())
assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True)
message = body["error"]["message"]
assert len(message) == MAX_ERROR_MESSAGE_CHARS
assert message.endswith("…")
assert all(len(payload) < 900_000 for _, payload, _ in env.client.published)
async def test_lone_surrogate_in_is_error_message_does_not_break_publishing(make_env: EnvFactory) -> None:
env = await make_env()
request = env.request()
job_id = request["job_id"]
await env.deliver(request)
await eventually(lambda: len(env.hotfolder.puts) == 1)
env.answer(job_id, b'{"file_uid": "x", "error": {"code": "INVALID_DOCUMENT", "message": "bad \\ud800 tail"}}')
await asyncio.wait_for(env.worker.wait_idle(), 5)
[(_, body)] = env.results(job_id)
assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True)
assert body["error"]["message"].startswith("bad ")
assert body["error"]["message"].endswith(" tail")
async def test_lone_surrogate_in_original_name_does_not_break_publishing(make_env: EnvFactory) -> None:
env = await make_env()
request = env.request(original_name="скан\udc80.pdf")
data = json.dumps(request, ensure_ascii=True).encode("ascii") # в JSON суррогат приходит экранированным
await env.deliver(data)
await asyncio.wait_for(env.worker.wait_idle(), 5)
[(_, body)] = env.results(request["job_id"])
assert body["status"] == "error"
# --- параллелизм и остановка --------------------------------------------------------------------------------------
async def test_five_jobs_with_four_slots(make_env: EnvFactory) -> None:
env = await make_env(SCAN_MAX_INFLIGHT=4)
requests = [env.request() for _ in range(5)]
for request in requests:
await env.deliver(request)
await eventually(lambda: len(env.hotfolder.puts) == 4)
await eventually(lambda: sum(len(env.acks(r["job_id"])) for r in requests) == 5)
await asyncio.sleep(0.05)
assert len(env.hotfolder.puts) == 4 # пятый ждёт слот
first_job = env.hotfolder.puts[0].split("_", 1)[0]
env.answer(first_job, json.dumps({**SUCCESS_ANSWER, "file_uid": first_job}).encode())
await eventually(lambda: len(env.hotfolder.puts) == 5)
assert len(env.results(first_job)) == 1
async def test_stop_cancels_jobs_and_rejects_new_requests(make_env: EnvFactory) -> None:
env = await make_env()
request = env.request()
await env.deliver(request)
await eventually(lambda: len(env.hotfolder.puts) == 1)
await asyncio.wait_for(env.worker.stop(), 1)
assert env.worker.active_jobs == 0
assert env.results(request["job_id"]) == []
await env.deliver(env.request())
await asyncio.wait_for(env.worker.wait_idle(), 1)
assert len(env.client.published) == 1 # только ack первого запроса
await env.worker.stop() # повторная остановка безопасна
async def test_service_info_and_handle_returns_published_message(make_env: EnvFactory) -> None:
from src.contract import RecognitionRequest # noqa: PLC0415
env = await make_env()
request = RecognitionRequest.model_validate(env.request(data=None))
message = await env.worker.handle(request)
assert message.service.name == "scan-gateway"
assert message.service.version == env.settings.VERSION
assert message.error is not None and message.error.code is ErrorCode.FILE_READ_ERROR
[(_, body)] = env.results(str(request.job_id))
assert body == message.to_wire()