arm-scan-gateway/api/tests/unit/test_smb_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

720 lines
33 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.

"""Горячая папка на SMB-шаре (spec image-recognition-gateway, D-1, R-5, slice-0-skeleton).
Модуль `smbclient` подменён фейком: UNC-пути `\\\\host\\share\\...` отображаются на `tmp_path`, ошибки — настоящие
`SMBOSError` (как у smbprotocol), все вызовы записываются. Тесты проверяют сами вызовы и UNC-пути.
"""
import os
import uuid
from pathlib import Path
from types import SimpleNamespace
from typing import IO, Any
import pytest
from smbprotocol.exceptions import NtStatus, SMBException, SMBOSError
from src.gateway import smb
from src.gateway.hotfolder import AnswerFile, HotFolder, inbox_name
from src.gateway.smb import SmbHotFolder
HOST = "smb.example.local"
ROOT = "\\\\smb.example.local\\share\\scan"
INPUT = ROOT + "\\Input"
OUTPUT = ROOT + "\\Output"
class FakeClock:
def __init__(self) -> None:
self.now = 100.0
def __call__(self) -> float:
return self.now
class FakeSmbClient:
"""Подмена модуля `smbclient` (только используемые функции) поверх локального каталога."""
def __init__(self, base: Path) -> None:
self.base = base
self.calls: list[tuple[str, tuple[str, ...], dict[str, Any]]] = []
self.fail: dict[str, BaseException] = {} # имя функции -> исключение, которое она бросит
self.plain_collision = False # rename на существующее назначение: обычный FileExistsError вместо SMBOSError
self.path = SimpleNamespace(isdir=self._isdir) # подмодуль smbclient.path
def _isdir(self, path: str, **kwargs: Any) -> bool:
self._record("isdir", (path,), kwargs)
return self._local(path).is_dir()
def _record(self, func: str, args: tuple[str, ...], kwargs: dict[str, Any]) -> None:
self.calls.append((func, args, kwargs))
if func in self.fail:
raise self.fail[func]
def _local(self, unc: str) -> Path:
assert unc.startswith("\\\\"), f"не UNC-путь: {unc!r}"
assert "/" not in unc, f"прямой слеш в UNC-пути: {unc!r}"
return self.base.joinpath(*[part for part in unc.split("\\") if part])
def open_file(self, path: str, mode: str = "r", **kwargs: Any) -> IO[Any]:
self._record("open_file", (path, mode), kwargs)
local = self._local(path)
if not local.parent.is_dir():
raise SMBOSError(NtStatus.STATUS_OBJECT_PATH_NOT_FOUND, path)
if "r" in mode and not local.is_file():
raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, path)
return open(local, mode) # noqa: SIM115 — возвращаем файловый объект, как smbclient.open_file
def mkdir(self, path: str, **kwargs: Any) -> None:
self._record("mkdir", (path,), kwargs)
local = self._local(path)
if local.exists():
raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_COLLISION, path)
if not local.parent.is_dir():
raise SMBOSError(NtStatus.STATUS_OBJECT_PATH_NOT_FOUND, path)
local.mkdir()
def replace(self, src: str, dst: str, **kwargs: Any) -> None:
self._record("replace", (src, dst), kwargs)
local_src = self._local(src)
if not local_src.exists():
raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, src)
os.replace(local_src, self._local(dst))
def rename(self, src: str, dst: str, **kwargs: Any) -> None:
self._record("rename", (src, dst), kwargs)
target = self._local(dst)
if target.exists():
# настоящий smbprotocol: SMBOSError(STATUS_OBJECT_NAME_COLLISION), errno = EEXIST, не FileExistsError
raise (
FileExistsError(dst) if self.plain_collision else SMBOSError(NtStatus.STATUS_OBJECT_NAME_COLLISION, dst)
)
os.rename(self._local(src), target)
def remove(self, path: str, **kwargs: Any) -> None:
self._record("remove", (path,), kwargs)
try:
os.unlink(self._local(path))
except FileNotFoundError:
raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, path) from None
def rmdir(self, path: str, **kwargs: Any) -> None:
self._record("rmdir", (path,), kwargs)
local = self._local(path)
if not local.exists():
raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, path)
if any(local.iterdir()):
raise SMBOSError(NtStatus.STATUS_DIRECTORY_NOT_EMPTY, path)
os.rmdir(local)
def scandir(self, path: str, **kwargs: Any) -> list[SimpleNamespace]:
"""Как `smbclient.scandir`: элементы с `name`, `is_file()` и `smb_info.end_of_file` (размер из листинга)."""
self._record("scandir", (path,), kwargs)
local = self._local(path)
if not local.parent.is_dir():
raise SMBOSError(NtStatus.STATUS_OBJECT_PATH_NOT_FOUND, path)
if not local.exists():
raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, path)
if not local.is_dir():
raise SMBOSError(NtStatus.STATUS_NOT_A_DIRECTORY, path)
return [
SimpleNamespace(
name=p.name,
is_file=p.is_file,
smb_info=SimpleNamespace(end_of_file=p.stat().st_size if p.is_file() else 0),
)
for p in local.iterdir()
]
def listdir(self, path: str, **kwargs: Any) -> list[str]:
self._record("listdir", (path,), kwargs)
local = self._local(path)
if not local.exists():
raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, path)
if not local.is_dir():
raise SMBOSError(NtStatus.STATUS_NOT_A_DIRECTORY, path)
return [p.name for p in local.iterdir()]
def funcs(self) -> list[str]:
return [func for func, _, _ in self.calls]
@pytest.fixture
def fake(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> FakeSmbClient:
fake = FakeSmbClient(tmp_path / "smb")
monkeypatch.setattr(smb, "smbclient", fake)
return fake
@pytest.fixture
def share_root(fake: FakeSmbClient) -> Path:
"""Существующий корень горячей папки `\\\\smb.example.local\\share\\scan`."""
root = fake.base / HOST / "share" / "scan"
root.mkdir(parents=True)
return root
def _job_id() -> str:
return str(uuid.uuid4())
def _hotfolder(**kwargs: Any) -> SmbHotFolder:
kwargs.setdefault("settle_seconds", 0)
return SmbHotFolder(HOST, "share/scan", **kwargs)
def _write_answer(share_root: Path, job_id: str, data: bytes, name: str | None = None) -> Path:
folder = share_root / "Output" / job_id
folder.mkdir(parents=True, exist_ok=True)
path = folder / (name or f"{job_id}.json")
path.write_bytes(data)
return path
def _fail_write(fake: FakeSmbClient) -> SMBOSError:
"""`open_file` открывает файл, но `write` пишет два байта и падает (диск полон): недописанный файл остаётся."""
error = SMBOSError(NtStatus.STATUS_DISK_FULL, INPUT)
real_open = fake.open_file
def open_file(path: str, mode: str = "r", **kwargs: Any) -> IO[Any]:
handle = real_open(path, mode, **kwargs)
def write(data: bytes) -> int:
handle.raw.write(data[:2]) if hasattr(handle, "raw") else None
handle.flush()
raise error
handle.write = write # type: ignore[method-assign]
return handle
fake.open_file = open_file # type: ignore[method-assign]
return error
class TestPaths:
def test_default_layout(self) -> None:
hf = _hotfolder()
job_id = _job_id()
assert hf.root == ROOT
assert hf.input_dir == INPUT
assert hf.output_dir == OUTPUT
assert hf.answer_dir(job_id) == f"{OUTPUT}\\{job_id}"
assert hf.answer_path(job_id) == f"{OUTPUT}\\{job_id}\\{job_id}.json"
@pytest.mark.parametrize("path", ["share/scan", "share\\scan", "/share/scan/", "\\share\\scan\\"])
def test_relative_share_path_is_joined_with_host(self, path: str) -> None:
assert SmbHotFolder(HOST, path).root == ROOT
@pytest.mark.parametrize("path", ["\\\\srv\\share\\scan", "//srv/share/scan", "\\\\srv\\share\\scan\\"])
def test_full_unc_path_is_used_as_is(self, path: str) -> None:
assert SmbHotFolder(HOST, path).root == "\\\\srv\\share\\scan"
def test_share_root_only(self) -> None:
assert SmbHotFolder(HOST, "share").root == "\\\\smb.example.local\\share"
def test_custom_dir_names(self) -> None:
hf = SmbHotFolder(HOST, "share/scan", input_dir="Входящие", output_dir="Исходящие")
assert hf.input_dir == ROOT + "\\Входящие"
assert hf.output_dir == ROOT + "\\Исходящие"
assert hf.answer_path("abc") == ROOT + "\\Исходящие\\abc\\abc.json"
@pytest.mark.parametrize(("host", "path"), [(HOST, ""), (HOST, "/"), ("", "share/scan"), ("", "\\\\srv")])
def test_rejects_incomplete_location(self, host: str, path: str) -> None:
with pytest.raises(ValueError, match="SMB"):
SmbHotFolder(host, path)
@pytest.mark.parametrize("job_id", ["", ".", "..", "../x", "a/b", "a\\b", "a:b", "a\x00b"])
def test_answer_path_rejects_unsafe_job_id(self, job_id: str) -> None:
with pytest.raises(ValueError, match="job_id"):
_hotfolder().answer_path(job_id)
with pytest.raises(ValueError, match="job_id"):
_hotfolder().answer_dir(job_id)
def test_negative_settle_is_rejected(self) -> None:
with pytest.raises(ValueError, match="settle"):
_hotfolder(settle_seconds=-1)
def test_constructor_does_not_touch_share(self, fake: FakeSmbClient) -> None:
_hotfolder(login="user", password="secret")
assert fake.calls == []
def test_implements_protocol(self, fake: FakeSmbClient, share_root: Path) -> None:
hf: HotFolder = _hotfolder()
assert hf.healthy() is True
class TestPut:
def test_writes_directly_into_input(self, fake: FakeSmbClient, share_root: Path) -> None:
name = inbox_name(_job_id(), "aep_5.PDF")
data = bytes(range(256)) * 10
_hotfolder().put(name, data)
assert fake.calls == [("mkdir", (INPUT,), {}), ("open_file", (f"{INPUT}\\{name}", "wb"), {})]
assert (share_root / "Input" / name).read_bytes() == data
assert sorted(p.name for p in share_root.iterdir()) == ["Input"] # Tmp не создаётся
def test_existing_input_dir_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
(share_root / "Input").mkdir()
_hotfolder().put("a.pdf", b"1")
assert (share_root / "Input" / "a.pdf").read_bytes() == b"1"
def test_missing_root_raises_and_writes_nothing(self, fake: FakeSmbClient) -> None:
with pytest.raises(OSError) as excinfo:
_hotfolder().put("a.pdf", b"1")
assert isinstance(excinfo.value, SMBOSError)
assert fake.funcs() == ["mkdir"]
assert not fake.base.exists()
def test_mkdir_denied_but_input_exists_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
"""Урезанные ACL: нельзя создавать подкаталоги в корне, но `Input` уже есть и в него писать можно."""
(share_root / "Input").mkdir()
fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
_hotfolder().put("a.pdf", b"1")
assert (share_root / "Input" / "a.pdf").read_bytes() == b"1"
assert fake.funcs() == ["mkdir", "isdir", "open_file"]
def test_mkdir_denied_and_no_input_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
fake.fail["mkdir"] = denied
with pytest.raises(SMBOSError) as excinfo:
_hotfolder().put("a.pdf", b"1")
assert excinfo.value is denied
assert fake.funcs() == ["mkdir", "isdir"]
def test_mkdir_denied_and_isdir_fails_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
fake.fail["mkdir"] = denied
fake.fail["isdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
with pytest.raises(SMBOSError) as excinfo:
_hotfolder().put("a.pdf", b"1")
assert excinfo.value is denied
assert fake.funcs() == ["mkdir", "isdir"]
def test_empty_data_is_written(self, fake: FakeSmbClient, share_root: Path) -> None:
_hotfolder().put("empty.pdf", b"")
assert (share_root / "Input" / "empty.pdf").read_bytes() == b""
def test_spaces_and_cyrillic_are_accepted(self, fake: FakeSmbClient, share_root: Path) -> None:
name = inbox_name(_job_id(), "Отчёт за 2024 год (копия).pdf")
_hotfolder().put(name, b"x")
assert (share_root / "Input" / name).read_bytes() == b"x"
@pytest.mark.parametrize(
"name",
[
"",
"../escape.pdf",
"sub/a.pdf",
"sub\\a.pdf",
"/abs.pdf",
".hidden.pdf",
".a.pdf.part",
"..",
".",
"a\x00b",
"a:b.pdf",
"a<b.pdf",
"a>b.pdf",
'a"b.pdf',
"a|b.pdf",
"a?b.pdf",
"a*b.pdf",
"a\x01b.pdf",
"a\nb.pdf",
],
)
def test_rejects_invalid_names_before_any_call(self, fake: FakeSmbClient, share_root: Path, name: str) -> None:
with pytest.raises(ValueError, match="имя файла"):
_hotfolder().put(name, b"x")
assert fake.calls == []
def test_write_failure_keeps_partial_file_when_cleanup_disabled(
self, fake: FakeSmbClient, share_root: Path
) -> None:
_fail_write(fake)
with pytest.raises(SMBOSError):
_hotfolder().put("a.pdf", b"data")
assert (share_root / "Input" / "a.pdf").read_bytes() == b"da"
assert "remove" not in fake.funcs()
def test_write_failure_removes_partial_file_when_cleanup_enabled(
self, fake: FakeSmbClient, share_root: Path
) -> None:
_fail_write(fake)
with pytest.raises(SMBOSError):
_hotfolder(cleanup=True).put("a.pdf", b"data")
assert fake.calls[-1] == ("remove", (f"{INPUT}\\a.pdf",), {})
assert not (share_root / "Input" / "a.pdf").exists()
def test_cleanup_error_does_not_mask_original(self, fake: FakeSmbClient, share_root: Path) -> None:
original = _fail_write(fake)
fake.fail["remove"] = SMBException("соединение потеряно")
with pytest.raises(SMBOSError) as excinfo:
_hotfolder(cleanup=True).put("a.pdf", b"data")
assert excinfo.value is original
assert fake.funcs() == ["mkdir", "open_file", "remove"]
@pytest.mark.parametrize("cleanup", [False, True])
def test_open_failure_never_removes_existing_file(
self, fake: FakeSmbClient, share_root: Path, cleanup: bool
) -> None:
"""I-1: открыть не удалось (например, ИС держит файл повторённого задания) — недописанного файла нет, целый
файл прошлой доставки не трогаем даже при включённой очистке; ошибка открытия поднимается как есть."""
(share_root / "Input").mkdir()
(share_root / "Input" / "a.pdf").write_bytes(b"delivered")
denied = SMBOSError(NtStatus.STATUS_SHARING_VIOLATION, INPUT + "\\a.pdf")
fake.fail["open_file"] = denied
with pytest.raises(SMBOSError) as excinfo:
_hotfolder(cleanup=cleanup).put("a.pdf", b"new")
assert excinfo.value is denied
assert "remove" not in fake.funcs()
assert (share_root / "Input" / "a.pdf").read_bytes() == b"delivered"
def test_passes_credentials_to_every_call(self, fake: FakeSmbClient, share_root: Path) -> None:
_hotfolder(login="user", password="secret").put("a.pdf", b"1")
assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 2
class TestPutOverwrite:
def test_existing_file_is_overwritten(self, fake: FakeSmbClient, share_root: Path) -> None:
"""Как в старом АРМ (решение владельца 09.10.2026): повтор задания перезаписывает входящий файл."""
hf = _hotfolder()
hf.put("job.pdf", b"first, longer")
hf.put("job.pdf", b"second")
assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"second"
assert "rename" not in fake.funcs() and "replace" not in fake.funcs()
class TestCleanup:
def test_disabled_is_noop(self, fake: FakeSmbClient, share_root: Path) -> None:
hf = _hotfolder()
hf.put("job.pdf", b"x")
before = len(fake.calls)
assert hf.cleanup("job", "job.pdf") is False
assert len(fake.calls) == before
def test_enabled_removes_everything(self, fake: FakeSmbClient) -> None:
share_root = fake.base / HOST / "share" / "scan"
share_root.mkdir(parents=True)
clock = FakeClock()
hf = SmbHotFolder(HOST, "share\\scan", settle_seconds=0, clock=clock, cleanup=True)
job_id = str(uuid.uuid4())
hf.put(f"{job_id}.pdf", b"x")
answer = fake._local(OUTPUT + f"\\{job_id}")
answer.mkdir(parents=True)
(answer / f"{job_id}.json").write_bytes(b"{}")
(answer / "readme.txt").write_bytes(b"keep")
assert hf.cleanup(job_id, f"{job_id}.pdf") is True
assert not answer.exists() and not fake._local(INPUT + f"\\{job_id}.pdf").exists()
assert [c[0] for c in fake.calls].count("rmdir") == 1
def test_missing_is_fine(self, fake: FakeSmbClient) -> None:
share_root = fake.base / HOST / "share" / "scan"
share_root.mkdir(parents=True)
clock = FakeClock()
hf = SmbHotFolder(HOST, "share\\scan", settle_seconds=0, clock=clock, cleanup=True)
assert hf.cleanup(str(uuid.uuid4()), "nothing.pdf") is True
def test_access_denied_propagates(self, fake: FakeSmbClient) -> None:
share_root = fake.base / HOST / "share" / "scan"
share_root.mkdir(parents=True)
clock = FakeClock()
hf = SmbHotFolder(HOST, "share\\scan", settle_seconds=0, clock=clock, cleanup=True)
hf.put("job.pdf", b"x")
fake.fail["remove"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT + "\\job.pdf")
with pytest.raises(SMBOSError):
hf.cleanup("job", "job.pdf")
class TestCleanupDetails:
"""Очистка (D-2): имена, сироты, ошибки шары; `cleanup=True` во всех тестах."""
@staticmethod
def _hf(fake: FakeSmbClient, **kwargs: Any) -> SmbHotFolder:
(fake.base / HOST / "share" / "scan").mkdir(parents=True, exist_ok=True)
return _hotfolder(cleanup=True, **kwargs)
@pytest.mark.parametrize("name", ["../x", "..\\x", "sub/a.pdf", ".hidden", "", "a:b.pdf"])
def test_rejects_unsafe_name_before_any_call(self, fake: FakeSmbClient, name: str) -> None:
hf = self._hf(fake)
with pytest.raises(ValueError, match="имя файла"):
hf.cleanup(_job_id(), name)
assert fake.calls == []
def test_done_marker_is_removed_and_tmp_is_not_touched(self, fake: FakeSmbClient) -> None:
hf = self._hf(fake)
job_id = _job_id()
name = f"{job_id}.pdf"
root = fake.base / HOST / "share" / "scan"
for folder in ("Input", "Tmp", "Output"):
(root / folder).mkdir()
(root / "Input" / name).write_bytes(b"pdf")
(root / "Input" / f"{name}.done").write_bytes(b"") # маркер ИС
(root / "Tmp" / f".{name}.part").write_bytes(b"legacy") # остаток прежних версий шлюза — не наш
other = root / "Input" / f"{_job_id()}.pdf"
other.write_bytes(b"other job") # чужое задание не трогаем
assert hf.cleanup(job_id, name) is True
assert sorted(p.name for p in (root / "Input").iterdir()) == [other.name]
assert (root / "Tmp" / f".{name}.part").exists()
removed = [args[0] for func, args, _ in fake.calls if func == "remove"]
assert removed == [f"{INPUT}\\{name}", f"{INPUT}\\{name}.done"]
def test_scandir_access_denied_propagates_and_nothing_else_is_touched(self, fake: FakeSmbClient) -> None:
hf = self._hf(fake)
job_id = _job_id()
hf.put(f"{job_id}.pdf", b"x")
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, hf.answer_dir(job_id))
fake.fail["scandir"] = denied
fake.calls.clear()
with pytest.raises(SMBOSError) as excinfo:
hf.cleanup(job_id, f"{job_id}.pdf")
assert excinfo.value is denied # не принят за «каталога нет»
assert fake.funcs() == ["scandir"]
assert fake._local(INPUT + f"\\{job_id}.pdf").exists()
def test_subdirectory_in_answer_dir_fails_rmdir_and_propagates(self, fake: FakeSmbClient) -> None:
hf = self._hf(fake)
job_id = _job_id()
answer = fake._local(OUTPUT + f"\\{job_id}")
(answer / "nested").mkdir(parents=True)
(answer / f"{job_id}.json").write_bytes(b"{}")
with pytest.raises(SMBOSError) as excinfo:
hf.cleanup(job_id, f"{job_id}.pdf")
assert excinfo.value.ntstatus == NtStatus.STATUS_DIRECTORY_NOT_EMPTY
assert not (answer / f"{job_id}.json").exists() # файлы сняты, каталог остался вместе с подкаталогом
assert (answer / "nested").is_dir()
assert fake.funcs()[-1] == "rmdir" # дальше (входящий файл, `.done`) очистка не пошла
def test_passes_credentials_to_every_call(self, fake: FakeSmbClient) -> None:
hf = self._hf(fake, login="user", password="secret")
job_id = _job_id()
name = f"{job_id}.pdf"
hf.put(name, b"x")
answer = fake._local(OUTPUT + f"\\{job_id}")
answer.mkdir(parents=True)
(answer / f"{job_id}.json").write_bytes(b"{}")
(answer / f"{job_id}_ofr.json").write_bytes(b"{}")
fake.calls.clear()
assert hf.cleanup(job_id, name) is True
assert fake.funcs() == ["scandir", "remove", "remove", "rmdir", "remove", "remove"]
assert all(kwargs == {"username": "user", "password": "secret"} for _, _, kwargs in fake.calls)
def test_missing_answer_dir_and_files_are_fine(self, fake: FakeSmbClient) -> None:
hf = self._hf(fake)
assert hf.cleanup(_job_id(), "nothing.pdf") is True
assert fake.funcs() == ["scandir", "remove", "remove"]
def test_answer_dir_is_a_file_is_fine(self, fake: FakeSmbClient) -> None:
hf = self._hf(fake)
job_id = _job_id()
(fake.base / HOST / "share" / "scan" / "Output").mkdir()
(fake.base / HOST / "share" / "scan" / "Output" / job_id).write_bytes(b"not a dir") # ENOTDIR
assert hf.cleanup(job_id, "nothing.pdf") is True
def test_missing_file_error_other_than_enoent_propagates(self, fake: FakeSmbClient) -> None:
hf = self._hf(fake)
fake.fail["remove"] = SMBOSError(NtStatus.STATUS_SHARING_VIOLATION, INPUT + "\\job.pdf")
with pytest.raises(SMBOSError):
hf.cleanup(_job_id(), "job.pdf")
class TestPoll:
def test_none_until_answer_dir_has_json(self, fake: FakeSmbClient, share_root: Path) -> None:
hf = _hotfolder()
job_id = _job_id()
assert hf.poll(job_id) is None # нет Output: STATUS_OBJECT_PATH_NOT_FOUND
(share_root / "Output").mkdir()
assert hf.poll(job_id) is None # нет Output/<job_id>: STATUS_OBJECT_NAME_NOT_FOUND
(share_root / "Output" / job_id).mkdir()
assert hf.poll(job_id) is None # каталог пуст
assert fake.funcs() == ["scandir", "scandir", "scandir"]
def test_lists_answer_dir_and_reads_every_json(self, fake: FakeSmbClient, share_root: Path) -> None:
hf = _hotfolder()
job_id = _job_id()
_write_answer(share_root, job_id, b'{"n": 2}', f"{job_id}_ofr.json")
_write_answer(share_root, job_id, b'{"n": 1}')
_write_answer(share_root, job_id, b"text", "readme.txt")
(share_root / "Output" / job_id / "nested.json").mkdir()
assert hf.poll(job_id) == [
AnswerFile(f"{job_id}.json", b'{"n": 1}'),
AnswerFile(f"{job_id}_ofr.json", b'{"n": 2}'),
]
folder = f"{OUTPUT}\\{job_id}"
assert fake.calls == [
("scandir", (folder,), {}),
("open_file", (f"{folder}\\{job_id}.json", "rb"), {}),
("open_file", (f"{folder}\\{job_id}_ofr.json", "rb"), {}),
("scandir", (folder,), {}), # T2.1: повторный листинг — набор не изменился за время чтения
]
def test_one_json_after_settle(self, fake: FakeSmbClient, share_root: Path) -> None:
clock = FakeClock()
hf = _hotfolder(settle_seconds=2, clock=clock)
job_id = _job_id()
_write_answer(share_root, 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"{}")]
# до готовности файлы не читаются; после чтения — повторный листинг (T2.1)
assert fake.funcs() == ["scandir", "scandir", "scandir", "open_file", "scandir"]
def test_growing_file_is_not_ready_until_stable(self, fake: FakeSmbClient, share_root: Path) -> None:
clock = FakeClock()
hf = _hotfolder(settle_seconds=2, clock=clock)
job_id = _job_id()
path = _write_answer(share_root, job_id, b'{"a": ')
assert hf.poll(job_id) is None
clock.now += 1.5
path.write_bytes(b'{"a": 1}')
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'{"a": 1}')]
def test_reads_only_own_answer(self, fake: FakeSmbClient, share_root: Path) -> None:
job_id, other = _job_id(), _job_id()
_write_answer(share_root, other, b"{}")
hf = _hotfolder()
assert hf.poll(job_id) is None
assert hf.poll(other) == [AnswerFile(f"{other}.json", b"{}")]
def test_missing_root_is_none(self, fake: FakeSmbClient) -> None:
assert _hotfolder().poll(_job_id()) is None
def test_plain_file_not_found_is_none(self, fake: FakeSmbClient) -> None:
fake.fail["scandir"] = FileNotFoundError("нет каталога")
assert _hotfolder().poll(_job_id()) is None
def test_file_vanished_before_read_is_none(self, fake: FakeSmbClient, share_root: Path) -> None:
job_id = _job_id()
_write_answer(share_root, job_id, b"{}")
fake.fail["open_file"] = SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, OUTPUT)
assert _hotfolder().poll(job_id) is None
def test_file_changed_between_listing_and_read_is_none(self, fake: FakeSmbClient, share_root: Path) -> None:
job_id = _job_id()
path = _write_answer(share_root, job_id, b"{}")
real_scandir = fake.scandir
def scandir_then_grow(folder: str, **kwargs: Any) -> list[SimpleNamespace]:
entries = real_scandir(folder, **kwargs)
path.write_bytes(b'{"more": 1}')
return entries
fake.scandir = scandir_then_grow # type: ignore[method-assign]
assert _hotfolder().poll(job_id) is None
@pytest.mark.parametrize("status", [NtStatus.STATUS_SHARING_VIOLATION, NtStatus.STATUS_DELETE_PENDING])
def test_answer_being_written_or_deleted_is_none(
self, fake: FakeSmbClient, share_root: Path, status: NtStatus
) -> None:
"""ИС ещё держит файл (монопольное открытие не проходит) — ответа пока нет, опросим позже."""
job_id = _job_id()
_write_answer(share_root, job_id, b"{}")
fake.fail["open_file"] = SMBOSError(status, OUTPUT)
assert _hotfolder().poll(job_id) is None
def test_answer_dir_is_a_file_is_none(self, fake: FakeSmbClient, share_root: Path) -> None:
"""T2.2: на месте `Output/<job_id>` — файл (ENOTDIR из листинга) — ответа нет, как у `LocalHotFolder`."""
job_id = _job_id()
(share_root / "Output").mkdir()
(share_root / "Output" / job_id).write_bytes(b"{}")
assert _hotfolder().poll(job_id) is None
def test_new_file_appeared_while_reading_is_none_and_set_resettles(
self, fake: FakeSmbClient, share_root: Path
) -> None:
"""T2.1: после чтения каталог листается заново; набор изменился — `None`, набор настаивается заново."""
clock = FakeClock()
hf = _hotfolder(settle_seconds=2, clock=clock)
job_id = _job_id()
_write_answer(share_root, job_id, b"{}")
assert hf.poll(job_id) is None
clock.now += 2
real_open = fake.open_file
def open_then_add(path: str, mode: str = "r", **kwargs: Any) -> IO[Any]:
fd = real_open(path, mode, **kwargs)
_write_answer(share_root, job_id, b"[]", f"{job_id}_ofr.json")
return fd
fake.open_file = open_then_add # type: ignore[method-assign]
assert hf.poll(job_id) is None
fake.open_file = real_open # type: ignore[method-assign]
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"[]")]
@pytest.mark.parametrize(
"error",
[
SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, OUTPUT),
SMBOSError(NtStatus.STATUS_SHARING_VIOLATION, OUTPUT),
SMBOSError(NtStatus.STATUS_NOT_A_DIRECTORY, OUTPUT),
],
ids=["ENOENT", "SHARING_VIOLATION", "ENOTDIR"],
)
def test_listing_error_resets_settle_state(self, fake: FakeSmbClient, share_root: Path, error: SMBOSError) -> None:
"""T2.4: «нет ответа»/«ещё занят» во время настаивания сбрасывает накопленное состояние settle."""
clock = FakeClock()
hf = _hotfolder(settle_seconds=2, clock=clock)
job_id = _job_id()
_write_answer(share_root, job_id, b"{}")
assert hf.poll(job_id) is None
assert hf.pending_jobs() == 1
fake.fail["scandir"] = error
clock.now += 2
assert hf.poll(job_id) is None
assert hf.pending_jobs() == 0
del fake.fail["scandir"]
assert hf.poll(job_id) is None # набор настаивается заново
clock.now += 2
assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b"{}")]
@pytest.mark.parametrize("func", ["scandir", "open_file"])
def test_other_errors_propagate(self, fake: FakeSmbClient, share_root: Path, func: str) -> None:
job_id = _job_id()
_write_answer(share_root, job_id, b"{}")
fake.fail[func] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, OUTPUT)
with pytest.raises(SMBOSError):
_hotfolder().poll(job_id)
def test_invalid_job_id_rejected_before_any_call(self, fake: FakeSmbClient) -> None:
with pytest.raises(ValueError, match="job_id"):
_hotfolder().poll("../x")
assert fake.calls == []
def test_passes_credentials(self, fake: FakeSmbClient, share_root: Path) -> None:
job_id = _job_id()
_write_answer(share_root, job_id, b"{}")
_hotfolder(login="user", password="secret").poll(job_id)
assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 3
class TestHealthy:
def test_true_for_existing_root(self, fake: FakeSmbClient, share_root: Path) -> None:
assert _hotfolder().healthy() is True
assert fake.calls == [("listdir", (ROOT,), {})]
def test_false_for_missing_root(self, fake: FakeSmbClient) -> None:
assert _hotfolder().healthy() is False
def test_false_for_file_root(self, fake: FakeSmbClient) -> None:
file_root = fake.base / HOST / "share" / "scan"
file_root.parent.mkdir(parents=True)
file_root.write_bytes(b"x")
assert _hotfolder().healthy() is False
@pytest.mark.parametrize(
"error", [SMBException("сервер недоступен"), ValueError("плохой путь"), ConnectionError("сброс"), OSError()]
)
def test_never_raises(self, fake: FakeSmbClient, share_root: Path, error: Exception) -> None:
fake.fail["listdir"] = error
assert _hotfolder().healthy() is False
def test_passes_credentials(self, fake: FakeSmbClient, share_root: Path) -> None:
_hotfolder(login="user", password="secret").healthy()
assert fake.calls == [("listdir", (ROOT,), {"username": "user", "password": "secret"})]