arm-core/api/tests/test_contract.py
Andrey Epifancev f0c1073918 NATS: subject'ы и JWT из OPENBAO__NATS__*, SecretStr, скрытие входа в ошибках (#106)
- NATSSettings: NATS_TABULAR_REQUEST_SUBJECT / NATS_IMAGE_REQUEST_SUBJECT / NATS_RESULT_SUBJECT с умолчанием
  имён контракта v1.0; проверка формата (без пробелов, '*', '>', пустых токенов) и попарного различия.
- request_subjects() и request_subject(pipeline, subjects) — карта subject'ов окружения.
- NATS_SEED / NATS_JWT — SecretStr; build_nats_auth читает значения через get_secret_value().
- hide_input_in_errors=True во всех классах настроек: ошибка «нет поля» не печатает JWT/seed.
- Схемы контракта api/contracts/ — копии docs/contracts/ (побайтно).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:13:04 +04:00

658 lines
29 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.

"""Контракт обмена с сервисами распознавания v1.0 (spec recognition-job-exchange, design D-6).
Быстрые тесты без БД: вендоренные схемы `api/contracts/` идентичны `../docs/contracts/`; каждый пример проходит и
JSON Schema, и pydantic-модель (с round-trip); модели и схемы выносят один и тот же вердикт на негативных кейсах.
"""
import copy
import json
import uuid
from collections.abc import Callable
from datetime import UTC, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
import pytest
from jsonschema import Draft202012Validator, FormatChecker
from pydantic import ValidationError
from referencing import Registry, Resource
from scripts.dbml_parity import default_dbml_path
from src.contract import (
CONTRACT_VERSION,
NATS_MSG_ID_HEADER,
RESULT_SUBJECT,
ContractModel,
MessageStatus,
RecognitionRequest,
RecognitionResult,
RecognitionResultMessage,
ack_msg_id,
request_msg_id,
request_subject,
result_msg_id,
)
CONTRACTS_DIR = Path(__file__).resolve().parents[1] / "contracts"
EXAMPLES_DIR = CONTRACTS_DIR / "examples"
SCHEMA_FILES = {
"request": "recognition-request.schema.json",
"message": "recognition-result-message.schema.json",
"result": "recognition-result.schema.json",
}
MODELS: dict[str, type[ContractModel]] = {
"request": RecognitionRequest,
"message": RecognitionResultMessage,
"result": RecognitionResult,
}
EXAMPLE_KIND = {
"request-image.json": "request",
"request-tabular.json": "request",
"result-message-done.json": "message",
"result-message-error.json": "message",
"result-message-processing.json": "message",
"result-error.json": "result",
"result-osv-minimal.json": "result",
"result-osv-needs-check.json": "result",
"result-statement-minimal.json": "result",
"result-statement-excel.json": "result",
}
EXAMPLES_COUNT = 10
def _load(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def _example(name: str) -> dict[str, Any]:
data = _load(EXAMPLES_DIR / name)
assert isinstance(data, dict)
return data
def _build_validators() -> dict[str, Draft202012Validator]:
"""Валидаторы по трём схемам; `$ref` между схемами разрешаются локальным реестром (без сети)."""
schemas = {kind: _load(CONTRACTS_DIR / file_name) for kind, file_name in SCHEMA_FILES.items()}
registry: Registry = Registry()
for schema in schemas.values():
registry = registry.with_resource(schema["$id"], Resource.from_contents(schema))
return {
kind: Draft202012Validator(schema, registry=registry, format_checker=FormatChecker())
for kind, schema in schemas.items()
}
VALIDATORS = _build_validators()
def _schema_ok(kind: str, payload: dict[str, Any]) -> bool:
return VALIDATORS[kind].is_valid(payload)
def _model_ok(kind: str, payload: dict[str, Any]) -> bool:
try:
MODELS[kind].model_validate(payload)
except ValidationError:
return False
return True
# --- идентичность вендоренных копий -------------------------------------------------------------------------------
def _docs_contracts_dir() -> Path:
"""`docs/contracts` рядом с `arm-core` или из `ARM_KA_DOCS` (тот же поиск, что у DBML)."""
return default_dbml_path().parents[1] / "contracts"
def test_vendored_contracts_are_identical_to_docs() -> None:
docs_dir = _docs_contracts_dir()
if not docs_dir.is_dir():
pytest.skip(
"docs недоступен: тест идентичности контракта пропущен (задайте ARM_KA_DOCS или положите docs рядом)"
)
docs_files = {p.relative_to(docs_dir).as_posix() for p in docs_dir.rglob("*.json")}
own_files = {p.relative_to(CONTRACTS_DIR).as_posix() for p in CONTRACTS_DIR.rglob("*.json")}
assert own_files == docs_files, (
f"набор файлов контракта разошёлся: только в api/contracts: {sorted(own_files - docs_files)}, "
f"только в docs/contracts: {sorted(docs_files - own_files)}"
)
for name in sorted(docs_files):
assert (CONTRACTS_DIR / name).read_bytes() == (docs_dir / name).read_bytes(), (
f"файл {name} в api/contracts отличается от docs/contracts: скопируйте заново (cp), не правьте руками"
)
def test_example_table_covers_all_example_files() -> None:
files = {p.name for p in EXAMPLES_DIR.glob("*.json")}
assert files == set(EXAMPLE_KIND)
assert len(files) == EXAMPLES_COUNT
# --- примеры: JSON Schema, модель, round-trip ---------------------------------------------------------------------
@pytest.mark.parametrize("name", sorted(EXAMPLE_KIND))
def test_example_passes_json_schema(name: str) -> None:
VALIDATORS[EXAMPLE_KIND[name]].validate(_example(name))
@pytest.mark.parametrize("name", sorted(EXAMPLE_KIND))
def test_example_passes_model_and_round_trips(name: str) -> None:
kind = EXAMPLE_KIND[name]
original = _example(name)
model = MODELS[kind].model_validate(original)
dumped = model.model_dump(mode="json", exclude_unset=True)
# Ничего не потеряно и не изменено (в том числе суммы-строки: «1100», «-12.30», «0.00»)
assert dumped == original
VALIDATORS[kind].validate(dumped)
# to_wire() — то, что уйдёт в NATS/S3: тоже проходит схему и совпадает с исходным примером
wire = model.to_wire()
assert wire == original
VALIDATORS[kind].validate(wire)
# повторный разбор даёт равную модель
assert MODELS[kind].model_validate(wire) == model
def test_result_amounts_are_decimal_in_model_and_strings_on_the_wire() -> None:
result = RecognitionResult.model_validate(_example("result-statement-minimal.json"))
values = result.documents[0].lines[1].values # type: ignore[union-attr]
assert values[0].amount == Decimal("5678.00")
assert values[1].amount == Decimal("-12.30")
assert isinstance(values[1].amount, Decimal)
wire = result.to_wire()
assert wire["documents"][0]["lines"][1]["values"][1]["amount"] == "-12.30"
assert wire["documents"][0]["lines"][0]["values"][1]["amount"] == "1100" # без «.00» — как пришло
def test_osv_amount_keeps_kopecks() -> None:
payload = _example("result-osv-minimal.json")
payload["documents"][0]["rows"][0]["closing_credit"] = "106311116.46"
result = RecognitionResult.model_validate(payload)
assert result.documents[0].rows[0].closing_credit == Decimal("106311116.46") # type: ignore[union-attr]
wire = result.to_wire()
assert wire["documents"][0]["rows"][0]["closing_credit"] == "106311116.46"
VALIDATORS["result"].validate(wire)
def test_models_expose_json_schema() -> None:
for model in MODELS.values():
assert model.model_json_schema()["type"] == "object"
# --- согласованность моделей и схем -------------------------------------------------------------------------------
def _set(path: str, value: Any) -> Callable[[dict[str, Any]], None]:
"""Мутация: положить `value` по пути `a.b.0.c` (числа — индексы списков)."""
def apply(payload: dict[str, Any]) -> None:
*parents, last = path.split(".")
node: Any = payload
for part in parents:
node = node[int(part)] if part.isdigit() else node[part]
if isinstance(node, list):
node[int(last)] = value
else:
node[last] = value
return apply
def _drop(path: str) -> Callable[[dict[str, Any]], None]:
def apply(payload: dict[str, Any]) -> None:
*parents, last = path.split(".")
node: Any = payload
for part in parents:
node = node[int(part)] if part.isdigit() else node[part]
del node[last]
return apply
# (идентификатор, пример-основа, мутация, ожидаемый вердикт «валидно»)
CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [
# --- запрос ---
("request/base", "request-tabular.json", lambda p: None, True),
("request/no-job_id", "request-tabular.json", _drop("job_id"), False),
("request/no-attempt", "request-tabular.json", _drop("attempt"), False),
("request/attempt-0", "request-tabular.json", _set("attempt", 0), False),
("request/attempt-2", "request-tabular.json", _set("attempt", 2), True),
("request/attempt-string", "request-tabular.json", _set("attempt", "1"), False),
("request/attempt-float", "request-tabular.json", _set("attempt", 1.5), False),
("request/attempt-1.0", "request-tabular.json", _set("attempt", 1.0), True),
("request/attempt-0.0", "request-tabular.json", _set("attempt", 0.0), False),
("request/attempt-nan", "request-tabular.json", _set("attempt", float("nan")), False),
("request/attempt-bool", "request-tabular.json", _set("attempt", True), False),
("request/pipeline-unknown", "request-tabular.json", _set("pipeline", "ocr"), False),
("request/file_type-unknown", "request-tabular.json", _set("file_type", "DOCX"), False),
("request/schema_version-2.0", "request-tabular.json", _set("schema_version", "2.0"), False),
("request/schema_version-1.7", "request-tabular.json", _set("schema_version", "1.7"), True),
("request/schema_version-1", "request-tabular.json", _set("schema_version", "1"), False),
("request/schema_version-1.0.1", "request-tabular.json", _set("schema_version", "1.0.1"), False),
("request/job_id-not-uuid", "request-tabular.json", _set("job_id", "not-a-uuid"), False),
(
"request/job_id-uuid-without-dashes",
"request-tabular.json",
_set("job_id", "3f2a9c1e7b4d4e0a9d512c8e6a1b0f01"),
False,
),
("request/file_id-number", "request-tabular.json", _set("file_id", 12345), False),
("request/original_name-empty", "request-tabular.json", _set("original_name", ""), False),
("request/s3-no-key", "request-tabular.json", _drop("s3.key"), False),
("request/s3-empty-bucket", "request-tabular.json", _set("s3.bucket", ""), False),
("request/s3-null", "request-tabular.json", _set("s3", None), False),
("request/extra-field", "request-tabular.json", _set("trace_id", "abc"), True),
("request/extra-field-in-s3", "request-tabular.json", _set("s3.version_id", "v1"), True),
# --- конверт ---
("message/processing", "result-message-processing.json", lambda p: None, True),
("message/done", "result-message-done.json", lambda p: None, True),
("message/status-unknown", "result-message-done.json", _set("status", "queued"), False),
("message/no-service", "result-message-processing.json", _drop("service"), False),
("message/service-unknown", "result-message-processing.json", _set("service.name", "other-service"), False),
("message/schema_version-2.0", "result-message-processing.json", _set("schema_version", "2.0"), False),
("message/schema_version-1.7", "result-message-processing.json", _set("schema_version", "1.7"), True),
("message/done-no-result_s3", "result-message-done.json", _drop("result_s3"), False),
("message/done-no-result_schema_version", "result-message-done.json", _drop("result_schema_version"), False),
("message/needs_check-ok", "result-message-done.json", _set("status", "needs_check"), True),
(
"message/needs_check-no-result_s3",
"result-message-done.json",
lambda p: (p.pop("result_s3"), p.update(status="needs_check")),
False,
),
("message/result_s3-no-key", "result-message-done.json", _drop("result_s3.key"), False),
("message/result_s3-null", "result-message-done.json", _set("result_s3", None), False),
("message/result_schema_version-2.0", "result-message-done.json", _set("result_schema_version", "2.0"), True),
("message/result_schema_version-bad", "result-message-done.json", _set("result_schema_version", "1"), False),
("message/error-ok", "result-message-error.json", lambda p: None, True),
("message/error-no-error", "result-message-error.json", _drop("error"), False),
("message/error-null", "result-message-error.json", _set("error", None), False),
("message/error-unknown-code", "result-message-error.json", _set("error.code", "BOOM"), False),
("message/error-no-message", "result-message-error.json", _drop("error.message"), False),
("message/processing-no-result_s3", "result-message-processing.json", lambda p: None, True),
(
"message/done-with-error-block",
"result-message-done.json",
_set("error", {"code": "TIMEOUT", "message": "x"}),
True,
),
("message/raw_s3_key-null", "result-message-done.json", _set("raw_s3_key", None), True),
("message/metrics-warnings-not-strings", "result-message-done.json", _set("metrics.warnings", [1]), False),
("message/metrics-duration-float", "result-message-done.json", _set("metrics.duration_ms", 1.5), False),
("message/metrics-duration-1.0", "result-message-done.json", _set("metrics.duration_ms", 18400.0), True),
("message/metrics-pages-bool", "result-message-done.json", _set("metrics.pages", True), False),
("message/metrics-extra-field", "result-message-done.json", _set("metrics.cpu", 3), True),
("message/metrics-rows", "result-message-done.json", _set("metrics.rows", 10), True),
("message/extra-field", "result-message-done.json", _set("trace_id", "abc"), True),
# --- файл результата ---
("result/osv-base", "result-osv-minimal.json", lambda p: None, True),
("result/statement-base", "result-statement-minimal.json", lambda p: None, True),
("result/error-base", "result-error.json", lambda p: None, True),
(
"result/amount-float",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.amount", 1234.5),
False,
),
("result/amount-int", "result-statement-minimal.json", _set("documents.0.lines.0.values.0.amount", 1234), False),
("result/amount-null", "result-statement-minimal.json", _set("documents.0.lines.0.values.0.amount", None), True),
(
"result/amount-3-decimals",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.amount", "1.234"),
False,
),
("result/amount-comma", "result-statement-minimal.json", _set("documents.0.lines.0.values.0.amount", "1,5"), False),
(
"result/amount-negative",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.amount", "-0.5"),
True,
),
(
"result/amount-exponent",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.amount", "1e3"),
False,
),
("result/amount-empty", "result-statement-minimal.json", _set("documents.0.lines.0.values.0.amount", ""), False),
("result/osv-amount-float", "result-osv-minimal.json", _set("documents.0.rows.0.opening_credit", 1000.0), False),
("result/osv-amount-missing", "result-osv-minimal.json", _drop("documents.0.rows.0.opening_credit"), True),
("result/done-empty-documents", "result-osv-minimal.json", _set("documents", []), False),
(
"result/needs_check-empty-documents",
"result-osv-minimal.json",
lambda p: p.update(status="needs_check", documents=[]),
False,
),
("result/error-empty-documents", "result-error.json", lambda p: None, True),
("result/no-documents", "result-error.json", _drop("documents"), False),
("result/status-processing", "result-osv-minimal.json", _set("status", "processing"), False),
("result/error-null", "result-error.json", _set("error", None), True),
("result/error-empty-object", "result-error.json", _set("error", {}), True),
("result/error-bad-code", "result-error.json", _set("error.code", "BOOM"), False),
("result/schema_version-2.0", "result-osv-minimal.json", _set("schema_version", "2.0"), False),
("result/schema_version-1.3", "result-osv-minimal.json", _set("schema_version", "1.3"), True),
("result/document_type-unknown", "result-osv-minimal.json", _set("documents.0.document_type", "MEMO"), False),
("result/document-no-document_type", "result-osv-minimal.json", _drop("documents.0.document_type"), False),
("result/osv-no-rows", "result-osv-minimal.json", _drop("documents.0.rows"), False),
("result/osv-no-group_schema", "result-osv-minimal.json", _drop("documents.0.group_schema"), False),
("result/osv-no-header-period_text", "result-osv-minimal.json", _drop("documents.0.header.period_text"), False),
("result/osv-header_variant-3", "result-osv-minimal.json", _set("documents.0.header.header_variant", 3), False),
("result/osv-period_quarter-Q5", "result-osv-minimal.json", _set("documents.0.header.period_quarter", "Q5"), False),
(
"result/osv-period_quarter-null",
"result-osv-minimal.json",
_set("documents.0.header.period_quarter", None),
True,
),
(
"result/osv-header-null-for-non-nullable",
"result-osv-minimal.json",
_set("documents.0.header.header_variant", None),
False,
),
(
"result/osv-row-group_type-unknown",
"result-osv-minimal.json",
_set("documents.0.rows.0.group_type", "FOLDER"),
False,
),
(
"result/osv-row-group_type-TOTAL",
"result-osv-minimal.json",
_set("documents.0.rows.0.group_type", "TOTAL"),
True,
),
("result/osv-row-no-row_number", "result-osv-minimal.json", _drop("documents.0.rows.0.row_number"), False),
("result/osv-level-1.0", "result-osv-minimal.json", _set("documents.0.rows.0.level", 1.0), True),
("result/osv-level-1.5", "result-osv-minimal.json", _set("documents.0.rows.0.level", 1.5), False),
("result/osv-level-bool", "result-osv-minimal.json", _set("documents.0.rows.0.level", True), False),
("result/osv-level-string", "result-osv-minimal.json", _set("documents.0.rows.0.level", "1"), False),
("result/osv-row_number-2.0", "result-osv-minimal.json", _set("documents.0.rows.0.row_number", 2.0), True),
(
"result/osv-parent_row_number-1.0",
"result-osv-minimal.json",
_set("documents.0.rows.1.parent_row_number", 1.0),
True,
),
(
"result/osv-parent_row_number-1.5",
"result-osv-minimal.json",
_set("documents.0.rows.1.parent_row_number", 1.5),
False,
),
("result/osv-period_year-2025.0", "result-osv-minimal.json", _set("documents.0.header.period_year", 2025.0), True),
(
"result/osv-period_year-nan",
"result-osv-minimal.json",
_set("documents.0.header.period_year", float("nan")),
False,
),
("result/osv-header_variant-2.0", "result-osv-minimal.json", _set("documents.0.header.header_variant", 2.0), True),
(
"result/osv-header_variant-true",
"result-osv-minimal.json",
_set("documents.0.header.header_variant", True),
False,
),
("result/osv-header_variant-1.5", "result-osv-minimal.json", _set("documents.0.header.header_variant", 1.5), False),
(
"result/osv-violations-1.0",
"result-osv-minimal.json",
_set("documents.0.checks.parent_sum_violations", [3.0]),
True,
),
(
"result/osv-violations-1.5",
"result-osv-minimal.json",
_set("documents.0.checks.parent_sum_violations", [3.5]),
False,
),
(
"result/statement-column_index-1.0",
"result-statement-minimal.json",
_set("documents.0.columns.0.column_index", 1.0),
True,
),
(
"result/statement-column_index-1.5",
"result-statement-minimal.json",
_set("documents.0.columns.0.column_index", 1.5),
False,
),
(
"result/statement-value-column_index-0.0",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.column_index", 0.0),
True,
),
(
"result/statement-value-column_index-bool",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.column_index", False),
False,
),
("result/osv-row-level-float", "result-osv-minimal.json", _set("documents.0.rows.0.level", 0.5), False),
("result/osv-columns-non-string", "result-osv-minimal.json", _set("documents.0.columns.opening_debit", 3), False),
("result/osv-columns-extra-key", "result-osv-minimal.json", _set("documents.0.columns.inn", "B"), True),
(
"result/osv-group_schema-bad-type",
"result-osv-minimal.json",
_set("documents.0.group_schema.0.type", "FOLDER"),
False,
),
(
"result/osv-checks-violations-not-ints",
"result-osv-minimal.json",
_set("documents.0.checks.parent_sum_violations", ["a"]),
False,
),
("result/osv-extra-field", "result-osv-minimal.json", _set("documents.0.debug", {"a": 1}), True),
("result/statement-no-lines", "result-statement-minimal.json", _drop("documents.0.lines"), False),
("result/statement-no-columns", "result-statement-minimal.json", _drop("documents.0.columns"), False),
(
"result/statement-type-INCOME",
"result-statement-minimal.json",
_set("documents.0.document_type", "INCOME"),
True,
),
(
"result/statement-type-OSV-with-statement-body",
"result-statement-minimal.json",
_set("documents.0.document_type", "OSV"),
False,
),
(
"result/statement-confidence-101",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.confidence", 101),
False,
),
(
"result/statement-confidence-negative",
"result-statement-minimal.json",
_set("documents.0.lines.0.line_code_confidence", -1),
False,
),
(
"result/statement-confidence-null",
"result-statement-minimal.json",
_set("documents.0.lines.0.line_code_confidence", None),
True,
),
(
"result/statement-confidence-bool",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.confidence", True),
False,
),
(
"result/statement-confidence-100.0",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.confidence", 100.0),
True,
),
(
"result/statement-report_date-bad",
"result-statement-minimal.json",
_set("documents.0.header.report_date", "2025-13-45"),
False,
),
(
"result/statement-report_date-null",
"result-statement-minimal.json",
_set("documents.0.header.report_date", None),
True,
),
(
"result/statement-value-no-column_index",
"result-statement-minimal.json",
_drop("documents.0.lines.0.values.0.column_index"),
False,
),
(
"result/statement-is_recognized-string",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.is_recognized", "yes"),
False,
),
(
"result/statement-string_value-null",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.string_value", None),
True,
),
(
"result/statement-period_date-bad",
"result-statement-minimal.json",
_set("documents.0.columns.0.period_date", "30.09.2025"),
False,
),
("result/statement-extra-field", "result-statement-minimal.json", _set("documents.0.header.fax", "1"), True),
]
@pytest.mark.parametrize(("case_id", "example", "mutate", "expected_valid"), CASES, ids=[c[0] for c in CASES])
def test_model_and_schema_agree(
case_id: str, example: str, mutate: Callable[[dict[str, Any]], None], expected_valid: bool
) -> None:
kind = EXAMPLE_KIND[example]
payload = copy.deepcopy(_example(example))
mutate(payload)
schema_verdict = _schema_ok(kind, payload)
model_verdict = _model_ok(kind, payload)
assert schema_verdict == expected_valid, (
f"{case_id}: JSON Schema вынесла вердикт {schema_verdict}, ожидали {expected_valid}"
)
assert model_verdict == schema_verdict, (
f"{case_id}: модель {'приняла' if model_verdict else 'отвергла'}, схема "
f"{'приняла' if schema_verdict else 'отвергла'} — расхождение модели и схемы"
)
def test_consistency_table_covers_every_kind() -> None:
assert {EXAMPLE_KIND[c[1]] for c in CASES} == set(MODELS)
# --- то, что модель проверяет строже валидатора схемы без дополнительных библиотек ---------------------------------
def test_reply_deadline_must_be_aware_and_is_normalized_to_utc() -> None:
naive = _example("request-tabular.json")
naive["reply_deadline"] = "2026-10-03T12:30:00"
with pytest.raises(ValidationError):
RecognitionRequest.model_validate(naive)
shifted = _example("request-tabular.json")
shifted["reply_deadline"] = "2026-10-03T15:30:00+03:00"
request = RecognitionRequest.model_validate(shifted)
assert request.reply_deadline == datetime(2026, 10, 3, 12, 30, tzinfo=UTC)
assert request.reply_deadline.utcoffset() == UTC.utcoffset(None)
assert request.to_wire()["reply_deadline"] == "2026-10-03T12:30:00Z"
def test_uuid_fields_are_uuid_objects() -> None:
request = RecognitionRequest.model_validate(_example("request-tabular.json"))
assert request.job_id == uuid.UUID("3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01")
assert isinstance(request.file_id, uuid.UUID)
def test_message_status_enum_values() -> None:
assert [s.value for s in MessageStatus] == ["processing", "done", "needs_check", "error"]
message = RecognitionResultMessage.model_validate(_example("result-message-done.json"))
assert message.status is MessageStatus.DONE
def test_contract_version_constant() -> None:
assert CONTRACT_VERSION == "1.0"
def test_python_float_amount_is_rejected_with_clear_error() -> None:
payload = _example("result-statement-minimal.json")
payload["documents"][0]["lines"][0]["values"][0]["amount"] = 1234.5
with pytest.raises(ValidationError, match="строк"):
RecognitionResult.model_validate(payload)
def test_decimal_object_input_is_accepted_and_dumped_as_string() -> None:
"""Для кода, собирающего результат из `Decimal` (тесты, будущие производители): объект `Decimal` принимается."""
payload = _example("result-statement-minimal.json")
payload["documents"][0]["lines"][0]["values"][0]["amount"] = Decimal("1234.50")
result = RecognitionResult.model_validate(payload)
assert result.to_wire()["documents"][0]["lines"][0]["values"][0]["amount"] == "1234.50"
# --- subject'ы, заголовок и идентификаторы сообщений ---------------------------------------------------------------
def test_request_msg_id() -> None:
job_id = uuid.UUID("3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01")
assert request_msg_id(job_id, 1) == "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01:1"
assert request_msg_id(str(job_id), 3) == "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01:3"
def test_ack_and_result_msg_ids() -> None:
job_id = uuid.UUID("3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01")
assert ack_msg_id(job_id, 1) == "ack:3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01:1"
assert result_msg_id(job_id, 2) == "result:3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01:2"
def test_request_subject_by_pipeline() -> None:
assert request_subject("tabular") == "files.tabular_uploaded"
assert request_subject("image") == "files.img_uploaded"
assert RESULT_SUBJECT == "files.tabular_parsed"
assert NATS_MSG_ID_HEADER == "Nats-Msg-Id"
def test_request_subject_uses_environment_map() -> None:
subjects = {"tabular": "test.files.tabular_uploaded", "image": "test.files.img_uploaded"}
assert request_subject("tabular", subjects) == "test.files.tabular_uploaded"
assert request_subject("image", subjects) == "test.files.img_uploaded"
def test_request_subject_rejects_unknown_pipeline() -> None:
with pytest.raises(ValueError, match="pipeline"):
request_subject("ocr")
def test_request_subject_matches_example_pipeline() -> None:
for name in ("request-tabular.json", "request-image.json"):
request = RecognitionRequest.model_validate(_example(name))
expected = {"tabular": "files.tabular_uploaded", "image": "files.img_uploaded"}[request.pipeline]
assert request_subject(request.pipeline) == expected
def test_integer_valued_float_is_normalized_to_int() -> None:
"""`1.0` (pandas) принимается как целое и уходит дальше целым числом: `1`."""
payload = _example("result-osv-minimal.json")
payload["documents"][0]["rows"][0]["level"] = 1.0
payload["documents"][0]["header"]["header_variant"] = 2.0
result = RecognitionResult.model_validate(payload)
wire = result.to_wire()
assert wire["documents"][0]["rows"][0]["level"] == 1
assert type(wire["documents"][0]["rows"][0]["level"]) is int
assert type(wire["documents"][0]["header"]["header_variant"]) is int
VALIDATORS["result"].validate(wire)