Files
netmover/tests/test_runner.py
T
Florian WildeandClaude Opus 5.5 642def66bd feat: Regellauf mit Lock, Lebenszeichen und Zeitlimit
RuleRunner prüft Anbindungen vor jedem Lauf, baut das Ziel auf und führt
die Engine im Worker mit Inaktivitäts-Watchdog aus. Der Lock wird erst
beim echten Ende des Workers freigegeben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:06:14 +02:00

308 lines
9.8 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.
"""Regellauf mit FakeMountBackend – Fallstricke 2, 8 und 13 sowie SFTP-Ziel."""
from __future__ import annotations
import threading
import time
from collections.abc import Iterator
from pathlib import Path
import pytest
from sqlalchemy.orm import Session, sessionmaker
from netmover.crypto import SecretBox
from netmover.db import session_scope
from netmover.models import (
Credential,
CredentialKind,
ItemStatus,
Mount,
MountType,
Rule,
RunStatus,
)
from netmover.mounting.fake import FakeMountBackend
from netmover.mounting.manager import MountManager
from netmover.mover.runner import RuleRunner
from tests.conftest import MountFactory
from tests.sftp_stub import StubSFTPServer
@pytest.fixture
def setup(
manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
session_factory: sessionmaker[Session],
) -> tuple[int, int, int, Path, Path]:
scans = fake_backend.export("nas", "scans")
archiv = fake_backend.export("nas2", "archiv")
src_id = make_mount("scans", server="nas", share="scans")
dst_id = make_mount("archiv", server="nas2", share="archiv")
manager.mount(src_id)
manager.mount(dst_id)
with session_scope(session_factory) as s:
r = Rule(
name="scans→archiv",
src_mount_id=src_id,
dst_mount_id=dst_id,
dst_path="eingang",
stability_seconds=1,
)
s.add(r)
s.flush()
rid = r.id
return rid, src_id, dst_id, scans, archiv
@pytest.fixture
def runner(session_factory: sessionmaker[Session], manager: MountManager) -> RuleRunner:
return RuleRunner(session_factory, manager, sleep=lambda _: None)
def test_moves_between_two_mounts(
runner: RuleRunner, setup: tuple[int, int, int, Path, Path]
) -> None:
rid, _, _, scans, archiv = setup
(scans / "scan001.pdf").write_text("x")
result = runner.run(rid)
assert result.status is RunStatus.OK
assert [(i.src, i.status) for i in result.items] == [("scan001.pdf", ItemStatus.MOVED)]
assert (archiv / "eingang" / "scan001.pdf").read_text() == "x"
assert not (scans / "scan001.pdf").exists()
# -- Fallstrick 2 ---------------------------------------------------------------------------
def test_unmounted_destination_aborts_and_writes_nothing_locally(
runner: RuleRunner,
setup: tuple[int, int, int, Path, Path],
fake_backend: FakeMountBackend,
manager: MountManager,
) -> None:
rid, _, _, scans, _archiv = setup
(scans / "scan001.pdf").write_text("x")
mp = manager.mountpoint_for("archiv")
fake_backend.vanish(mp) # Freigabe weg, Mountpoint ist jetzt ein leeres lokales Verzeichnis
result = runner.run(rid)
assert result.status is RunStatus.ERROR
assert "nicht eingehängt" in (result.message or "")
assert list(mp.iterdir()) == [] # nichts auf die lokale Platte geschrieben
assert (scans / "scan001.pdf").exists()
def test_unmounted_source_aborts(
runner: RuleRunner,
setup: tuple[int, int, int, Path, Path],
fake_backend: FakeMountBackend,
manager: MountManager,
) -> None:
rid, *_ = setup
fake_backend.vanish(manager.mountpoint_for("scans"))
result = runner.run(rid)
assert result.status is RunStatus.ERROR and result.items == []
def test_local_destination_under_mount_base_is_refused(
runner: RuleRunner,
setup: tuple[int, int, int, Path, Path],
manager: MountManager,
session_factory: sessionmaker[Session],
) -> None:
rid, *_ = setup
with session_scope(session_factory) as s:
r = s.get(Rule, rid)
assert r is not None
r.dst_mount_id = None
r.dst_path = str(manager.mountpoint_for("archiv"))
result = runner.run(rid)
assert result.status is RunStatus.ERROR
assert "Anbindung als Ziel" in (result.message or "")
def test_local_destination(
runner: RuleRunner,
setup: tuple[int, int, int, Path, Path],
tmp_path: Path,
session_factory: sessionmaker[Session],
) -> None:
rid, _, _, scans, _ = setup
consume = tmp_path / "consume"
consume.mkdir()
with session_scope(session_factory) as s:
r = s.get(Rule, rid)
assert r is not None
r.dst_mount_id = None
r.dst_path = str(consume)
(scans / "a.pdf").write_text("x")
assert runner.run(rid).status is RunStatus.OK
assert (consume / "a.pdf").exists()
# -- Fallstrick 8 / 13: Überlappung und Zeitlimit ---------------------------------------------
@pytest.fixture
def blocking_runner(
session_factory: sessionmaker[Session], manager: MountManager
) -> Iterator[tuple[RuleRunner, threading.Event, threading.Event]]:
entered = threading.Event()
release = threading.Event()
def sleep(_: float) -> None: # Stabilitätsfenster „hängt“
entered.set()
release.wait(10)
yield RuleRunner(session_factory, manager, sleep=sleep), entered, release
release.set()
def test_overlapping_run_is_skipped(
blocking_runner: tuple[RuleRunner, threading.Event, threading.Event],
setup: tuple[int, int, int, Path, Path],
) -> None:
runner, entered, release = blocking_runner
rid, _, _, scans, archiv = setup
(scans / "a.pdf").write_text("x")
results = []
t = threading.Thread(target=lambda: results.append(runner.run(rid)))
t.start()
assert entered.wait(5)
skipped = runner.run(rid)
assert skipped.status is RunStatus.SKIPPED
assert "läuft noch" in (skipped.message or "")
release.set()
t.join(5)
assert results[0].status is RunStatus.OK
assert (archiv / "eingang" / "a.pdf").exists()
def test_hanging_run_times_out_and_blocks_rule_until_worker_ends(
blocking_runner: tuple[RuleRunner, threading.Event, threading.Event],
setup: tuple[int, int, int, Path, Path],
session_factory: sessionmaker[Session],
) -> None:
runner, _entered, release = blocking_runner
rid, _, _, scans, archiv = setup
with session_scope(session_factory) as s:
r = s.get(Rule, rid)
assert r is not None
r.op_timeout_seconds = 1
(scans / "a.pdf").write_text("x")
t0 = time.monotonic()
result = runner.run(rid)
assert time.monotonic() - t0 < 5 # Scheduler wird nicht blockiert
assert result.status is RunStatus.ERROR
assert "Keine Reaktion" in (result.message or "")
assert runner.running_since(rid) is not None
# solange der alte Worker hängt: neue Termine überspringen
assert runner.run(rid).status is RunStatus.SKIPPED
# Worker wacht auf → sieht den Abbruch, verschiebt nichts mehr, gibt Lock frei
release.set()
for _ in range(50):
if runner.running_since(rid) is None:
break
time.sleep(0.05)
assert runner.running_since(rid) is None
assert (scans / "a.pdf").exists()
assert not (archiv / "eingang" / "a.pdf").exists()
def test_stale_source_is_recovered_before_run(
runner: RuleRunner,
setup: tuple[int, int, int, Path, Path],
fake_backend: FakeMountBackend,
manager: MountManager,
) -> None:
import errno
rid, _, _, scans, archiv = setup
(scans / "a.pdf").write_text("x")
fake_backend.inject_fault(manager.mountpoint_for("scans"), errno.ESTALE)
result = runner.run(rid)
assert result.status is RunStatus.OK
assert (archiv / "eingang" / "a.pdf").exists()
assert ("unmount", True) in [(c.action, c.lazy) for c in fake_backend.calls]
def test_dry_run_flag_from_rule_and_override(
runner: RuleRunner,
setup: tuple[int, int, int, Path, Path],
session_factory: sessionmaker[Session],
) -> None:
rid, _, _, scans, _archiv = setup
(scans / "a.pdf").write_text("x")
with session_scope(session_factory) as s:
r = s.get(Rule, rid)
assert r is not None
r.dry_run = True
result = runner.run(rid)
assert result.dry_run and result.items[0].status is ItemStatus.PLANNED
assert (scans / "a.pdf").exists()
assert runner.run(rid, dry_run=False).items[0].status is ItemStatus.MOVED
def test_unknown_rule(runner: RuleRunner) -> None:
assert runner.run(999).status is RunStatus.ERROR
# -- SFTP-Ziel --------------------------------------------------------------------------------
@pytest.fixture
def sftp_server(tmp_path: Path) -> Iterator[StubSFTPServer]:
root = tmp_path / "sftp-root"
(root / "consume").mkdir(parents=True)
srv = StubSFTPServer(root).start()
yield srv
srv.stop()
def test_move_to_sftp_destination_with_collision(
runner: RuleRunner,
setup: tuple[int, int, int, Path, Path],
sftp_server: StubSFTPServer,
session_factory: sessionmaker[Session],
secretbox: SecretBox,
) -> None:
rid, _, _, scans, _ = setup
with session_scope(session_factory) as s:
c = Credential(
name="pk",
kind=CredentialKind.SSH_KEY,
username=sftp_server.username,
secret_enc=secretbox.encrypt(sftp_server.client_private_key),
)
s.add(c)
s.flush()
m = Mount(
name="paperless",
slug="paperless",
type=MountType.SFTP,
server="127.0.0.1",
share="/consume",
port=sftp_server.port,
cred_id=c.id,
host_key=sftp_server.host_key_string,
)
s.add(m)
s.flush()
r = s.get(Rule, rid)
assert r is not None
r.dst_mount_id = m.id
r.dst_path = ""
consume = sftp_server.root / "consume"
(consume / "scan.pdf").write_text("alt")
(scans / "scan.pdf").write_bytes(b"%PDF neu" * 50_000)
result = runner.run(rid)
assert result.status is RunStatus.OK, result.message
assert result.items[0].dst == "paperless:/consume/scan-1.pdf"
assert (consume / "scan-1.pdf").read_bytes() == b"%PDF neu" * 50_000
assert (consume / "scan.pdf").read_text() == "alt"
assert not (scans / "scan.pdf").exists()
assert sorted(p.name for p in consume.iterdir()) == ["scan-1.pdf", "scan.pdf"]