"""Горячая папка в обычном каталоге (spec image-recognition-gateway, D-1, R-4/R-5, slice-0-skeleton). Быстрые тесты на `tmp_path`: запись входящего файла прямо в `Input` с перезаписью, опрос набора ответов ИС с готовностью по стабилизации (settle, часы подменены), проверка здоровья, валидация имён. """ import errno import os import uuid from pathlib import Path from typing import Any import pytest from src.gateway import hotfolder as hotfolder_module from src.gateway.hotfolder import AnswerFile, HotFolder, LocalHotFolder, SettleTracker, inbox_name def _job_id() -> str: return str(uuid.uuid4()) class FakeClock: """Монотонные часы теста: время двигается только вручную.""" def __init__(self) -> None: self.now = 100.0 def __call__(self) -> float: return self.now def _write_answer(hf: LocalHotFolder, job_id: str, data: bytes, name: str | None = None) -> Path: path = hf.answer_dir(job_id) / (name or f"{job_id}.json") path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) return path def _settled(tmp_path: Path, settle: float = 2) -> tuple[LocalHotFolder, FakeClock]: clock = FakeClock() return LocalHotFolder(tmp_path, settle_seconds=settle, clock=clock), clock class _FailingFile: """Файл, открытый на запись: `write` пишет первые два байта и падает (диск полон).""" def __init__(self, path: Any, mode: str) -> None: self._handle = open(path, mode) # noqa: SIM115 — закрывается в __exit__ def __enter__(self) -> "_FailingFile": return self def __exit__(self, *exc: object) -> None: self._handle.close() def write(self, data: bytes) -> int: self._handle.write(data[:2]) raise OSError(errno.ENOSPC, "диск полон") def _failing_open(path: Any, mode: str = "r", *args: Any, **kwargs: Any) -> _FailingFile: return _FailingFile(path, mode) def _failing_unlink(self: Path, missing_ok: bool = False) -> None: raise PermissionError(errno.EACCES, "нет прав") class TestInboxName: def test_job_id_and_lowercase_extension(self) -> None: job_id = _job_id() assert inbox_name(job_id, "aep_5.PDF") == f"{job_id}.pdf" assert inbox_name(job_id, "dir/sub/Отчёт за 2024.Jpeg") == f"{job_id}.jpeg" assert inbox_name(job_id, "C:\\scans\\report.pdf") == f"{job_id}.pdf" @pytest.mark.parametrize( "original", ["scan", "", ".", "..", "report.", "a/..", "x.a:b", "x." + "p" * 17, "name.тар"], ) def test_rejects_names_without_safe_extension(self, original: str) -> None: with pytest.raises(ValueError, match="расширени"): inbox_name("job", original) class TestCleanup: def _fill(self, hf: LocalHotFolder, job_id: str) -> None: hf.put(f"{job_id}.pdf", b"x") (hf.input_dir / f"{job_id}.pdf.done").write_bytes(b"") hf.tmp_dir.mkdir(exist_ok=True) (hf.tmp_dir / f".{job_id}.pdf.part").write_bytes(b"legacy") # остаток прежних версий шлюза — не наш _write_answer(hf, job_id, b"{}") _write_answer(hf, job_id, b"{}", name=f"{job_id}_ofr.json") (hf.answer_dir(job_id) / "readme.txt").write_bytes(b"keep") def test_disabled_touches_nothing(self, tmp_path: Path) -> None: hf = LocalHotFolder(tmp_path) job_id = _job_id() self._fill(hf, job_id) assert hf.cleanup(job_id, f"{job_id}.pdf") is False assert (hf.input_dir / f"{job_id}.pdf").exists() and hf.answer_dir(job_id).is_dir() assert (hf.tmp_dir / f".{job_id}.pdf.part").exists() def test_enabled_removes_job_files(self, tmp_path: Path) -> None: hf = LocalHotFolder(tmp_path, cleanup=True) job_id = _job_id() self._fill(hf, job_id) assert hf.cleanup(job_id, f"{job_id}.pdf") is True assert not (hf.input_dir / f"{job_id}.pdf").exists() assert not (hf.input_dir / f"{job_id}.pdf.done").exists() assert (hf.tmp_dir / f".{job_id}.pdf.part").exists() # Tmp очистка не трогает assert not hf.answer_dir(job_id).exists() # каталог удалён вместе с readme.txt assert hf.cleanup(job_id, f"{job_id}.pdf") is True # повтор — не ошибка def test_missing_files_are_fine(self, tmp_path: Path) -> None: hf = LocalHotFolder(tmp_path, cleanup=True) assert hf.cleanup(_job_id(), "nothing.pdf") is True def test_permission_error_propagates(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: hf = LocalHotFolder(tmp_path, cleanup=True) job_id = _job_id() self._fill(hf, job_id) def deny(path: Any, *args: Any, **kwargs: Any) -> None: raise PermissionError(f"нет прав: {path}") monkeypatch.setattr(os, "unlink", deny) with pytest.raises(PermissionError): hf.cleanup(job_id, f"{job_id}.pdf") def test_rejects_unsafe_names(self, tmp_path: Path) -> None: """cleanup: validate_name защищает от выхода за пределы папки (T11.4).""" hf = LocalHotFolder(tmp_path, cleanup=True) with pytest.raises(ValueError, match="имя файла"): hf.cleanup(_job_id(), "../x.pdf") # ничего не удалено assert not hf.input_dir.exists() class TestPaths: def test_default_layout(self, tmp_path: Path) -> None: hf = LocalHotFolder(tmp_path) job_id = _job_id() assert hf.root == tmp_path assert hf.input_dir == tmp_path / "Input" assert hf.output_dir == tmp_path / "Output" assert hf.tmp_dir == tmp_path / "Tmp" assert hf.answer_dir(job_id) == tmp_path / "Output" / job_id assert hf.answer_path(job_id) == tmp_path / "Output" / job_id / f"{job_id}.json" def test_custom_dir_names_and_str_root(self, tmp_path: Path) -> None: hf = LocalHotFolder(str(tmp_path), input_dir="Входящие", output_dir="Исходящие", tmp_dir="Служебный") assert hf.root == tmp_path assert hf.input_dir == tmp_path / "Входящие" assert hf.output_dir == tmp_path / "Исходящие" assert hf.tmp_dir == tmp_path / "Служебный" assert hf.answer_path("abc") == tmp_path / "Исходящие" / "abc" / "abc.json" @pytest.mark.parametrize("job_id", ["", ".", "..", "../x", "a/b", "a\\b", "a:b", "a?b", "a*b", "a\x00b", "a\nb"]) def test_answer_path_rejects_unsafe_job_id(self, tmp_path: Path, job_id: str) -> None: hf = LocalHotFolder(tmp_path) with pytest.raises(ValueError, match="job_id"): hf.answer_path(job_id) with pytest.raises(ValueError, match="job_id"): hf.answer_dir(job_id) with pytest.raises(ValueError, match="job_id"): hf.poll(job_id) def test_negative_settle_is_rejected(self, tmp_path: Path) -> None: with pytest.raises(ValueError, match="settle"): LocalHotFolder(tmp_path, settle_seconds=-1) def test_implements_protocol(self, tmp_path: Path) -> None: hf: HotFolder = LocalHotFolder(tmp_path) assert hf.healthy() is True assert hasattr(hf, "cleanup") class TestPut: def test_writes_bytes_under_inbox_name(self, tmp_path: Path) -> None: hf = LocalHotFolder(tmp_path) job_id = _job_id() name = inbox_name(job_id, "aep_5.pdf") data = bytes(range(256)) * 10 hf.put(name, data) assert (tmp_path / "Input" / name).read_bytes() == data def test_creates_input_dir_only(self, tmp_path: Path) -> None: hf = LocalHotFolder(tmp_path) hf.put("a.pdf", b"1") assert sorted(p.name for p in tmp_path.iterdir()) == ["Input"] # Tmp не создаётся def test_existing_file_is_overwritten(self, tmp_path: Path) -> None: """Как в старом АРМ (решение владельца 09.10.2026): повтор задания перезаписывает входящий файл.""" hf = LocalHotFolder(tmp_path, settle_seconds=0) hf.put("job.pdf", b"first, longer") hf.put("job.pdf", b"second") assert (hf.input_dir / "job.pdf").read_bytes() == b"second" def test_write_failure_keeps_partial_file_when_cleanup_disabled( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: hf = LocalHotFolder(tmp_path, settle_seconds=0) monkeypatch.setattr(hotfolder_module, "open", _failing_open, raising=False) with pytest.raises(OSError, match="диск полон"): hf.put("job.pdf", b"data") assert (hf.input_dir / "job.pdf").read_bytes() == b"da" def test_write_failure_removes_partial_file_when_cleanup_enabled( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: hf = LocalHotFolder(tmp_path, settle_seconds=0, cleanup=True) monkeypatch.setattr(hotfolder_module, "open", _failing_open, raising=False) with pytest.raises(OSError, match="диск полон"): hf.put("job.pdf", b"data") assert not (hf.input_dir / "job.pdf").exists() def test_remove_failure_does_not_mask_write_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: hf = LocalHotFolder(tmp_path, settle_seconds=0, cleanup=True) monkeypatch.setattr(hotfolder_module, "open", _failing_open, raising=False) monkeypatch.setattr(Path, "unlink", _failing_unlink) with pytest.raises(OSError, match="диск полон"): hf.put("job.pdf", b"data") @pytest.mark.parametrize("cleanup", [False, True]) def test_open_failure_never_removes_existing_file(self, tmp_path: Path, cleanup: bool) -> None: """I-1: открыть не удалось — недописанного файла нет, целый файл прошлой доставки не трогаем.""" hf = LocalHotFolder(tmp_path, settle_seconds=0, cleanup=cleanup) hf.input_dir.mkdir() delivered = hf.input_dir / "job.pdf" delivered.write_bytes(b"delivered") delivered.chmod(0o444) try: with pytest.raises(PermissionError): hf.put("job.pdf", b"new") assert delivered.read_bytes() == b"delivered" finally: delivered.chmod(0o644) def test_existing_input_dir_is_fine(self, tmp_path: Path) -> None: (tmp_path / "Input").mkdir() LocalHotFolder(tmp_path).put("a.pdf", b"1") assert (tmp_path / "Input" / "a.pdf").read_bytes() == b"1" def test_missing_root_raises_and_creates_nothing(self, tmp_path: Path) -> None: root = tmp_path / "absent" with pytest.raises(FileNotFoundError): LocalHotFolder(root).put("a.pdf", b"1") assert not root.exists() assert list(tmp_path.iterdir()) == [] def test_empty_data_is_written(self, tmp_path: Path) -> None: hf = LocalHotFolder(tmp_path) hf.put("empty.pdf", b"") assert (hf.input_dir / "empty.pdf").read_bytes() == b"" @pytest.mark.parametrize( "name", ["", "../escape.pdf", "sub/a.pdf", "sub\\a.pdf", "/abs.pdf", ".hidden.pdf", ".a.pdf.part", "..", ".", "a\x00b"], ) def test_rejects_invalid_names(self, tmp_path: Path, name: str) -> None: hf = LocalHotFolder(tmp_path) with pytest.raises(ValueError, match="имя файла"): hf.put(name, b"x") assert not hf.input_dir.exists() assert not (tmp_path.parent / "escape.pdf").exists() @pytest.mark.parametrize("name", ["a:b.pdf", "ab.pdf", 'a"b.pdf', "a|b.pdf", "a?b.pdf", "a*b.pdf"]) def test_rejects_names_illegal_on_windows(self, tmp_path: Path, name: str) -> None: hf = LocalHotFolder(tmp_path) with pytest.raises(ValueError, match="имя файла"): hf.put(name, b"x") assert not hf.input_dir.exists() @pytest.mark.parametrize("name", ["a\x01b.pdf", "a\tb.pdf", "a\nb.pdf", "a\x1fb.pdf"]) def test_rejects_control_chars(self, tmp_path: Path, name: str) -> None: with pytest.raises(ValueError, match="имя файла"): LocalHotFolder(tmp_path).put(name, b"x") def test_accepts_inbox_names(self, tmp_path: Path) -> None: hf = LocalHotFolder(tmp_path) job_id_1 = _job_id() job_id_2 = _job_id() names = [inbox_name(job_id_1, "Отчёт за 2024 год (копия).PDF"), inbox_name(job_id_2, "aep_8.pdf")] for name in names: hf.put(name, b"x") assert sorted(p.name for p in hf.input_dir.iterdir()) == sorted(names) class TestPoll: """`poll` — все `*.json` каталога `//` по имени, когда набор настоялся `settle_seconds`.""" def test_none_until_answer_dir_has_json(self, tmp_path: Path) -> None: hf = LocalHotFolder(tmp_path, settle_seconds=0) job_id = _job_id() assert hf.poll(job_id) is None (tmp_path / "Output").mkdir() assert hf.poll(job_id) is None (tmp_path / "Output" / job_id).mkdir() assert hf.poll(job_id) is None def test_missing_root_is_none(self, tmp_path: Path) -> None: assert LocalHotFolder(tmp_path / "absent", settle_seconds=0).poll(_job_id()) is None def test_answer_dir_is_a_file_is_none(self, tmp_path: Path) -> None: hf = LocalHotFolder(tmp_path, settle_seconds=0) job_id = _job_id() (tmp_path / "Output").mkdir() (tmp_path / "Output" / job_id).write_bytes(b"{}") assert hf.poll(job_id) is None def test_settle_zero_returns_at_once(self, tmp_path: Path) -> None: hf = LocalHotFolder(tmp_path, settle_seconds=0) job_id = _job_id() payload = b'{"file_uid": "x", "data_ocr": {}}' _write_answer(hf, job_id, payload) assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", payload)] def test_one_json_after_settle(self, tmp_path: Path) -> None: hf, clock = _settled(tmp_path) job_id = _job_id() _write_answer(hf, job_id, b"{}") assert hf.poll(job_id) is None # набор впервые увиден clock.now += 1.9 assert hf.poll(job_id) is None clock.now += 0.1 assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b"{}")] def test_two_jsons_ascending_by_name(self, tmp_path: Path) -> None: hf, clock = _settled(tmp_path) job_id = _job_id() _write_answer(hf, job_id, b'{"n": 2}', f"{job_id}_ofr.json") _write_answer(hf, job_id, b'{"n": 1}', f"{job_id}.json") assert hf.poll(job_id) is None clock.now += 2 assert hf.poll(job_id) == [ AnswerFile(f"{job_id}.json", b'{"n": 1}'), AnswerFile(f"{job_id}_ofr.json", b'{"n": 2}'), ] def test_non_json_files_and_subdirs_are_ignored(self, tmp_path: Path) -> None: hf, clock = _settled(tmp_path) job_id = _job_id() _write_answer(hf, job_id, b"text", "readme.txt") _write_answer(hf, job_id, b"{}", f".{job_id}.json.part") (hf.answer_dir(job_id) / "nested.json").mkdir() assert hf.poll(job_id) is None clock.now += 10 assert hf.poll(job_id) is None # json-файлов нет — ответа нет, сколько ни жди _write_answer(hf, job_id, b"{}") assert hf.poll(job_id) is None clock.now += 2 assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b"{}")] def test_json_extension_is_case_insensitive(self, tmp_path: Path) -> None: hf = LocalHotFolder(tmp_path, settle_seconds=0) job_id = _job_id() _write_answer(hf, job_id, b"{}", "B.JSON") _write_answer(hf, job_id, b"[]", "a.Json") assert hf.poll(job_id) == [AnswerFile("B.JSON", b"{}"), AnswerFile("a.Json", b"[]")] def test_growing_file_is_not_ready_until_stable(self, tmp_path: Path) -> None: hf, clock = _settled(tmp_path) job_id = _job_id() path = _write_answer(hf, job_id, b'{"part": ') assert hf.poll(job_id) is None clock.now += 1.5 path.write_bytes(b'{"part": 1, "more": ') # размер вырос — таймер заново assert hf.poll(job_id) is None clock.now += 1.5 path.write_bytes(b'{"part": 1, "more": 2}') assert hf.poll(job_id) is None clock.now += 1.9 assert hf.poll(job_id) is None clock.now += 0.1 assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b'{"part": 1, "more": 2}')] def test_new_file_resets_timer(self, tmp_path: Path) -> None: hf, clock = _settled(tmp_path) job_id = _job_id() _write_answer(hf, job_id, b"{}") assert hf.poll(job_id) is None clock.now += 1.5 _write_answer(hf, job_id, b"[]", f"{job_id}_ofr.json") assert hf.poll(job_id) is None clock.now += 1.5 assert hf.poll(job_id) is None # с момента появления второго файла прошло 1,5 с clock.now += 0.5 assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b"{}"), AnswerFile(f"{job_id}_ofr.json", b"[]")] def test_removed_file_resets_timer(self, tmp_path: Path) -> None: hf, clock = _settled(tmp_path) job_id = _job_id() _write_answer(hf, job_id, b"{}") extra = _write_answer(hf, job_id, b"[]", f"{job_id}_ofr.json") assert hf.poll(job_id) is None clock.now += 1.5 extra.unlink() assert hf.poll(job_id) is None clock.now += 1.5 assert hf.poll(job_id) is None clock.now += 0.5 assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b"{}")] def test_removal_of_all_json_is_none_and_restarts(self, tmp_path: Path) -> None: hf, clock = _settled(tmp_path) job_id = _job_id() path = _write_answer(hf, job_id, b"{}") assert hf.poll(job_id) is None clock.now += 1 path.unlink() assert hf.poll(job_id) is None clock.now += 5 path.write_bytes(b"{}") # тот же набор снова: настаивается заново, а не «с прошлого раза» assert hf.poll(job_id) is None clock.now += 2 assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b"{}")] def test_state_is_dropped_after_answer(self, tmp_path: Path) -> None: hf, clock = _settled(tmp_path) job_id = _job_id() _write_answer(hf, job_id, b"{}") assert hf.poll(job_id) is None clock.now += 2 assert hf.poll(job_id) is not None assert hf.pending_jobs() == 0 assert hf.poll(job_id) is None # состояние сброшено: повторный опрос настаивает набор заново def test_other_job_is_unaffected(self, tmp_path: Path) -> None: hf, clock = _settled(tmp_path) job_id, other = _job_id(), _job_id() _write_answer(hf, other, b"{}") assert hf.poll(other) is None clock.now += 1 _write_answer(hf, job_id, b"[]") assert hf.poll(job_id) is None clock.now += 1 assert hf.poll(job_id) is None assert hf.poll(other) == [AnswerFile(f"{other}.json", b"{}")] clock.now += 1 assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b"[]")] def test_file_changed_between_listing_and_read_is_none( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: hf = LocalHotFolder(tmp_path, settle_seconds=0) job_id = _job_id() _write_answer(hf, job_id, b"{}") monkeypatch.setattr(Path, "read_bytes", lambda self: b'{"longer": true}') assert hf.poll(job_id) is None def test_file_removed_between_listing_and_read_is_none( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: hf = LocalHotFolder(tmp_path, settle_seconds=0) job_id = _job_id() path = _write_answer(hf, job_id, b"{}") def vanished(self: Path) -> bytes: path.unlink() raise FileNotFoundError(str(self)) monkeypatch.setattr(Path, "read_bytes", vanished) assert hf.poll(job_id) is None def test_new_file_appeared_while_reading_is_none_and_set_resettles( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """T2.1: после чтения каталог листается заново; набор изменился (появился файл) — `None`, набор настаивается заново и отдаётся целиком.""" hf, clock = _settled(tmp_path) job_id = _job_id() _write_answer(hf, job_id, b"{}") assert hf.poll(job_id) is None clock.now += 2 real_read = Path.read_bytes def read_then_add(self: Path) -> bytes: data = real_read(self) _write_answer(hf, job_id, b'{"n": 2}', f"{job_id}_ofr.json") return data monkeypatch.setattr(Path, "read_bytes", read_then_add) assert hf.poll(job_id) is None monkeypatch.setattr(Path, "read_bytes", real_read) assert hf.poll(job_id) is None # состояние сброшено: новый набор настаивается с нуля clock.now += 2 assert hf.poll(job_id) == [ AnswerFile(f"{job_id}.json", b"{}"), AnswerFile(f"{job_id}_ofr.json", b'{"n": 2}'), ] def test_file_removed_after_read_is_none(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """T2.1: файл прочитан, но пропал до повторного листинга — набор изменился, `None`.""" hf = LocalHotFolder(tmp_path, settle_seconds=0) job_id = _job_id() _write_answer(hf, job_id, b"{}") second = _write_answer(hf, job_id, b"[]", f"{job_id}_ofr.json") real_read = Path.read_bytes def read_then_remove(self: Path) -> bytes: data = real_read(self) if self == second: second.unlink() return data monkeypatch.setattr(Path, "read_bytes", read_then_remove) assert hf.poll(job_id) is None class TestSettleTracker: def test_snapshot_must_be_stable_for_settle_seconds(self) -> None: clock = FakeClock() tracker = SettleTracker(2, clock) snap = (("a.json", 2),) assert tracker.ready("job", snap) is False clock.now += 2 assert tracker.ready("job", snap) is True assert len(tracker) == 0 def test_empty_snapshot_is_never_ready(self) -> None: assert SettleTracker(0).ready("job", ()) is False def test_zero_settle_keeps_no_state(self) -> None: tracker = SettleTracker(0) assert tracker.ready("job", (("a.json", 2),)) is True assert len(tracker) == 0 def test_state_is_bounded(self) -> None: """Задания, ушедшие по таймауту с ненастоявшимся набором, не копятся: старые вытесняются.""" clock = FakeClock() tracker = SettleTracker(2, clock, max_jobs=3) for job in ("j1", "j2", "j3", "j4"): assert tracker.ready(job, (("a.json", 1),)) is False assert len(tracker) == 3 clock.now += 2 assert tracker.ready("j1", (("a.json", 1),)) is False # вытеснено — настаивается заново assert tracker.ready("j4", (("a.json", 1),)) is True def test_forget(self) -> None: clock = FakeClock() tracker = SettleTracker(2, clock) tracker.ready("job", (("a.json", 1),)) tracker.forget("job") tracker.forget("unknown") assert len(tracker) == 0 def test_negative_settle_is_rejected(self) -> None: with pytest.raises(ValueError, match="settle"): SettleTracker(-0.1) class TestHealthy: def test_true_for_existing_dir(self, tmp_path: Path) -> None: assert LocalHotFolder(tmp_path).healthy() is True def test_false_for_missing_root(self, tmp_path: Path) -> None: assert LocalHotFolder(tmp_path / "absent").healthy() is False def test_false_for_file_root(self, tmp_path: Path) -> None: file_root = tmp_path / "file" file_root.write_bytes(b"x") assert LocalHotFolder(file_root).healthy() is False def test_never_raises(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: def boom(self: Path) -> bool: raise PermissionError("нет доступа") monkeypatch.setattr(Path, "is_dir", boom) assert LocalHotFolder(tmp_path).healthy() is False