"""Склейка набора файлов ответа ИС в один ответ (spec image-recognition-gateway «Сырой ответ…», D-4, R-24). `merge_answers(job_id, files)` разбирает каждый файл `json.loads` независимо: не JSON — `response = null` и `parse_error`, задание не падает; файлы — по возрастанию имени. `dump_merged` — байты объекта `raw/{job_id}.json`. """ import json import shutil from pathlib import Path import pytest from src.gateway.hotfolder import AnswerFile from src.gateway.merge import dump_merged, merge_answers API_DIR = Path(__file__).resolve().parents[2] REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr" JOB = "3f1c2b7a-0000-4000-8000-000000000001" def test_structure_for_one_file() -> None: merged = merge_answers(JOB, [AnswerFile(f"{JOB}.json", b'{"file_uid": "x", "error": {"code": "", "message": ""}}')]) assert merged == { "job_id": JOB, "files": [ { "name": f"{JOB}.json", "response": {"file_uid": "x", "error": {"code": "", "message": ""}}, "parse_error": None, } ], } def test_files_are_ordered_by_name() -> None: files = [ AnswerFile(f"{JOB}_ofr.json", b'{"n": 2}'), AnswerFile("0.json", b'{"n": 0}'), AnswerFile(f"{JOB}.json", b'{"n": 1}'), ] merged = merge_answers(JOB, files) assert [f["name"] for f in merged["files"]] == ["0.json", f"{JOB}.json", f"{JOB}_ofr.json"] assert [f["response"] for f in merged["files"]] == [{"n": 0}, {"n": 1}, {"n": 2}] @pytest.mark.parametrize( "data", [b"", b"not json", b'{"a": ', b"\xff\xfe", b"[" * 100_000], ids=["empty", "text", "truncated", "bad_utf8", "deep"], ) def test_invalid_json_becomes_parse_error_and_others_are_parsed(data: bytes) -> None: files = [AnswerFile(f"{JOB}.json", b'{"ok": true}'), AnswerFile(f"{JOB}_ofr.json", data)] merged = merge_answers(JOB, files) first, second = merged["files"] assert first == {"name": f"{JOB}.json", "response": {"ok": True}, "parse_error": None} assert second["name"] == f"{JOB}_ofr.json" assert second["response"] is None assert isinstance(second["parse_error"], str) assert second["parse_error"] @pytest.mark.parametrize("data", [b"[]", b'"text"', b"42", b"null"]) def test_valid_json_of_any_root_is_kept_as_is(data: bytes) -> None: """Корень не объект — это забота нормализатора (`INVALID_FORMAT` файла), склейка хранит разобранное как есть.""" [entry] = merge_answers(JOB, [AnswerFile(f"{JOB}.json", data)])["files"] assert entry["response"] == json.loads(data) assert entry["parse_error"] is None def test_empty_set_gives_empty_files() -> None: assert merge_answers(JOB, []) == {"job_id": JOB, "files": []} def test_dump_is_utf8_json_that_round_trips() -> None: merged = merge_answers(JOB, [AnswerFile(f"{JOB}.json", '{"m": "Не удалось"}'.encode())]) data = dump_merged(merged) assert "Не удалось".encode() in data # кириллица читаема в S3, не \\uXXXX assert json.loads(data) == merged def test_dump_with_lone_surrogate_is_valid_utf8() -> None: """`\\ud800` в ответе ИС разбирается в одиночный суррогат: объект всё равно записывается валидным UTF-8.""" merged = merge_answers(JOB, [AnswerFile(f"{JOB}.json", b'{"m": "bad \\ud800 tail"}')]) data = dump_merged(merged) data.decode("utf-8") assert json.loads(data) == merged @pytest.mark.fixtures def test_real_fixtures_are_merged(tmp_path: Path) -> None: if not REAL_FIXTURES.is_dir(): pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") for name in ("aep_3.json", "aep_3_ofr.json"): shutil.copy(REAL_FIXTURES / name, tmp_path / name) files = [AnswerFile(p.name, p.read_bytes()) for p in sorted(tmp_path.iterdir())] merged = merge_answers(JOB, files) assert [f["name"] for f in merged["files"]] == ["aep_3.json", "aep_3_ofr.json"] assert all(f["parse_error"] is None for f in merged["files"]) assert merged["files"][0]["response"]["error"] == {"code": "", "message": ""} assert merged["files"][1]["response"]["error"]["code"] == "INVALID_DOCUMENT" assert json.loads(dump_merged(merged)) == merged