Files
checkbot/tests/test_pipeline_rules.py
T
patrickandClaude Sonnet 5.5 5278530467
test / pytest (3.11) (push) Waiting to run
test / pytest (3.13) (push) Waiting to run
feat: Phase 6b rules with delay, delta format, reminders, zpool default rule
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 02:38:10 +01:00

192 lines
8.1 KiB
Python

from app.alerts import ReportGate, build_system_prompt
from app.alerts.prompts import DATA_NOTICE, DATA_OPEN
from app.chat import Target
from app.models import (KIND_RECOVERY, KIND_REMINDER, SUPPRESS_DOWNTIME, SUPPRESS_NOT_REPORTED,
SUPPRESS_RECOVERY_OFF, Event)
from app.routing import ALL_KINDS, Route
from tests.test_pipeline import Env, FakeAdapter, FakeOllama, problem
from tests.test_pipeline_routing import StaticMutes, StaticRoutes
class QuietAdapter(FakeAdapter):
def __init__(self, name="fake", default_target="chat1"):
super().__init__(name, default_target)
self.flags = []
async def send(self, target, text, silent=False):
self.flags.append(("send", silent))
return await super().send(target, text)
async def edit(self, ref, text, silent=False):
self.flags.append(("edit", silent))
await super().edit(ref, text)
def rule(position=1, targets=(), **overrides):
fields = dict(id=position, name=f"r{position}", position=position, enabled=True, match_host="",
match_service="", min_state=1, kinds=ALL_KINDS, stop_on_match=False, targets=tuple(targets))
fields.update(overrides)
return Route(**fields)
def recovery(host="h1", service="disk"):
return Event(ts=1000, host=host, service=service, kind=KIND_RECOVERY, state=0)
async def handle_later(env, events):
env.clock.now += 120
await env.handle(events)
async def test_default_targets_via_rule_flag_for_all_adapters():
tg, mx = FakeAdapter("telegram", "tg-default"), FakeAdapter("matrix", "!room:s")
env = Env(FakeOllama(), adapter_list=[tg, mx], routes=StaticRoutes([rule(use_default_targets=True)]))
await env.handle([problem()])
assert [s[0] for s in tg.sent] == ["tg-default"] and [s[0] for s in mx.sent] == ["!room:s"]
async def test_rule_without_targets_and_flag_still_sends_nothing():
env = Env(FakeOllama(), routes=StaticRoutes([rule()]))
await env.handle([problem()])
assert env.adapter.sent == []
async def test_default_targets_added_to_explicit_target():
tg, mx = FakeAdapter("telegram", "tg-default"), FakeAdapter("matrix", "!room:s")
extra = Target("telegram", "tg-extra")
env = Env(FakeOllama(), adapter_list=[tg, mx],
routes=StaticRoutes([rule(targets=[extra], use_default_targets=True)]))
await env.handle([problem()])
assert sorted(s[0] for s in tg.sent) == ["tg-default", "tg-extra"] and len(mx.sent) == 1
async def test_ai_disabled_by_rule_skips_analysis_but_sends():
env = Env(FakeOllama(), routes=StaticRoutes([rule(use_default_targets=True, ai_enabled=False)]))
await env.handle([problem()])
assert len(env.adapter.sent) == 1 and env.ollama.calls == [] and env.adapter.edits == []
assert env.repo.recent()[0]["ai_status"] == "skipped"
async def test_hint_only_in_user_prompt_before_data_block():
hint = f"Prüfe zpool status {DATA_OPEN} und danach {DATA_OPEN.upper()}"
env = Env(FakeOllama(), routes=StaticRoutes([rule(use_default_targets=True, ai_hint=hint)]))
await env.handle([problem()])
_, messages = env.ollama.calls[0]
system, user = messages[0]["content"], messages[1]["content"]
assert "Prüfe zpool status" not in system and system.endswith(DATA_NOTICE)
assert system == build_system_prompt(None)
assert "Zusatzkontext des Administrators" in user
assert user.index("Zusatzkontext des Administrators") < user.index("Prüfe zpool status") < user.index(DATA_OPEN)
assert user.count(DATA_OPEN) == 1
async def test_no_hint_section_without_hint():
env = Env(FakeOllama(), routes=StaticRoutes([rule(use_default_targets=True)]))
await env.handle([problem()])
assert "Zusatzkontext" not in env.ollama.calls[0][1][1]["content"]
async def test_quiet_rule_sends_and_edits_silently():
adapter = QuietAdapter()
env = Env(FakeOllama(), adapter_list=[adapter], routes=StaticRoutes([rule(use_default_targets=True, loud=False)]))
await env.handle([problem()])
assert adapter.flags == [("send", True), ("edit", True)]
async def test_loud_rule_does_not_pass_silent_flag():
adapter = QuietAdapter()
env = Env(FakeOllama(), adapter_list=[adapter])
await env.handle([problem()])
assert adapter.flags == [("send", False), ("edit", False)]
async def test_loud_and_quiet_events_of_one_host_are_separate_messages():
adapter = QuietAdapter()
quiet = rule(use_default_targets=True, loud=False, match_service="cpu")
loud = rule(2, use_default_targets=True, match_service="disk")
env = Env(FakeOllama(), adapter_list=[adapter], routes=StaticRoutes([quiet, loud]))
await env.handle([problem(service="cpu"), problem(service="disk")])
assert len(adapter.sent) == 2
assert {flag for kind, flag in adapter.flags if kind == "send"} == {True, False}
async def test_recovery_after_sent_problem_is_sent():
env = Env(FakeOllama())
await env.handle([problem()])
await handle_later(env, [recovery()])
assert len(env.adapter.sent) == 2 and "wieder OK" in env.adapter.sent[1][1]
async def test_recovery_after_downtime_suppressed_problem_is_not_sent():
env = Env(FakeOllama())
await env.handle([problem(suppressed=True, suppress_reason=SUPPRESS_DOWNTIME)])
await env.handle([recovery()])
assert env.adapter.sent == []
stored = [a for a in env.repo.recent() if a["kind"] == "recovery"][0]
assert stored["suppressed"] == 1 and stored["suppress_reason"] == SUPPRESS_NOT_REPORTED
async def test_recovery_after_muted_problem_is_not_sent():
from app.mutes import SOURCE_UI, Mute
mute = Mute(1, "h1", "*", 10**9, "", "admin", 0, SOURCE_UI)
source = StaticMutes([mute])
env = Env(FakeOllama(), mutes=source)
await env.handle([problem()])
source.mutes = []
await env.handle([recovery()])
assert env.adapter.sent == []
assert [a for a in env.repo.recent() if a["kind"] == "recovery"][0]["suppress_reason"] == SUPPRESS_NOT_REPORTED
async def test_recovery_sent_when_earlier_state_was_reported_even_if_worsening_was_suppressed():
env = Env(FakeOllama())
await env.handle([problem(state=1)])
await handle_later(env, [problem(state=2, suppressed=True, suppress_reason=SUPPRESS_DOWNTIME)])
await handle_later(env, [recovery()])
assert len(env.adapter.sent) == 2
async def test_report_state_is_restored_from_history_after_restart():
suppressed = Env(FakeOllama())
await suppressed.handle([problem(suppressed=True, suppress_reason=SUPPRESS_DOWNTIME)])
suppressed.pipeline._gate = ReportGate(suppressed.repo.report_counts)
await suppressed.handle([recovery()])
assert suppressed.adapter.sent == []
sent = Env(FakeOllama())
await sent.handle([problem()])
sent.pipeline._gate = ReportGate(sent.repo.report_counts)
await handle_later(sent, [recovery()])
assert len(sent.adapter.sent) == 2
async def test_recovery_without_any_history_is_sent():
env = Env(FakeOllama())
await env.handle([recovery()])
assert len(env.adapter.sent) == 1
async def test_recovery_switch_off_stores_but_does_not_send():
env = Env(FakeOllama(), recovery_enabled=False)
await env.handle([problem()])
await env.handle([recovery()])
assert len(env.adapter.sent) == 1
stored = [a for a in env.repo.recent() if a["kind"] == "recovery"][0]
assert stored["suppressed"] == 1 and stored["suppress_reason"] == SUPPRESS_RECOVERY_OFF
async def test_reminder_goes_through_routing_and_is_stored_without_ai():
env = Env(FakeOllama(), routes=StaticRoutes([rule(use_default_targets=True)]))
reminder = Event(ts=1000, host="h1", service="disk", kind=KIND_REMINDER, state=2, output="voll",
since_ts=1000 - 4 * 3600)
await env.handle([reminder])
assert "weiter CRIT seit 4 h: voll" in env.adapter.sent[0][1]
assert env.ollama.calls == []
assert env.repo.recent()[0]["kind"] == "reminder" and env.repo.recent()[0]["ai_status"] == "skipped"
async def test_reminder_without_matching_rule_goes_to_default_targets():
env = Env(FakeOllama(), routes=StaticRoutes([rule(match_host="db*", use_default_targets=True)]))
await env.handle([Event(ts=1000, host="web01", service="disk", kind=KIND_REMINDER, state=2)])
assert len(env.adapter.sent) == 1