diff --git a/api/src/scan_emulator/__init__.py b/api/src/scan_emulator/__init__.py index 084481e..f90358e 100644 --- a/api/src/scan_emulator/__init__.py +++ b/api/src/scan_emulator/__init__.py @@ -256,12 +256,15 @@ def process_once( # noqa: PLR0913 один раз за проход ждёт `delay` секунд. Для каждого файла: считает sha256 содержимого, ищет в индексе фикстур, при совпадении кладёт набор фикстур, при промахе — набор по умолчанию (если задан) или ошибку. Публикует набор ответов `//` (атомарно, через `tmp_dir`) и переименовывает входящий файл в `….done`. Имена не по - шаблону игнорируются (одно предупреждение за проход). Индекс обновляется (refresh) в начале прохода. Нет - входящего каталога — пустой результат. + шаблону игнорируются (одно предупреждение за проход). Если `index=None`, строит индекс один раз в начале + (refresh — ответственность вызывающей стороны). Нет входящего каталога — пустой результат. """ hotfolder = LocalHotFolder(root, input_dir, output_dir, tmp_dir) fixtures_dir = Path(fixtures) - index = (index or build_index(fixtures_dir)).refresh(fixtures_dir) + if index is None: + index = build_index(fixtures_dir) + else: + index = index.refresh(fixtures_dir) try: entries = sorted(hotfolder.input_dir.iterdir(), key=lambda p: p.name) except FileNotFoundError: @@ -311,7 +314,8 @@ 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() только при передаче в process_once().""" + строится один раз при старте и обновляется один раз в конце каждого прохода через refresh() — всего один refresh + за проход, если файлы в каталоге изменились.""" hotfolder = LocalHotFolder(settings.hotfolder_path, settings.input_dir, settings.output_dir, settings.tmp_dir) logger.info( "Эмулятор ИС Сканирование: корень {}, входящий каталог {}, исходящий каталог {}, задержка {} с, опрос {} с", diff --git a/api/tests/unit/test_emulator.py b/api/tests/unit/test_emulator.py index 4bee3d7..5b0c4a0 100644 --- a/api/tests/unit/test_emulator.py +++ b/api/tests/unit/test_emulator.py @@ -128,7 +128,7 @@ class TestHashIndex: assert refreshed is not index and set(refreshed.by_hash.values()) == {"a", "b"} def test_ignores_part_tmp_done(self, tmp_path: Path) -> None: - """Review Focus 5: временные и обработанные файлы не обрабатываются.""" + """Временные и обработанные файлы не обрабатываются.""" hf = _hotfolder(tmp_path) fixtures = _fixtures_dir(tmp_path) hf.input_dir.mkdir(exist_ok=True) @@ -171,6 +171,7 @@ class TestHashIndex: assert (hf.input_dir / f"{job}.pdf.done").exists() assert _answer(hf, job)["file_uid"] == job + @pytest.mark.skipif(os.geteuid() == 0, reason="chmod test not applicable when running as root") def test_upload_file_read_error_continues(self, tmp_path: Path, log_messages: list[str]) -> None: """T14.3: OSError при чтении входящего файла логируется одной строкой, проход продолжается.""" hf = _hotfolder(tmp_path) @@ -200,14 +201,20 @@ class TestHashIndex: os.chmod(bad_file, 0o644) def test_uppercase_extension_accepted(self, tmp_path: Path) -> None: - """M-4: Файл .PDF (верхний регистр) принимается.""" + """M-4: Файл .PDF (верхний регистр) принимается и переименовывается в .pdf.done.""" 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") + # Писать файл напрямую, чтобы проверить обработку .PDF (hf.put нормализует расширение) + job_id = str(uuid.uuid4()) + hf.input_dir.mkdir(parents=True, exist_ok=True) + (hf.input_dir / f"{job_id}.PDF").write_bytes(b"a data") assert process_once(hf.root, fixtures, 0) == [job_id] + # Файл переименован в .pdf.done (расширение нормализовано в lowercase) assert (hf.input_dir / f"{job_id}.pdf.done").exists() + # Ответ создан + assert hf.answer_path(job_id).exists() def test_ignores_part_tmp_done_robust(self, tmp_path: Path) -> None: """T14.2c: Усиленный тест — падает при отсутствии skip-списка.""" @@ -701,6 +708,85 @@ class TestRun: assert str(fixtures) in startup assert any("пар: 2" in m for m in log_messages) + def test_build_index_not_called_repeatedly_when_fixtures_unchanged( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """T14.2(a): build_index() не вызывается повторно на каждом проходе, если файлы в фикстурах не меняются. + + Минимум 5 проходов (max_iterations=5) без изменения списка файлов должны привести к ровно 1 вызову + build_index() (при инициализации run), остальные проходы используют refresh(). + """ + _hotfolder(tmp_path) # Создаём структуру горячей папки + fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}}) + (fixtures / "a.pdf").write_bytes(b"a data") + + build_index_calls: list[int] = [] + original_build_index = emulator.build_index + + def spy_build_index(*args: object, **kwargs: object) -> emulator.FixtureIndex: + build_index_calls.append(1) + return original_build_index(*args, **kwargs) + + monkeypatch.setattr(emulator, "build_index", spy_build_index) + run(_settings(tmp_path, fixtures), threading.Event(), max_iterations=5) + + # build_index вызван ровно один раз (при инициализации run) + assert len(build_index_calls) == 1 + + def test_run_with_missing_then_appearing_fixtures_dir(self, tmp_path: Path) -> None: + """T14.2(b): run() обрабатывает появление каталога фикстур ПОСЛЕ стартапа без ошибок. + + Стартуем с несуществующим каталогом фикстур, затем каталог создаётся. + Эмулятор не должен выбросить исключение, а индекс должен обновиться. + """ + _hotfolder(tmp_path) # Создаём структуру горячей папки + fixtures = tmp_path / "fixtures" + + # Стартуем с несуществующим каталогом - не должно быть ошибки + stop = threading.Event() + stop.set() # Стоп после первого прохода (в run цикл ждёт stop.is_set) + run(_settings(tmp_path, fixtures), stop, max_iterations=1) + + # После успешного запуска с отсутствующим каталогом создаём его + fixtures.mkdir(exist_ok=True) + (fixtures / "a.json").write_text('{"v": 1}') + (fixtures / "a.pdf").write_bytes(b"test data") + + # Второй запуск должен использовать новый каталог + stop.clear() + stop.set() + run(_settings(tmp_path, fixtures), stop, max_iterations=1) + # Если мы сюда дошли без исключения - тест прошёл + + def test_skip_suffixes_not_logged_as_regex_mismatch( + self, tmp_path: Path, log_messages: list[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + """T14.2(c): Файлы в _SKIP_SUFFIXES пропускаются молча, не логируются как не совпадающие с шаблоном. + + Мутационный тест: если убрать проверку _SKIP_SUFFIXES, тест сломается (файлы будут залогированы). + """ + 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_id = _uid() + # Кладём файлы с _SKIP_SUFFIXES + (hf.input_dir / f"{job_id}.pdf.done").write_bytes(b"skip") + (hf.input_dir / f"{job_id}.pdf.tmp").write_bytes(b"skip") + (hf.input_dir / f"{job_id}.pdf.part").write_bytes(b"skip") + + # Кладём нормальный файл для проверки, что обработка работает + job_id2 = _drop(hf, "a.pdf", data=b"a data") + + result = process_once(hf.root, fixtures, 0) + assert job_id2 in result + + # Логи НЕ должны содержать сообщения о "не по шаблону" для файлов с _SKIP_SUFFIXES + foreign_warnings = [m for m in log_messages if m.startswith("WARNING|") and "не по шаблону" in m] + assert len(foreign_warnings) == 0 # Ни одного предупреждения про "не по шаблону" + def test_missing_fixtures_dir_warns_and_continues(self, tmp_path: Path, log_messages: list[str]) -> None: hf = _hotfolder(tmp_path) missing = tmp_path / "нет"