test: SMB-очистка и коллизии rename, проверки очистки в воркере, точные докстринги

- F-D: фейк rename бросает SMBOSError(STATUS_OBJECT_NAME_COLLISION) (и обычный FileExistsError), rmdir — STATUS_DIRECTORY_NOT_EMPTY;
  тесты validate_name, сирот .part/.done, access-denied scandir, подкаталога в Output, .part при существующем назначении,
  учётных данных во всех вызовах очистки; докстринг SmbHotFolder.put больше не противоречит сам себе (T12.5)
- F-E: тест ошибки очистки проверяет вызов очистки и ровно одну строку WARNING
- T9.4: докстринг протокола Normalizer; T13.4: устаревшие ссылки на «Task 14»; мёртвый noqa BLE001 в worker.py

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 03:35:10 +04:00
parent b956d5746f
commit a65ff8cf40
6 changed files with 150 additions and 14 deletions

View File

@ -626,7 +626,7 @@ class Worker:
return
try:
removed = await asyncio.to_thread(self._hotfolder.cleanup, job_id, name)
except Exception as exc: # noqa: BLE001 — любая ошибка удаления не влияет на конверт
except Exception as exc: # любая ошибка удаления не влияет на конверт
logger.warning(
"Очистка горячей папки не удалась, задание продолжается. job_id={job_id}, error={error}",
job_id=job_id,

View File

@ -102,10 +102,12 @@ class SmbHotFolder:
return ntpath.join(self.answer_dir(job_id), f"{job_id}.json")
def put(self, name: str, data: bytes) -> None:
"""Записать `.<name>.part` в служебный каталог, затем `smbclient.rename` в `<input>\\<name>` без замены:
назначение есть — файл считается доставленным, `.part` остаётся (удаляется только при включённой очистке).
Имя проверяется до любого обращения к шаре. При сбое временный файл удаляется (ошибка удаления не маскирует
исходную). Нет корня — `SMBOSError` (ENOENT) от первого `mkdir`.
"""Записать `.<name>.part` в служебный каталог, затем `smbclient.rename` в `<input>\\<name>` без замены.
Имя проверяется до любого обращения к шаре. Назначение уже есть (повтор задания) — это не ошибка: файл
считается доставленным и не заменяется. В этом случае, как и при любой другой ошибке записи или переименования,
`.part` снимается только при включённой очистке (`SCAN_CLEANUP`, D-2), иначе остаётся в `Tmp`; ошибка снятия
исходную не маскирует. При успехе `.part` забирает сам rename. Нет корня — `SMBOSError` (ENOENT) от первого
`mkdir`.
"""
validate_name(name)
self._ensure_dir(self.tmp_dir)

View File

@ -72,7 +72,12 @@ class NormalizeOutcome(BaseModel):
@runtime_checkable
class Normalizer(Protocol):
"""Превращает склеенный ответ ИС (D-4) в `NormalizeOutcome`; не бросает исключений на любом входе."""
"""Превращает склеенный ответ ИС (D-4) в `NormalizeOutcome`.
Структурные ошибки входа (не объект, нет `files`) возвращаются как `status = error`, а не исключением;
`NullNormalizer` не бросает ни на каком входе. `OcrNormalizer` изолирует только ошибки разбора таблицы (одна
таблица — одно предупреждение `BACKEND_PARSING_ERROR`, файл и набор живут дальше); исключения вне разбора таблиц
поднимаются сознательно (D-4): воркер превращает их в `UNKNOWN_ERROR`."""
def normalize(self, raw: object) -> NormalizeOutcome: ...

View File

@ -38,7 +38,7 @@ async def make_env(tmp_path: Path) -> AsyncIterator[EnvFactory]:
fixtures = tmp_path / "fixtures"
fixtures.mkdir(exist_ok=True)
(fixtures / "ok_doc.json").write_text(json.dumps(SUCCESS_ANSWER, ensure_ascii=False), encoding="utf-8")
(fixtures / "ok_doc.pdf").write_bytes(PDF_BYTES) # пара ответа по sha256 содержимого (эмулятор, Task 14)
(fixtures / "ok_doc.pdf").write_bytes(PDF_BYTES) # пара ответа по sha256 содержимого (эмулятор)
settings = get_settings().model_copy(
update={
"SCAN_HOTFOLDER_PATH": str(root),

View File

@ -39,6 +39,7 @@ class FakeSmbClient:
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:
@ -84,7 +85,10 @@ class FakeSmbClient:
self._record("rename", (src, dst), kwargs)
target = self._local(dst)
if target.exists():
raise FileExistsError(dst)
# настоящий 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:
@ -96,7 +100,12 @@ class FakeSmbClient:
def rmdir(self, path: str, **kwargs: Any) -> None:
self._record("rmdir", (path,), kwargs)
os.rmdir(self._local(path))
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` (размер из листинга)."""
@ -315,7 +324,9 @@ class TestPut:
assert excinfo.value is denied
assert fake.funcs() == ["mkdir", "isdir"]
def test_no_replace_on_existing_file(self, fake: FakeSmbClient, share_root: Path) -> None:
@pytest.mark.parametrize("plain", [False, True], ids=["SMBOSError", "FileExistsError"])
def test_no_replace_on_existing_file(self, fake: FakeSmbClient, share_root: Path, plain: bool) -> None:
fake.plain_collision = plain
hf = _hotfolder()
hf.put("a.pdf", b"old")
hf.put("a.pdf", b"new")
@ -390,13 +401,29 @@ class TestPutWithoutReplace:
assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"x"
assert not fake._local(TMP + "\\.job.pdf.part").exists()
def test_existing_destination_kept_and_part_left(self, fake: FakeSmbClient, share_root: Path) -> None:
@pytest.mark.parametrize("plain", [False, True], ids=["SMBOSError", "FileExistsError"])
def test_existing_destination_kept_and_part_left(self, fake: FakeSmbClient, share_root: Path, plain: bool) -> None:
fake.plain_collision = plain
hf = _hotfolder()
hf.put("job.pdf", b"first")
hf.put("job.pdf", b"second")
assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"first"
assert fake._local(TMP + "\\.job.pdf.part").read_bytes() == b"second"
@pytest.mark.parametrize("plain", [False, True], ids=["SMBOSError", "FileExistsError"])
def test_existing_destination_drops_part_when_cleanup_enabled(
self, fake: FakeSmbClient, share_root: Path, plain: bool
) -> None:
"""Назначение есть (повтор задания) и очистка включена: файл не заменён, `.part` снят."""
fake.plain_collision = plain
hf = _hotfolder(cleanup=True)
hf.put("job.pdf", b"first")
fake.calls.clear()
hf.put("job.pdf", b"second") # исключения нет: назначение есть — файл считается доставленным
assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"first"
assert not fake._local(TMP + "\\.job.pdf.part").exists()
assert fake.calls[-1] == ("remove", (f"{TMP}\\.job.pdf.part",), {})
class TestCleanup:
def test_disabled_is_noop(self, fake: FakeSmbClient, share_root: Path) -> None:
@ -439,6 +466,98 @@ class TestCleanup:
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_orphan_part_and_done_marker_are_removed(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"orphan") # `.part` осиротевшей записи
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 list((root / "Tmp").iterdir()) == []
removed = [args[0] for func, args, _ in fake.calls if func == "remove"]
assert removed == [f"{INPUT}\\{name}", f"{INPUT}\\{name}.done", f"{TMP}\\.{name}.part"]
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`, `.part`) очистка не пошла
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", "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", "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()

View File

@ -219,7 +219,7 @@ async def test_real_fixture_error_is_passed_through(make_env: EnvFactory, tmp_pa
real.mkdir()
for source in REAL_FIXTURES.glob("*.json"):
shutil.copy(source, real / source.name)
(real / "aep_5.pdf").write_bytes(PDF_BYTES) # пара ответа `aep_5.json` по sha256 содержимого запроса (Task 14)
(real / "aep_5.pdf").write_bytes(PDF_BYTES) # пара ответа `aep_5.json` по sha256 содержимого запроса
expected = json.loads((real / "aep_5.json").read_text(encoding="utf-8"))["error"]
env = await make_env()
request = env.request(original_name="aep_5.PDF")
@ -928,19 +928,29 @@ class TestSlice3a:
assert ("cleanup", f"{body['job_id']}.pdf") not in env.events
async def test_cleanup_error_does_not_change_result(
self, make_env: EnvFactory, monkeypatch: pytest.MonkeyPatch
self, make_env: EnvFactory, monkeypatch: pytest.MonkeyPatch, log_lines: list[str]
) -> None:
env = await make_env(SCAN_CLEANUP=True)
cleanup_calls: list[tuple[str, str]] = []
def deny(job_id: str, name: str) -> bool:
cleanup_calls.append((job_id, name))
raise PermissionError("нет прав")
monkeypatch.setattr(env.hotfolder.inner, "cleanup", deny)
body = env.request()
async with emulator(env):
result = await env.run(body)
job_id = body["job_id"]
# очистка действительно вызывалась (а не пропущена), и её ошибка не изменила исход задания
assert cleanup_calls == [(job_id, f"{job_id}.pdf")]
assert ("cleanup", f"{job_id}.pdf") in env.events
assert result["status"] == "error" and result["error"]["code"] == "NOT_IMPLEMENTED"
assert "metrics" not in result or "warnings" not in result.get("metrics", {})
assert "warnings" not in result.get("metrics", {}) # ошибка очистки в конверт не попадает
# ошибка очистки — ровно одна строка лога уровня WARNING
warnings = [line for line in log_lines if line.startswith("WARNING|")]
assert len(warnings) == 1
assert "Очистка горячей папки не удалась" in warnings[0] and job_id in warnings[0] and "нет прав" in warnings[0]
async def test_existing_input_waits_for_answer(self, make_env: EnvFactory) -> None:
"""Review Focus 4: входящий файл уже лежит (повтор задания), ответа нет — TIMEOUT, файл не перезаписан."""