Срез 0 [7.4]: интеграционные тесты — наборы ответа ИС на реальных фикстурах (aep_3/aep_6/aep_5.PDF)
Сквозные сценарии с реальными NATS и MinIO: aep_3 (два файла, warnings про _ofr.json, склеенный raw), aep_6 (NOT_IMPLEMENTED без metrics), aep_5.PDF (INVALID_DOCUMENT, повтор запроса), unknown (заглушка эмулятора). settle 0.2 с, SCAN_TMP_DIR передаётся эмулятору. Исправлено устаревшее ожидание data_ocr в сценарии unknown (R-28). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
24af6f1b9a
commit
68389d5cc2
@ -4,13 +4,17 @@
|
||||
(`src.scan_emulator.run` в потоке) работают в процессе теста; NATS 127.0.0.1:4222 и MinIO 127.0.0.1:9000 — из
|
||||
`api/.env.test` (`make up`), горячая папка — tmp-каталог.
|
||||
|
||||
Сценарии (R-10: обе реальные фикстуры `fixtures/ocr/*.json` — ответы ИС с ошибкой `INVALID_DOCUMENT`):
|
||||
1. `aep_5.pdf` (реальная фикстура) → ack `processing`, затем `INVALID_DOCUMENT` с сообщением ИС и `raw_s3_key`;
|
||||
повтор того же запроса во время ожидания ответа ИС и после результата — ещё один ack, второго файла нет;
|
||||
2. `ok_doc.pdf` (синтетический успешный ответ ИС) → ack, затем `NOT_IMPLEMENTED` с `raw_s3_key`;
|
||||
3. `unknown.pdf` (нет фикстуры) → ack, затем `INVALID_DOCUMENT` эмулятора.
|
||||
Каждый конверт проверяется схемой `recognition-result-message`, `service.name` и `Nats-Msg-Id`; сырой ответ в MinIO
|
||||
совпадает с ответом эмулятора байт в байт; входящий файл совпадает с объектом S3.
|
||||
Сценарии (реальные фикстуры `fixtures/ocr`: OQ-76/77/78; эмулятор кладёт в `Output/<job_id>/` весь набор ответа):
|
||||
1. `aep_5.PDF` (ответ-ошибка `INVALID_DOCUMENT`) → ack `processing`, затем `INVALID_DOCUMENT` с сообщением ИС и
|
||||
`raw_s3_key`; повтор запроса во время ожидания ответа ИС и после результата — ещё один ack, второго файла нет;
|
||||
2. `aep_3.pdf` (набор из двух файлов: `<job_id>.json` — успех, `<job_id>_ofr.json` — `INVALID_DOCUMENT`) → ack, затем
|
||||
`NOT_IMPLEMENTED` с `metrics.warnings` про `_ofr.json`; `raw/{job_id}.json` — оба файла (склейка, D-4);
|
||||
3. `aep_6.pdf` (один успешный файл) → `NOT_IMPLEMENTED` без `metrics`;
|
||||
4. `ok_doc.pdf` (синтетический успешный ответ) → ack, затем `NOT_IMPLEMENTED` с `raw_s3_key`;
|
||||
5. `unknown.pdf` (нет фикстуры) → ack, затем `INVALID_DOCUMENT` эмулятора.
|
||||
Каждый конверт проверяется схемой `recognition-result-message`, `service.name` и `Nats-Msg-Id`; склеенный сырой ответ в
|
||||
MinIO содержит ровно те файлы, что лежат в каталоге ответа, в порядке возрастания имени, с телами байт в байт;
|
||||
входящий файл совпадает с объектом S3. Готовность набора — по settle (`SETTLE_SECONDS`, 0.2 с).
|
||||
|
||||
Общая инфраструктура: NATS и MinIO используют и другие сервисы workspace. Чужие конверты на `recognition.result`
|
||||
отсекаются по `job_id` теста; ключи MinIO уникальны (`uploads/itest-<uuid>/…`, `raw/<job_id>.json`) и удаляются.
|
||||
@ -68,7 +72,8 @@ CONNECT_WAIT = 10.0 # секунд на подключение к NATS (тес
|
||||
SCENARIO_WAIT = 20.0 # секунд на ожидание конвертов одного сценария
|
||||
QUIET_PERIOD = 1.0 # секунд тишины после повторного запроса: второго результата быть не должно
|
||||
EMULATOR_DELAY = 0.5 # секунд «распознавания»: окно, в котором повторный запрос приходит во время ожидания ИС
|
||||
POLL_SECONDS = 0.2 # SCAN_POLL_SECONDS шлюза
|
||||
POLL_SECONDS = 0.1 # SCAN_POLL_SECONDS шлюза
|
||||
SETTLE_SECONDS = 0.2 # SCAN_SETTLE_SECONDS шлюза: набор ответа готов, когда не менялся столько секунд
|
||||
TIMEOUT_SECONDS = 15 # SCAN_TIMEOUT_SECONDS: зависший эмулятор даёт TIMEOUT раньше, чем истечёт SCENARIO_WAIT
|
||||
|
||||
MESSAGE_VALIDATOR = Draft202012Validator(
|
||||
@ -156,8 +161,12 @@ class Roundtrip:
|
||||
def input_dir(self) -> Path:
|
||||
return self.root / self.settings.SCAN_INPUT_DIR
|
||||
|
||||
def answer_path(self, job_id: str) -> Path:
|
||||
return self.root / self.settings.SCAN_OUTPUT_DIR / job_id / f"{job_id}.json"
|
||||
def answer_dir(self, job_id: str) -> Path:
|
||||
return self.root / self.settings.SCAN_OUTPUT_DIR / job_id
|
||||
|
||||
def answer_names(self, job_id: str) -> list[str]:
|
||||
"""Имена всех `*.json` каталога ответа по возрастанию (каталог шлюз не удаляет)."""
|
||||
return sorted(p.name for p in self.answer_dir(job_id).glob("*.json"))
|
||||
|
||||
# --- запрос ---------------------------------------------------------------------------------------------------
|
||||
async def request(self, original_name: str, data: bytes) -> dict[str, Any]:
|
||||
@ -223,6 +232,10 @@ class Roundtrip:
|
||||
return body
|
||||
|
||||
# --- проверки -------------------------------------------------------------------------------------------------
|
||||
async def raw(self, job_id: str) -> dict[str, Any]:
|
||||
raw: dict[str, Any] = json.loads(await self.s3.get_bytes(f"raw/{job_id}.json"))
|
||||
return raw
|
||||
|
||||
def inbox(self, job_id: str) -> list[str]:
|
||||
return sorted(p.name for p in self.input_dir.iterdir() if p.name.startswith(job_id))
|
||||
|
||||
@ -245,12 +258,16 @@ class Roundtrip:
|
||||
assert result.body["status"] == "error"
|
||||
assert result.body["file_id"] == body["file_id"]
|
||||
assert result.body["raw_s3_key"] == f"raw/{job_id}.json"
|
||||
raw = json.loads(await self.s3.get_bytes(f"raw/{job_id}.json"))
|
||||
raw = await self.raw(job_id)
|
||||
assert raw["job_id"] == job_id
|
||||
first = raw["files"][0]
|
||||
assert first["name"] == f"{job_id}.json"
|
||||
assert first["response"] == json.loads(self.answer_path(job_id).read_bytes())
|
||||
assert first["response"]["file_uid"] == job_id
|
||||
names = self.answer_names(job_id)
|
||||
assert names, "эмулятор не положил ни одного json"
|
||||
# склейка (D-4): все json каталога ответа, по возрастанию имени, тела совпадают с файлами
|
||||
assert [entry["name"] for entry in raw["files"]] == names
|
||||
for entry in raw["files"]:
|
||||
assert entry["parse_error"] is None
|
||||
assert entry["response"] == json.loads((self.answer_dir(job_id) / entry["name"]).read_bytes())
|
||||
assert entry["response"]["file_uid"] == job_id
|
||||
stat = await self.s3.client.stat_object(self.s3.bucket, f"raw/{job_id}.json")
|
||||
assert stat.content_type == JSON_CONTENT_TYPE
|
||||
# результат-файл пишется только для done/needs_check
|
||||
@ -262,11 +279,13 @@ class Roundtrip:
|
||||
|
||||
|
||||
def _write_fixtures(fixtures: Path) -> None:
|
||||
"""Каталог фикстур эмулятора: копия реальной `aep_5.json` и синтетическая успешная `ok_doc.json` (R-10)."""
|
||||
"""Каталог фикстур эмулятора: копии реальных ответов (`aep_3.json` + `aep_3_ofr.json`, `aep_5.json`, `aep_6.json`)
|
||||
и синтетическая успешная `ok_doc.json` (R-10). Без репозитория fixtures — только `ok_doc.json`."""
|
||||
fixtures.mkdir()
|
||||
template: dict[str, Any] = {"file_uid": "aep_5"}
|
||||
if REAL_FIXTURES.is_dir():
|
||||
shutil.copy(_real_fixture("aep_5", ".json"), fixtures / "aep_5.json")
|
||||
for name in ("aep_3.json", "aep_3_ofr.json", "aep_5.json", "aep_6.json"):
|
||||
shutil.copy(REAL_FIXTURES / name, fixtures / name)
|
||||
template = json.loads((fixtures / "aep_5.json").read_text(encoding="utf-8"))
|
||||
# успех — в форме реальных ответов ИС: `error = {"code": "", "message": ""}`, а не `null` (OQ-77)
|
||||
success = {**template, "file_uid": "ok_doc", "error": {"code": "", "message": ""}, "data_ocr": SUCCESS_DATA_OCR}
|
||||
@ -278,6 +297,7 @@ def _settings(monkeypatch: pytest.MonkeyPatch, root: Path) -> Settings:
|
||||
monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "local")
|
||||
monkeypatch.setenv("SCAN_HOTFOLDER_PATH", str(root))
|
||||
monkeypatch.setenv("SCAN_POLL_SECONDS", str(POLL_SECONDS))
|
||||
monkeypatch.setenv("SCAN_SETTLE_SECONDS", str(SETTLE_SECONDS))
|
||||
monkeypatch.setenv("SCAN_TIMEOUT_SECONDS", str(TIMEOUT_SECONDS))
|
||||
monkeypatch.setenv("NATS_ENABLED", "true")
|
||||
settings = get_settings()
|
||||
@ -304,6 +324,7 @@ def _start_emulator(stack: AsyncExitStack, settings: Settings, root: Path, fixtu
|
||||
hotfolder_path=root,
|
||||
input_dir=settings.SCAN_INPUT_DIR,
|
||||
output_dir=settings.SCAN_OUTPUT_DIR,
|
||||
tmp_dir=settings.SCAN_TMP_DIR,
|
||||
fixtures_dir=fixtures,
|
||||
delay=EMULATOR_DELAY,
|
||||
poll_interval=0.05,
|
||||
@ -366,15 +387,16 @@ async def roundtrip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, log_lines:
|
||||
|
||||
@pytest.mark.fixtures
|
||||
async def test_real_fixture_aep_5_passes_is_error_through_and_repeat_gets_ack(roundtrip: Roundtrip) -> None:
|
||||
data = _real_fixture("aep_5", ".pdf").read_bytes()
|
||||
pdf = _real_fixture("aep_5", ".pdf") # реальное имя `aep_5.PDF`: расширение в верхнем регистре
|
||||
data = pdf.read_bytes()
|
||||
expected_error = json.loads(_real_fixture("aep_5", ".json").read_text(encoding="utf-8"))["error"]
|
||||
health = await roundtrip.health()
|
||||
assert (health["nats"], health["hotfolder"]) == ("ok", "ok"), health
|
||||
|
||||
body = await roundtrip.request("aep_5.pdf", data)
|
||||
body = await roundtrip.request(pdf.name, data)
|
||||
job_id = body["job_id"]
|
||||
await roundtrip.publish(body)
|
||||
inbox_file = roundtrip.input_dir / f"{job_id}_aep_5.pdf"
|
||||
inbox_file = roundtrip.input_dir / f"{job_id}_{pdf.name}"
|
||||
await roundtrip.wait_for(f"файл {inbox_file.name} во входящем каталоге", inbox_file.exists)
|
||||
# повтор того же запроса во время ожидания ответа ИС (эмулятор «распознаёт» EMULATOR_DELAY с)
|
||||
await roundtrip.publish(body)
|
||||
@ -387,8 +409,10 @@ async def test_real_fixture_aep_5_passes_is_error_through_and_repeat_gets_ack(ro
|
||||
assert result.body["error"] == {"code": "INVALID_DOCUMENT", "message": expected_error["message"]}
|
||||
assert result.body["error"]["message"] == "Не удалось определить тип документа."
|
||||
await roundtrip.check_files(body, result, data)
|
||||
raw = json.loads(await roundtrip.s3.get_bytes(f"raw/{job_id}.json"))
|
||||
assert raw["files"][0]["response"]["error"] == expected_error
|
||||
raw = await roundtrip.raw(job_id)
|
||||
assert [entry["response"]["error"] for entry in raw["files"]] == [expected_error]
|
||||
# все файлы набора ошибочные: итог — ошибка первого файла, а warnings перечисляют каждый ошибочный файл (D-5)
|
||||
assert result.body["metrics"]["warnings"] == [f"{job_id}.json: INVALID_DOCUMENT: {expected_error['message']}"]
|
||||
|
||||
# повтор ядра после результата (attempt 2): ещё один ack, без второго файла и второго результата
|
||||
await roundtrip.publish({**body, "attempt": 2})
|
||||
@ -396,7 +420,45 @@ async def test_real_fixture_aep_5_passes_is_error_through_and_repeat_gets_ack(ro
|
||||
await asyncio.sleep(QUIET_PERIOD)
|
||||
assert roundtrip.acks(job_id) == [f"ack:{job_id}:1", f"ack:{job_id}:1", f"ack:{job_id}:2"]
|
||||
assert [e.msg_id for e in roundtrip.results(job_id)] == [f"result:{job_id}:1"]
|
||||
assert roundtrip.inbox(job_id) == [f"{job_id}_aep_5.pdf.done"]
|
||||
assert roundtrip.inbox(job_id) == [f"{job_id}_{pdf.name}.done"]
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
async def test_real_fixture_aep_3_two_files_merged_with_warning(roundtrip: Roundtrip) -> None:
|
||||
"""Набор из двух файлов: успешный `<job_id>.json` и `<job_id>_ofr.json` с `INVALID_DOCUMENT` (OQ-76, OQ-78)."""
|
||||
data = _real_fixture("aep_3", ".pdf").read_bytes()
|
||||
ofr_error = json.loads(_real_fixture("aep_3_ofr", ".json").read_text(encoding="utf-8"))["error"]
|
||||
assert ofr_error["code"] == "INVALID_DOCUMENT"
|
||||
|
||||
body, result = await roundtrip.run("aep_3.pdf", data)
|
||||
job_id = body["job_id"]
|
||||
assert result.body["error"] == {"code": "NOT_IMPLEMENTED", "message": NOT_IMPLEMENTED_MESSAGE}
|
||||
# ошибочный файл набора — в warnings одной строкой `<имя>: <код>: <сообщение>`; успешный файл в них не попадает
|
||||
assert result.body["metrics"]["warnings"] == [f"{job_id}_ofr.json: INVALID_DOCUMENT: {ofr_error['message']}"]
|
||||
|
||||
raw = await roundtrip.raw(job_id)
|
||||
assert [entry["name"] for entry in raw["files"]] == [f"{job_id}.json", f"{job_id}_ofr.json"]
|
||||
first, second = raw["files"]
|
||||
assert first["response"]["error"] == {"code": "", "message": ""} # успех — не `null`
|
||||
assert [t["label"]["form_name"] for t in first["response"]["data_ocr"]["table_structure"]] == [
|
||||
"Бухгалтерский баланс",
|
||||
"Отчет о финансовых результатах",
|
||||
]
|
||||
assert second["response"]["error"] == ofr_error
|
||||
assert (first["parse_error"], second["parse_error"]) == (None, None)
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
async def test_real_fixture_aep_6_success_without_warnings(roundtrip: Roundtrip) -> None:
|
||||
data = _real_fixture("aep_6", ".pdf").read_bytes()
|
||||
body, result = await roundtrip.run("aep_6.pdf", data)
|
||||
job_id = body["job_id"]
|
||||
assert result.body["error"] == {"code": "NOT_IMPLEMENTED", "message": NOT_IMPLEMENTED_MESSAGE}
|
||||
assert "metrics" not in result.body # ошибочных файлов нет — warnings опущено
|
||||
raw = await roundtrip.raw(job_id)
|
||||
[entry] = raw["files"]
|
||||
assert entry["name"] == f"{job_id}.json"
|
||||
assert entry["response"]["error"] == {"code": "", "message": ""}
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
@ -406,7 +468,7 @@ async def test_success_answer_gives_not_implemented(roundtrip: Roundtrip) -> Non
|
||||
job_id = body["job_id"]
|
||||
assert result.body["error"] == {"code": "NOT_IMPLEMENTED", "message": NOT_IMPLEMENTED_MESSAGE}
|
||||
assert "metrics" not in result.body # ошибочных файлов нет — warnings опущено
|
||||
raw = json.loads(await roundtrip.s3.get_bytes(f"raw/{job_id}.json"))
|
||||
raw = await roundtrip.raw(job_id)
|
||||
[entry] = raw["files"]
|
||||
assert entry["response"]["error"] == {"code": "", "message": ""}
|
||||
assert entry["response"]["data_ocr"] == SUCCESS_DATA_OCR
|
||||
@ -418,14 +480,11 @@ async def test_unknown_name_gets_emulator_invalid_document(roundtrip: Roundtrip)
|
||||
job_id = body["job_id"]
|
||||
error = {"code": "INVALID_DOCUMENT", "message": "unknown.pdf: нет эталонного ответа в фикстурах"}
|
||||
assert result.body["error"] == error
|
||||
raw = json.loads(await roundtrip.s3.get_bytes(f"raw/{job_id}.json"))
|
||||
assert raw == {
|
||||
"job_id": job_id,
|
||||
"files": [
|
||||
{
|
||||
"name": f"{job_id}.json",
|
||||
"response": {"file_uid": job_id, "error": error, "data_ocr": []},
|
||||
"parse_error": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
raw = await roundtrip.raw(job_id)
|
||||
[entry] = raw["files"]
|
||||
assert entry["name"] == f"{job_id}.json"
|
||||
assert entry["parse_error"] is None
|
||||
# ответ-заглушка эмулятора в форме реальных ответов ИС (R-28): `data_ocr` — объект без таблиц
|
||||
assert entry["response"]["file_uid"] == job_id
|
||||
assert entry["response"]["error"] == error
|
||||
assert entry["response"]["data_ocr"]["table_structure"] == []
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user