Files
netmover/tests/test_runs.py
T
Florian WildeandClaude Opus 5.5 f008c216be feat: Scheduler, Laufprotokoll, Aufräumen und Gesundheitsprüfung
APScheduler-Jobs je Regel mit Startversatz und Jitter, Protokoll in
runs/run_items mit Regelzählern, Aufräumen nach Aufbewahrungsfrist,
Health-Auswertung für /healthz und Verdrahtung des Dienststarts.

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

396 lines
13 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.
"""Protokollierung, Regelzähler, Benachrichtigung, Aufräumen, Health (Fallstrick 9)."""
from __future__ import annotations
import json
from datetime import timedelta
from pathlib import Path
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from netmover.db import session_scope
from netmover.health import check_health
from netmover.models import (
FileFailure,
ItemStatus,
Mount,
MountState,
Rule,
Run,
RunItem,
RunStatus,
RunTrigger,
utcnow,
)
from netmover.mover.engine import ItemResult, RunResult
from netmover.mover.runner import RuleRunner
from netmover.notify import Notification, Notifier, render
from netmover.runs import RunService, cleanup
from netmover.settings_store import AppSettings, NotifyMode, load_settings, save_settings
from tests.conftest import MountFactory
class FakeRunner:
def __init__(self) -> None:
self.results: list[RunResult] = []
def run(self, rule_id: int, *, dry_run: bool | None = None) -> RunResult:
return self.results.pop(0)
@pytest.fixture
def rule_id(make_mount: MountFactory, session_factory: sessionmaker[Session]) -> int:
mid = make_mount()
with session_scope(session_factory) as s:
r = Rule(name="scans", src_mount_id=mid, dst_path="/x", interval_seconds=30)
s.add(r)
s.flush()
return r.id
@pytest.fixture
def sent() -> list[tuple[str, str, str]]:
return []
@pytest.fixture
def notifier(session_factory: sessionmaker[Session], sent: list[tuple[str, str, str]]) -> Notifier:
return Notifier(session_factory, sender=lambda *a: sent.append(a), background=False)
@pytest.fixture
def fake_runner() -> FakeRunner:
return FakeRunner()
@pytest.fixture
def service(
session_factory: sessionmaker[Session], fake_runner: FakeRunner, notifier: Notifier
) -> RunService:
return RunService(session_factory, fake_runner, notifier) # type: ignore[arg-type]
def _rule(sf: sessionmaker[Session], rid: int) -> Rule:
with sf() as s:
r = s.get(Rule, rid)
assert r is not None
s.expunge(r)
return r
def _enable_webhook(sf: sessionmaker[Session], mode: NotifyMode, after: int = 3) -> None:
save_settings(
sf, AppSettings(webhook_url="http://hook.invalid/x", notify_mode=mode, notify_after=after)
)
def ok() -> RunResult:
return RunResult(
RunStatus.OK, seen=1, moved=1, items=[ItemResult("a.pdf", ItemStatus.MOVED, "/x/a.pdf", 3)]
)
def err() -> RunResult:
return RunResult(RunStatus.ERROR, message="„scans“ ist nicht eingehängt")
def partial() -> RunResult:
return RunResult(
RunStatus.PARTIAL,
seen=2,
moved=1,
failed=1,
items=[ItemResult("b.pdf", ItemStatus.FAILED, None, 1, "kaputt (Versuch 1)")],
)
def skipped() -> RunResult:
return RunResult(RunStatus.SKIPPED, message="läuft noch")
def test_run_and_items_are_recorded(
service: RunService,
fake_runner: FakeRunner,
rule_id: int,
session_factory: sessionmaker[Session],
) -> None:
fake_runner.results.append(ok())
run_id = service.execute(rule_id)
with session_factory() as s:
run = s.get(Run, run_id)
assert run is not None
assert (run.status, run.seen, run.moved, run.trigger) == (
RunStatus.OK,
1,
1,
RunTrigger.SCHEDULE,
)
assert run.finished_at is not None and run.finished_at >= run.started_at
items = s.scalars(select(RunItem)).all()
assert [(i.src, i.dst, i.status) for i in items] == [
("a.pdf", "/x/a.pdf", ItemStatus.MOVED)
]
r = _rule(session_factory, rule_id)
assert r.last_success_at is not None and r.consecutive_failures == 0
def test_counters(
service: RunService,
fake_runner: FakeRunner,
rule_id: int,
session_factory: sessionmaker[Session],
) -> None:
fake_runner.results += [err(), partial(), skipped()]
service.execute(rule_id)
r = _rule(session_factory, rule_id)
assert r.consecutive_failures == 1 and r.last_success_at is None and r.last_run_at is not None
service.execute(rule_id)
r = _rule(session_factory, rule_id)
assert r.consecutive_failures == 2 and r.last_success_at is not None # partial: Werkzeug lebt
last_run = r.last_run_at
service.execute(rule_id) # übersprungen: protokolliert, Zähler unverändert
r = _rule(session_factory, rule_id)
assert r.consecutive_failures == 2 and r.last_run_at == last_run
with session_factory() as s:
assert [x.status for x in s.scalars(select(Run).order_by(Run.id))] == [
RunStatus.ERROR,
RunStatus.PARTIAL,
RunStatus.SKIPPED,
]
def test_manual_dry_run_does_not_touch_counters(
service: RunService,
fake_runner: FakeRunner,
rule_id: int,
session_factory: sessionmaker[Session],
) -> None:
fake_runner.results.append(RunResult(RunStatus.ERROR, dry_run=True, message="x"))
service.execute(rule_id, RunTrigger.MANUAL, dry_run=True)
r = _rule(session_factory, rule_id)
assert r.consecutive_failures == 0 and r.last_run_at is None
def test_deleted_rule_is_not_recorded(service: RunService, fake_runner: FakeRunner) -> None:
fake_runner.results.append(err())
assert service.execute(4711) is None
def test_items_are_capped(
service: RunService,
fake_runner: FakeRunner,
rule_id: int,
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("netmover.runs.MAX_ITEMS_PER_RUN", 3)
fake_runner.results.append(
RunResult(RunStatus.OK, items=[ItemResult(f"{i}.pdf", ItemStatus.MOVED) for i in range(5)])
)
run_id = service.execute(rule_id)
with session_factory() as s:
assert len(s.scalars(select(RunItem)).all()) == 3
run = s.get(Run, run_id)
assert run is not None and "2 weitere" in (run.message or "")
# -- Benachrichtigung -------------------------------------------------------------------------
def test_notify_every_failure(
service: RunService,
fake_runner: FakeRunner,
rule_id: int,
session_factory: sessionmaker[Session],
sent: list[tuple[str, str, str]],
) -> None:
_enable_webhook(session_factory, NotifyMode.EVERY)
fake_runner.results += [err(), err(), ok()]
for _ in range(3):
service.execute(rule_id)
bodies = [json.loads(b) for _, _, b in sent]
assert [b["event"] for b in bodies] == ["rule.failed", "rule.failed", "rule.recovered"]
assert bodies[0]["message"] == "„scans“ ist nicht eingehängt"
assert "2× in Folge" in bodies[1]["title"]
def test_notify_after_n_only_once_at_threshold(
service: RunService,
fake_runner: FakeRunner,
rule_id: int,
session_factory: sessionmaker[Session],
sent: list[tuple[str, str, str]],
) -> None:
_enable_webhook(session_factory, NotifyMode.AFTER_N, after=3)
fake_runner.results += [err(), err(), err(), err(), partial(), ok()]
events = []
for _ in range(6):
service.execute(rule_id)
events.append(len(sent))
assert events == [0, 0, 1, 1, 1, 2]
assert json.loads(sent[-1][2])["event"] == "rule.recovered"
def test_no_recovery_message_below_threshold(
service: RunService,
fake_runner: FakeRunner,
rule_id: int,
session_factory: sessionmaker[Session],
sent: list[tuple[str, str, str]],
) -> None:
_enable_webhook(session_factory, NotifyMode.AFTER_N, after=3)
fake_runner.results += [err(), ok()]
service.execute(rule_id)
service.execute(rule_id)
assert sent == []
def test_notify_off_by_default(
service: RunService, fake_runner: FakeRunner, rule_id: int, sent: list[tuple[str, str, str]]
) -> None:
fake_runner.results.append(err())
service.execute(rule_id)
assert sent == []
def test_render_json_escapes_values() -> None:
n = Notification("rule.failed", 'Titel mit "Anführung"', "Zeile 1\nZeile 2 \\ $x", "r")
body = render('{"t": "$title", "m": "$message", "x": "$unbekannt"}', "application/json", n)
data = json.loads(body)
assert data == {"t": 'Titel mit "Anführung"', "m": "Zeile 1\nZeile 2 \\ $x", "x": "$unbekannt"}
assert render("$message", "text/plain", n) == "Zeile 1\nZeile 2 \\ $x"
def test_sender_failure_is_swallowed(session_factory: sessionmaker[Session]) -> None:
def broken(*a: object) -> None:
raise ConnectionError("weg")
_enable_webhook(session_factory, NotifyMode.EVERY)
Notifier(session_factory, sender=broken, background=False).send(Notification("x", "t", "m"))
def test_settings_roundtrip(session_factory: sessionmaker[Session]) -> None:
assert load_settings(session_factory) == AppSettings()
save_settings(session_factory, AppSettings(retention_days=7, notify_mode=NotifyMode.EVERY))
loaded = load_settings(session_factory)
assert loaded.retention_days == 7 and loaded.notify_mode is NotifyMode.EVERY
# -- Aufräumen ---------------------------------------------------------------------------------
def test_cleanup_removes_old_runs_with_items(
service: RunService,
fake_runner: FakeRunner,
rule_id: int,
session_factory: sessionmaker[Session],
) -> None:
fake_runner.results += [ok(), ok()]
old = service.execute(rule_id)
new = service.execute(rule_id)
with session_scope(session_factory) as s:
run = s.get(Run, old)
assert run is not None
run.started_at = utcnow() - timedelta(days=31)
s.add(
FileFailure(
rule_id=rule_id,
src_relpath="x",
size=1,
mtime_ns=1,
attempts=1,
last_attempt_at=utcnow() - timedelta(days=40),
)
)
stats = cleanup(session_factory)
assert stats == {"runs": 1, "file_failures": 1}
with session_factory() as s:
assert [r.id for r in s.scalars(select(Run))] == [new]
assert len(s.scalars(select(RunItem)).all()) == 1
# -- Health (Fallstrick 9) -------------------------------------------------------------------
def _health(sf: sessionmaker[Session], started_ago: float = 3600, **kw: object): # type: ignore[no-untyped-def]
now = utcnow()
args: dict[str, object] = {
"scheduler_running": True,
"last_heartbeat": now,
"service_started": now - timedelta(seconds=started_ago),
"now": now,
}
args.update(kw)
return check_health(sf, **args) # type: ignore[arg-type]
def _age_rule(sf: sessionmaker[Session], rid: int, seconds: float | None) -> None:
with session_scope(sf) as s:
r = s.get(Rule, rid)
assert r is not None
r.created_at = utcnow() - timedelta(days=1)
r.last_success_at = None if seconds is None else utcnow() - timedelta(seconds=seconds)
def test_health_ok(session_factory: sessionmaker[Session], rule_id: int) -> None:
_age_rule(session_factory, rule_id, 60) # 2× Intervall
h = _health(session_factory)
assert h.ok, h.problems
def test_health_stale_rule_after_three_intervals(
session_factory: sessionmaker[Session], rule_id: int
) -> None:
_age_rule(session_factory, rule_id, 91)
h = _health(session_factory)
assert not h.ok and h.stale_rules == {rule_id}
assert "kein erfolgreicher Lauf" in h.problems[0]
def test_health_never_succeeded_but_service_just_started(
session_factory: sessionmaker[Session], rule_id: int
) -> None:
_age_rule(session_factory, rule_id, None)
assert _health(session_factory, started_ago=30).ok
assert not _health(session_factory, started_ago=120).ok
def test_health_disabled_rule_is_ignored(
session_factory: sessionmaker[Session], rule_id: int
) -> None:
_age_rule(session_factory, rule_id, None)
with session_scope(session_factory) as s:
r = s.get(Rule, rule_id)
assert r is not None
r.enabled = False
assert _health(session_factory).ok
def test_health_mount_error_only_for_enabled_mounts(
session_factory: sessionmaker[Session], make_mount: MountFactory
) -> None:
mid = make_mount("b", share="b", enabled=False)
with session_scope(session_factory) as s:
m = s.get(Mount, mid)
assert m is not None
m.state = MountState.ERROR
assert _health(session_factory).ok
with session_scope(session_factory) as s:
m = s.get(Mount, mid)
assert m is not None
m.enabled = True
h = _health(session_factory)
assert not h.ok and "Fehlerzustand" in h.problems[0]
def test_health_scheduler(session_factory: sessionmaker[Session]) -> None:
assert not _health(session_factory, scheduler_running=False).ok
assert not _health(session_factory, last_heartbeat=utcnow() - timedelta(minutes=5)).ok
def test_runner_type_is_compatible(session_factory: sessionmaker[Session], tmp_path: Path) -> None:
# FakeRunner oben muss dieselbe Schnittstelle bieten wie RuleRunner.run
assert RuleRunner.run.__code__.co_kwonlyargcount == 1