diff --git a/api/src/scan_emulator/__init__.py b/api/src/scan_emulator/__init__.py index f90b1ab..084481e 100644 --- a/api/src/scan_emulator/__init__.py +++ b/api/src/scan_emulator/__init__.py @@ -2,14 +2,14 @@ Воспроизводит протокол горячих папок: для нового файла `.` во входящем каталоге через задержку кладёт каталог `//` с набором эталонных ответов из `SCAN_FIXTURES_DIR` по sha256 содержимого входящего файла. -При совпадении хеша находит в фиксутрах файл с той же основой имени: `.json` -> `.json` и +При совпадении хеша находит в фикстурах файл с той же основой имени: `.json` -> `.json` и `_.json` -> `_.json`. При промахе по хешу использует набор по умолчанию (`SCAN_EMULATOR_DEFAULT`), если он задан и существует; иначе ответ-ошибка `INVALID_DOCUMENT`. Набор собирается во временном каталоге служебной папки (`SCAN_TMP_DIR`, та же ФС, что и ``) и появляется в `` одним rename каталога: шлюз не видит частичного набора. Эмулятор читает собственные переменные окружения (`EmulatorSettings`), а не настройки шлюза: в его контейнере нет переменных MinIO/NATS. С каталогами работает через `LocalHotFolder`, тем самым тестируя и его. Индекс хешей пересчитывается только при изменении списка файлов в -каталоге фикстур (Review Focus 5). +каталоге фикстур. """ import hashlib @@ -286,16 +286,21 @@ def process_once( # noqa: PLR0913 for entry, job_id in pending: try: data = entry.read_bytes() + except OSError as exc: + logger.warning("Эмулятор: ошибка чтения файла {} ({}), пропущен", entry.name, exc) + continue + try: stem, source = resolve_stem(data, index, default) digest = hashlib.sha256(data).hexdigest() miss_message = f"{digest[:12]}: нет пары по содержимому в фикстурах" + source_str = "по умолчанию" if source == "default" else source _publish_answers(hotfolder, job_id, _build_answers(job_id, stem, fixtures_dir, miss_message=miss_message)) os.replace(entry, entry.with_name(entry.name + ".done")) except FileNotFoundError: logger.warning("Эмулятор: файл {} исчез во время обработки", entry.name) continue processed.append(job_id) - logger.info("Эмулятор: {} -> {} (пара: {})", entry.name, hotfolder.answer_dir(job_id), source) + logger.info("Эмулятор: {} -> {} (пара: {})", entry.name, hotfolder.answer_dir(job_id), source_str) return processed @@ -306,7 +311,7 @@ def _count_fixtures(index: FixtureIndex) -> int: def run(settings: EmulatorSettings, stop: threading.Event, *, max_iterations: int | None = None) -> None: """Цикл эмулятора: `process_once` каждые `poll_interval` секунд, пока не взведён `stop` (или не выполнено `max_iterations` проходов — для тестов). Сбой прохода логируется и не останавливает цикл. Индекс фикстур - строится один раз и обновляется на каждом проходе через refresh().""" + строится один раз и обновляется на каждом проходе через refresh() только при передаче в process_once().""" hotfolder = LocalHotFolder(settings.hotfolder_path, settings.input_dir, settings.output_dir, settings.tmp_dir) logger.info( "Эмулятор ИС Сканирование: корень {}, входящий каталог {}, исходящий каталог {}, задержка {} с, опрос {} с", @@ -316,9 +321,8 @@ def run(settings: EmulatorSettings, stop: threading.Event, *, max_iterations: in settings.delay, settings.poll_interval, ) - index: FixtureIndex | None = None + index = build_index(settings.fixtures_dir) if settings.fixtures_dir.is_dir(): - index = build_index(settings.fixtures_dir) logger.info("Фикстуры: {}, пар: {}", settings.fixtures_dir, _count_fixtures(index)) else: logger.warning( @@ -337,8 +341,7 @@ def run(settings: EmulatorSettings, stop: threading.Event, *, max_iterations: in default=settings.default, index=index, ) - if index is not None: - index = index.refresh(settings.fixtures_dir) + index = index.refresh(settings.fixtures_dir) except Exception: # noqa: BLE001 — эмулятор живёт, пока его не остановят logger.exception("Эмулятор: сбой прохода по входящему каталогу") iterations += 1 diff --git a/api/tests/unit/test_emulator.py b/api/tests/unit/test_emulator.py index d95ad96..4bee3d7 100644 --- a/api/tests/unit/test_emulator.py +++ b/api/tests/unit/test_emulator.py @@ -140,14 +140,17 @@ class TestHashIndex: assert process_once(hf.root, fixtures, 0) == [] assert not hf.output_dir.exists() or list(hf.output_dir.iterdir()) == [] - def test_default_set_on_miss(self, tmp_path: Path) -> None: + def test_default_set_on_miss(self, tmp_path: Path, log_messages: list[str]) -> None: hf = _hotfolder(tmp_path) fixtures = _fixtures_dir( tmp_path, {"dflt.json": {"file_uid": "x", "error": {"code": "", "message": ""}, "data_ocr": {}}} ) + (fixtures / "dflt.pdf").write_bytes(b"dflt data") job = _drop(hf, "any.pdf", data=b"unknown bytes") assert process_once(hf.root, fixtures, 0, default="dflt") == [job] assert _answer(hf, job)["error"]["code"] == "" + # M-6: Log message uses "по умолчанию" + assert any("по умолчанию" in m for m in log_messages) def test_miss_without_default_is_invalid_document(self, tmp_path: Path) -> None: hf = _hotfolder(tmp_path) @@ -168,6 +171,71 @@ class TestHashIndex: assert (hf.input_dir / f"{job}.pdf.done").exists() assert _answer(hf, job)["file_uid"] == job + def test_upload_file_read_error_continues(self, tmp_path: Path, log_messages: list[str]) -> None: + """T14.3: OSError при чтении входящего файла логируется одной строкой, проход продолжается.""" + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}}) + (fixtures / "a.pdf").write_bytes(b"a data") + hf.input_dir.mkdir() + + job_id = _uid() + bad_file = hf.input_dir / f"{job_id}.pdf" + bad_file.write_bytes(b"test") + + # Сделать файл нечитаемым + os.chmod(bad_file, 0o000) + + try: + job_id2 = _drop(hf, "a.pdf", data=b"a data") + result = process_once(hf.root, fixtures, 0) + + # Первый файл должен быть пропущен с логом, второй обработан + assert job_id2 in result + has_warning = any( + m.startswith("WARNING|") and "ошибка чтения файла" in m and bad_file.name in m for m in log_messages + ) + assert has_warning + finally: + # Восстановить права для очистки + os.chmod(bad_file, 0o644) + + def test_uppercase_extension_accepted(self, tmp_path: Path) -> None: + """M-4: Файл .PDF (верхний регистр) принимается.""" + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}}) + (fixtures / "a.pdf").write_bytes(b"a data") + job_id = _drop(hf, "a.PDF", data=b"a data") + + assert process_once(hf.root, fixtures, 0) == [job_id] + assert (hf.input_dir / f"{job_id}.pdf.done").exists() + + def test_ignores_part_tmp_done_robust(self, tmp_path: Path) -> None: + """T14.2c: Усиленный тест — падает при отсутствии skip-списка.""" + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}}) + (fixtures / "a.pdf").write_bytes(b"a data") + hf.input_dir.mkdir(exist_ok=True) + hf.tmp_dir.mkdir(exist_ok=True) + + job = _uid() + # Файлы для пропуска + (hf.input_dir / f"{job}.pdf.done").write_bytes(b"skip") + (hf.input_dir / f"{job}.pdf.tmp").write_bytes(b"skip") + (hf.input_dir / f"{job}.pdf.part").write_bytes(b"skip") + (hf.tmp_dir / f".{job}.pdf.part").write_bytes(b"skip") + + # Нормальный файл + job2 = _drop(hf, "a.pdf", data=b"a data") + + result = process_once(hf.root, fixtures, 0) + assert result == [job2] + + # Все skip-файлы остаются + assert (hf.input_dir / f"{job}.pdf.done").exists() + assert (hf.input_dir / f"{job}.pdf.tmp").exists() + assert (hf.input_dir / f"{job}.pdf.part").exists() + assert (hf.tmp_dir / f".{job}.pdf.part").exists() + @pytest.mark.fixtures class TestRealFixtures: