Открытие вынесено из блока снятия недописанного файла: при SCAN_CLEANUP=true удаляется только файл, сбойнувший после открытия. Решения R-5…R-8 — в design.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
720 lines
33 KiB
Python
720 lines
33 KiB
Python
"""Горячая папка на 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"})]
|