arm-scan-gateway/api/tests/unit/test_local_hotfolder.py
Andrey Epifancev 7d8a506f4d fix(hotfolder): ошибка открытия входящего файла не удаляет файл прошлой доставки (#26, I-1)
Открытие вынесено из блока снятия недописанного файла: при SCAN_CLEANUP=true
удаляется только файл, сбойнувший после открытия. Решения R-5…R-8 — в design.md.

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

571 lines
25 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.

"""Горячая папка в обычном каталоге (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", "a<b.pdf", "a>b.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` каталога `<output>/<job_id>/` по имени, когда набор настоялся `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