From 1695573b35728c4c0a4dbcba264f1383224ae46d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:57:31 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=80=D0=B5=D0=B7=200=20[4.2]:=20SmbHotF?= =?UTF-8?q?older=20=D0=BF=D0=BE=D0=B2=D0=B5=D1=80=D1=85=20smbclient=20(src?= =?UTF-8?q?/gateway/smb.py),=20=D1=84=D0=B0=D0=B1=D1=80=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=20create=5Fhotfolder,=20unit-=D1=82=D0=B5=D1=81=D1=82=D1=8B,?= =?UTF-8?q?=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82=D0=BA=D0=B0=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D1=87=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SmbHotFolder: UNC-корень \\host\SMB_PATH (или полный UNC в SMB_PATH), put — `..part` + smbclient.replace, poll — open_file(rb), ENOENT (в т.ч. нет каталога) -> None, healthy — listdir корня без исключений. create_hotfolder(settings): local -> LocalHotFolder, smb -> SmbHotFolder с ленивым импортом модуля (R-6); отсутствие импорта src.gateway.smb в режиме local проверяется в отдельном интерпретаторе. Co-Authored-By: Claude Fable 5.1 --- api/src/gateway/__init__.py | 30 ++ api/src/gateway/smb.py | 115 ++++++ api/tests/unit/test_hotfolder_factory.py | 114 ++++++ api/tests/unit/test_smb_hotfolder.py | 331 ++++++++++++++++++ .../plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 6 files changed, 592 insertions(+), 2 deletions(-) create mode 100644 api/src/gateway/smb.py create mode 100644 api/tests/unit/test_hotfolder_factory.py create mode 100644 api/tests/unit/test_smb_hotfolder.py diff --git a/api/src/gateway/__init__.py b/api/src/gateway/__init__.py index e69de29..2bfd56c 100644 --- a/api/src/gateway/__init__.py +++ b/api/src/gateway/__init__.py @@ -0,0 +1,30 @@ +"""Горячие папки ИС Сканирование: фабрика по настройкам (R-6). + +`SCAN_HOTFOLDER_MODE=local` — `LocalHotFolder` (модуль `src.gateway.smb` не импортируется), `smb` — `SmbHotFolder` +из `OPENBAO.SMB` (импорт модуля — только здесь, лениво). +""" + +from src.core.config import Settings +from src.gateway.hotfolder import HotFolder, LocalHotFolder + +__all__ = ["create_hotfolder"] + + +def create_hotfolder(settings: Settings) -> HotFolder: + """Горячая папка для режима `SCAN_HOTFOLDER_MODE`. К файловой системе и шаре не обращается.""" + mode = settings.SCAN_HOTFOLDER_MODE + if mode == "local": + return LocalHotFolder(settings.SCAN_HOTFOLDER_PATH, settings.SCAN_INPUT_DIR, settings.SCAN_OUTPUT_DIR) + if mode == "smb": + from src.gateway.smb import SmbHotFolder # noqa: PLC0415 — ленивый импорт: в режиме local smb не нужен (R-6) + + smb = settings.OPENBAO.SMB + return SmbHotFolder( + smb.SMB_HOST, + smb.SMB_PATH, + settings.SCAN_INPUT_DIR, + settings.SCAN_OUTPUT_DIR, + login=smb.SMB_LOGIN, + password=smb.SMB_PASSWORD, + ) + raise ValueError(f"неизвестный SCAN_HOTFOLDER_MODE: {mode!r}") diff --git a/api/src/gateway/smb.py b/api/src/gateway/smb.py new file mode 100644 index 0000000..68e7ad4 --- /dev/null +++ b/api/src/gateway/smb.py @@ -0,0 +1,115 @@ +r"""Горячая папка на SMB-шаре платформы (`SCAN_HOTFOLDER_MODE=smb`, D-1, R-5): `SmbHotFolder` поверх `smbclient`. + +Модуль импортируется лениво — только фабрикой `create_hotfolder` в режиме `smb` (R-6). SMB-сессия регистрируется +в lifespan приложения (`app.py`); логин/пароль, если заданы, передаются ещё и в каждый вызов `smbclient`, чтобы +сессия поднималась заново по требованию (после сбоя регистрации при старте или сброса соединения). + +Пути — UNC: `\\\\\<имя>` и `\\\\\\.json`. +Ошибки smbprotocol — `SMBOSError` (подкласс `OSError`, но НЕ `FileNotFoundError`): «нет файла/каталога» распознаётся +по `errno == ENOENT`. +""" + +import contextlib +import errno +import ntpath +from typing import Any + +import smbclient + +from src.gateway.hotfolder import validate_job_id, validate_name + + +def _has_errno(err: OSError, code: int, exc_type: type[OSError]) -> bool: + """`SMBOSError` несёт `errno`, но не превращается в подкласс `OSError` по нему — проверяем и то, и другое.""" + return isinstance(err, exc_type) or err.errno == code + + +def build_unc_root(host: str, path: str) -> str: + r"""UNC-корень горячей папки. + + `path` — путь на сервере `host` (`share/scan`, разделители `/` или `\`) или уже полный UNC-путь + (`\\server\share\dir` или `//server/share/dir`, как `SMB_PATH` в старом коде) — тогда `host` не используется. + Нужны хотя бы сервер и имя шары, иначе `ValueError`. + """ + if path.startswith(("\\\\", "//")): + unc = ntpath.normpath(path) + else: + server, share_path = host.strip("\\/ "), path.strip("\\/ ") + if not server or not share_path: + raise ValueError(f"неполный адрес SMB: host={host!r}, path={path!r}") + unc = ntpath.normpath(f"\\\\{server}\\{share_path}") + if len([part for part in unc.split("\\") if part]) < 2: # noqa: PLR2004 — сервер и шара + raise ValueError(f"в пути SMB нет сервера и шары: {path!r}") + return unc.rstrip("\\") + + +class SmbHotFolder: + """Горячая папка ИС Сканирование на SMB-шаре. Методы синхронные (блокирующий SMB), семантика как у + `LocalHotFolder`: те же правила имён, атомарная запись `..part` + replace, `Input` создаётся без родителей, + корень не создаётся.""" + + def __init__( # noqa: PLR0913 — адрес и учётные данные D-1 + имена каталогов, как у LocalHotFolder + self, + host: str, + path: str, + input_dir: str = "Input", + output_dir: str = "Output", + *, + login: str = "", + password: str = "", + ) -> None: + self.root: str = build_unc_root(host, path) + self.input_dir: str = ntpath.join(self.root, input_dir) + self.output_dir: str = ntpath.join(self.root, output_dir) + self._credentials: dict[str, Any] = {"username": login, "password": password} if login else {} + + def answer_path(self, job_id: str) -> str: + r"""UNC-путь ответа ИС: `\\.json`.""" + validate_job_id(job_id) + return ntpath.join(self.output_dir, job_id, f"{job_id}.json") + + def put(self, name: str, data: bytes) -> None: + """Записать `..part` во входящий каталог и переименовать в `name` (`smbclient.replace`: существующий + файл заменяется). Имя проверяется до любого обращения к шаре. При сбое временный файл удаляется (ошибка + удаления не маскирует исходную). Нет корня — `SMBOSError` (ENOENT) от `mkdir`. + """ + validate_name(name) + self._ensure_input_dir() + part = ntpath.join(self.input_dir, f".{name}.part") + try: + with smbclient.open_file(part, mode="wb", **self._credentials) as fd: + fd.write(data) + smbclient.replace(part, ntpath.join(self.input_dir, name), **self._credentials) + except BaseException: + with contextlib.suppress(Exception): + smbclient.remove(part, **self._credentials) + raise + + def poll(self, job_id: str) -> bytes | None: + r"""Байты `\\.json` или `None`, пока его нет (нет файла, каталога задания или + `Output`). Остальные ошибки SMB поднимаются.""" + path = self.answer_path(job_id) + try: + with smbclient.open_file(path, mode="rb", **self._credentials) as fd: + data: bytes = fd.read() + except OSError as err: + if _has_errno(err, errno.ENOENT, FileNotFoundError): + return None + raise + return data + + def healthy(self) -> bool: + """Корень доступен и является каталогом (`smbclient.listdir` проходит). Не бросает исключений.""" + try: + smbclient.listdir(self.root, **self._credentials) + except Exception: + return False + return True + + def _ensure_input_dir(self) -> None: + """`mkdir` входящего каталога; «уже существует» (EEXIST) — не ошибка.""" + try: + smbclient.mkdir(self.input_dir, **self._credentials) + except OSError as err: + if not _has_errno(err, errno.EEXIST, FileExistsError): + raise diff --git a/api/tests/unit/test_hotfolder_factory.py b/api/tests/unit/test_hotfolder_factory.py new file mode 100644 index 0000000..0c9fc75 --- /dev/null +++ b/api/tests/unit/test_hotfolder_factory.py @@ -0,0 +1,114 @@ +"""Фабрика горячей папки `create_hotfolder(settings)` (R-6, D-1, slice-0-skeleton): режим из `SCAN_HOTFOLDER_MODE`, +модуль `src.gateway.smb` импортируется только в режиме `smb`.""" + +import subprocess +import sys +from pathlib import Path + +import pytest + +from src.core.config import Settings, SMBSettings, get_settings +from src.gateway import create_hotfolder +from src.gateway.hotfolder import LocalHotFolder + +API_DIR = Path(__file__).resolve().parents[2] + + +def _local_settings(root: Path) -> Settings: + return get_settings().model_copy( + update={"SCAN_HOTFOLDER_MODE": "local", "SCAN_HOTFOLDER_PATH": str(root), "SCAN_INPUT_DIR": "In"} + ) + + +def _smb_settings() -> Settings: + settings = get_settings() + smb = SMBSettings(SMB_HOST="smb.example.local", SMB_PATH="share/scan", SMB_LOGIN="user", SMB_PASSWORD="secret") + return settings.model_copy( + update={ + "SCAN_HOTFOLDER_MODE": "smb", + "SCAN_OUTPUT_DIR": "Out", + "OPENBAO": settings.OPENBAO.model_copy(update={"SMB": smb}), + } + ) + + +def test_local_mode_builds_local_hotfolder(tmp_path: Path) -> None: + hf = create_hotfolder(_local_settings(tmp_path)) + assert isinstance(hf, LocalHotFolder) + assert hf.root == tmp_path + assert hf.input_dir == tmp_path / "In" + assert hf.output_dir == tmp_path / "Output" + assert hf.healthy() is True + + +def test_smb_mode_builds_smb_hotfolder() -> None: + from src.gateway.smb import SmbHotFolder # noqa: PLC0415 — импорт смб-модуля только в этом тесте + + hf = create_hotfolder(_smb_settings()) + assert isinstance(hf, SmbHotFolder) + assert hf.root == "\\\\smb.example.local\\share\\scan" + assert hf.input_dir == "\\\\smb.example.local\\share\\scan\\Input" + assert hf.output_dir == "\\\\smb.example.local\\share\\scan\\Out" + + +def test_smb_mode_does_not_touch_share(monkeypatch: pytest.MonkeyPatch) -> None: + from src.gateway import smb # noqa: PLC0415 + + def forbidden(*args: object, **kwargs: object) -> None: + raise AssertionError("фабрика не должна обращаться к SMB") + + for name in ("open_file", "listdir", "mkdir", "replace", "remove", "register_session"): + monkeypatch.setattr(smb.smbclient, name, forbidden) + create_hotfolder(_smb_settings()) + + +def test_unknown_mode_is_rejected(tmp_path: Path) -> None: + settings = _local_settings(tmp_path).model_copy(update={"SCAN_HOTFOLDER_MODE": "ftp"}) + with pytest.raises(ValueError, match="SCAN_HOTFOLDER_MODE"): + create_hotfolder(settings) + + +def test_local_mode_does_not_import_smb_module(tmp_path: Path) -> None: + """Отдельный интерпретатор: в процессе pytest модуль мог быть импортирован другими тестами.""" + code = "\n".join( + [ + "import sys", + "from src.core.config import get_settings", + "from src.gateway import create_hotfolder", + f"settings = get_settings().model_copy(update={{'SCAN_HOTFOLDER_PATH': {str(tmp_path)!r}}})", + "hf = create_hotfolder(settings)", + "assert settings.SCAN_HOTFOLDER_MODE == 'local'", + "assert type(hf).__name__ == 'LocalHotFolder', type(hf)", + "loaded = sorted(m for m in sys.modules if m.startswith('src.gateway'))", + "assert 'src.gateway.smb' not in sys.modules, loaded", + "print(loaded)", + ] + ) + result = subprocess.run( + [sys.executable, "-c", code], cwd=API_DIR, capture_output=True, text=True, check=False, timeout=60 + ) + assert result.returncode == 0, result.stderr + assert "src.gateway.hotfolder" in result.stdout + + +def test_smb_mode_imports_smb_module_lazily() -> None: + """Контроль предыдущего теста: в режиме smb тот же сценарий импортирует `src.gateway.smb`.""" + code = "\n".join( + [ + "import sys", + "from src.core.config import SMBSettings, get_settings", + "from src.gateway import create_hotfolder", + "assert 'src.gateway.smb' not in sys.modules", + "s = get_settings()", + "smb = SMBSettings(SMB_HOST='h', SMB_PATH='share', SMB_LOGIN='u', SMB_PASSWORD='p')", + "openbao = s.OPENBAO.model_copy(update={'SMB': smb})", + "s = s.model_copy(update={'SCAN_HOTFOLDER_MODE': 'smb', 'OPENBAO': openbao})", + "hf = create_hotfolder(s)", + "assert type(hf).__name__ == 'SmbHotFolder', type(hf)", + "assert 'src.gateway.smb' in sys.modules", + ] + ) + result = subprocess.run( + [sys.executable, "-c", code], cwd=API_DIR, capture_output=True, text=True, check=False, timeout=60 + ) + assert result.returncode == 0, result.stderr diff --git a/api/tests/unit/test_smb_hotfolder.py b/api/tests/unit/test_smb_hotfolder.py new file mode 100644 index 0000000..ad2dd1f --- /dev/null +++ b/api/tests/unit/test_smb_hotfolder.py @@ -0,0 +1,331 @@ +"""Горячая папка на 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 typing import IO, Any + +import pytest +from smbprotocol.exceptions import NtStatus, SMBException, SMBOSError + +from src.gateway import smb +from src.gateway.hotfolder import 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 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] = {} # имя функции -> исключение, которое она бросит + + 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 remove(self, path: str, **kwargs: Any) -> None: + self._record("remove", (path,), kwargs) + local = self._local(path) + if not local.is_file(): + raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, path) + local.unlink() + + 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: + return SmbHotFolder(HOST, "share/scan", **kwargs) + + +def _write_answer(share_root: Path, job_id: str, data: bytes) -> None: + folder = share_root / "Output" / job_id + folder.mkdir(parents=True) + (folder / f"{job_id}.json").write_bytes(data) + + +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_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) + + 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_part_then_replaces(self, fake: FakeSmbClient, share_root: Path) -> None: + name = inbox_name(_job_id(), "aep_5.PDF") + data = bytes(range(256)) * 10 + _hotfolder().put(name, data) + part = f"{INPUT}\\.{name}.part" + assert fake.calls == [ + ("mkdir", (INPUT,), {}), + ("open_file", (part, "wb"), {}), + ("replace", (part, f"{INPUT}\\{name}"), {}), + ] + assert (share_root / "Input" / name).read_bytes() == data + assert [p.name for p in (share_root / "Input").iterdir()] == [name] + + 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_other_error_propagates(self, fake: FakeSmbClient, share_root: Path) -> None: + fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) + with pytest.raises(SMBOSError): + _hotfolder().put("a.pdf", b"1") + assert fake.funcs() == ["mkdir"] + + def test_overwrites_existing_file(self, fake: FakeSmbClient, share_root: Path) -> None: + hf = _hotfolder() + hf.put("a.pdf", b"old") + hf.put("a.pdf", b"new") + assert (share_root / "Input" / "a.pdf").read_bytes() == b"new" + assert [p.name for p in (share_root / "Input").iterdir()] == ["a.pdf"] + + 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", + "ab.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_cleans_part_file_on_replace_failure(self, fake: FakeSmbClient, share_root: Path) -> None: + fake.fail["replace"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) + with pytest.raises(OSError): + _hotfolder().put("a.pdf", b"data") + part = f"{INPUT}\\.a.pdf.part" + assert fake.calls[-1] == ("remove", (part,), {}) + assert list((share_root / "Input").iterdir()) == [] + + def test_cleanup_error_does_not_mask_original(self, fake: FakeSmbClient, share_root: Path) -> None: + fake.fail["replace"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) + fake.fail["remove"] = SMBException("соединение потеряно") + with pytest.raises(SMBOSError): + _hotfolder().put("a.pdf", b"data") + assert fake.funcs() == ["mkdir", "open_file", "replace", "remove"] + + 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"}] * 3 + + +class TestPoll: + def test_none_until_answer_exists(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/ + (share_root / "Output" / job_id).mkdir() + assert hf.poll(job_id) is None # нет .json: STATUS_OBJECT_NAME_NOT_FOUND + + def test_returns_bytes_when_answer_appears(self, fake: FakeSmbClient, share_root: Path) -> None: + hf = _hotfolder() + job_id = _job_id() + payload = b'{"file_uid": "x", "data_ocr": []}' + _write_answer(share_root, job_id, payload) + assert hf.poll(job_id) == payload + assert fake.calls == [("open_file", (f"{OUTPUT}\\{job_id}\\{job_id}.json", "rb"), {})] + + 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"{}") + (share_root / "Output" / other / "extra.json").write_bytes(b"[]") + hf = _hotfolder() + assert hf.poll(job_id) is None + assert hf.poll(other) == b"{}" + assert fake.funcs() == ["open_file", "open_file"] + + 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["open_file"] = FileNotFoundError("нет файла") + assert _hotfolder().poll(_job_id()) is None + + def test_other_errors_propagate(self, fake: FakeSmbClient, share_root: Path) -> None: + fake.fail["open_file"] = 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: + _hotfolder(login="user", password="secret").poll(_job_id()) + assert fake.calls[0][2] == {"username": "user", "password": "secret"} + + +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"})] diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index cb4fb80..36389f1 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -48,7 +48,7 @@ ### Task 8: [4.2] `src/gateway/smb.py` -- [ ] `src/gateway/smb.py`: `SmbHotFolder` поверх `smbclient` (UNC `\\host\path\Input`), те же методы; unit-тест с подменённым `smbclient` (вызовы `open_file`, `listdir`); проверка: тест зелёный, в режиме `local` модуль не импортируется +- [x] `src/gateway/smb.py`: `SmbHotFolder` поверх `smbclient` (UNC `\\host\path\Input`), те же методы; unit-тест с подменённым `smbclient` (вызовы `open_file`, `listdir`); проверка: тест зелёный, в режиме `local` модуль не импортируется ### Task 9: [4.3] `src/scan_emulator/__init__.py` + `__main__.py` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 861c3be..2cbbe87 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -35,7 +35,7 @@ - [x] 4.1 `src/gateway/hotfolder.py`: протокол `HotFolder`, `LocalHotFolder` (put/poll/healthy); `tests/unit/test_local_hotfolder.py` на `tmp_path`; проверка: put пишет `_`, poll возвращает `None` до появления ответа и байты после -- [ ] 4.2 `src/gateway/smb.py`: `SmbHotFolder` поверх `smbclient` (UNC `\\host\path\Input`), те же методы; unit-тест с +- [x] 4.2 `src/gateway/smb.py`: `SmbHotFolder` поверх `smbclient` (UNC `\\host\path\Input`), те же методы; unit-тест с подменённым `smbclient` (вызовы `open_file`, `listdir`); проверка: тест зелёный, в режиме `local` модуль не импортируется - [ ] 4.3 `src/scan_emulator/__init__.py` + `__main__.py`: функция `process_once(root, fixtures, delay)` и цикл; поиск фикстуры по имени/префиксу, подстановка `file_uid`, ошибка `INVALID_DOCUMENT`, переименование в `.done`;