Files
checkbot/tests/test_reminders.py
patrickandClaude Sonnet 5.5 347f55cf7c
test / pytest (3.11) (push) Canceled after 0s
test / pytest (3.13) (push) Canceled after 0s
feat: Phase 6c baseline, ack, host alarms, outage notice, send limit, privacy mode, UI link
Includes atomic migrations, per-rule reminder semantics, first-run helper script
and the note on the undecided UI chat idea.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 03:06:23 +01:00

176 lines
6.0 KiB
Python

from app.db import Database
from app.models import KIND_REMINDER, StateRow
from app.mutes import SOURCE_UI, Mute
from app.poller import StateStore
from app.reminders import ReminderWorker, due_reminders
from app.routing import ALL_KINDS, Route
NOW = 1_000_000
HOUR = 3600
def row(state=2, reported=2, reported_at=NOW - 5 * HOUR, last_reminder=0, downtime=False, flapping=False,
service="zpool status"):
return StateRow("h1", service, state, "kaputt", NOW - 6 * HOUR, downtime, flapping, reported, False,
NOW - 7 * HOUR, reported_at, NOW - 6 * HOUR, last_reminder)
def rule(hours=4, host="*", position=1):
return Route(position, f"r{position}", position, True, host, "", 1, ALL_KINDS, False, (), reminder_hours=hours)
def due(rows, rules=(), mutes=(), default=0, now=NOW):
return due_reminders(rows, list(rules), list(mutes), default, now)
def test_reminder_when_interval_elapsed_since_report():
(event,) = due([row()], [rule(4)])
assert event.kind == KIND_REMINDER and event.state == 2 and event.output == "kaputt"
assert event.since_ts == NOW - 6 * HOUR and event.problem_since_ts == NOW - 7 * HOUR
def test_no_reminder_before_interval():
assert due([row(reported_at=NOW - 3 * HOUR)], [rule(4)]) == []
def test_last_reminder_counts_when_newer_than_report():
assert due([row(last_reminder=NOW - 3 * HOUR)], [rule(4)]) == []
assert len(due([row(last_reminder=NOW - 4 * HOUR)], [rule(4)])) == 1
def test_only_crit_state_and_reported_crit():
assert due([row(state=1, reported=1)], [rule(4)]) == []
assert due([row(state=1, reported=2)], [rule(4)]) == []
assert due([row(state=2, reported=1)], [rule(4)]) == []
assert due([row(state=2, reported=0)], [rule(4)]) == []
def test_no_reminder_during_downtime_or_flapping():
assert due([row(downtime=True)], [rule(4)]) == []
assert due([row(flapping=True)], [rule(4)]) == []
def test_no_reminder_during_mute():
mute = Mute(1, "h1", "*zpool*", NOW + HOUR, "", "admin", 0, SOURCE_UI)
assert due([row()], [rule(4)], [mute]) == []
expired = Mute(1, "h1", "*zpool*", NOW - 1, "", "admin", 0, SOURCE_UI)
assert len(due([row()], [rule(4)], [expired])) == 1
def test_smallest_rule_interval_wins_and_overrides_default():
rules = [rule(8, position=1), rule(2, position=2)]
assert len(due([row(reported_at=NOW - 2 * HOUR)], rules, default=6)) == 1
assert due([row(reported_at=NOW - 5 * HOUR)], [rule(8)], default=2) == []
def test_global_default_applies_without_rule_interval():
assert due([row()], [], default=0) == []
assert len(due([row()], [], default=4)) == 1
assert len(due([row()], [rule(None)], default=4)) == 1
def test_rule_with_explicit_zero_turns_reminder_off_despite_default():
assert due([row()], [rule(0)], default=4) == []
assert due([row()], [rule(0), rule(4, position=2)], default=4) == []
def test_acknowledged_crit_gets_no_reminder_but_worsened_ack_does():
from dataclasses import replace
assert due([replace(row(), ack_state=2)], [rule(4)]) == []
assert len(due([replace(row(), ack_state=1)], [rule(4)])) == 1
def test_rule_for_other_host_does_not_apply():
assert due([row()], [rule(4, host="db*")], default=0) == []
class Env:
def __init__(self, rules=(), mutes=(), default=0):
self.db = Database(":memory:")
self.state = StateStore(self.db)
self.sent = []
self.clock = [NOW]
self.rules, self.mutes, self.default = list(rules), list(mutes), default
self.worker = self.make_worker()
def make_worker(self):
async def sink(events):
self.sent.extend(events)
return ReminderWorker(self.state.load, lambda: self.rules, lambda now: self.mutes, lambda: self.default,
sink, self.state.mark_reminded, clock=lambda: self.clock[0])
def store(self, state_row):
self.state.apply({}, [state_row], NOW)
async def test_worker_sends_and_persists_timestamp():
env = Env([rule(4)])
env.store(row())
assert await env.worker.tick() == 1
assert env.state.load()[("h1", "zpool status")].last_reminder_ts == NOW
assert len(env.sent) == 1 and env.sent[0].kind == KIND_REMINDER
async def test_worker_waits_full_interval_for_next_reminder():
env = Env([rule(4)])
env.store(row())
await env.worker.tick()
env.clock[0] = NOW + 3 * HOUR
assert await env.worker.tick() == 0
env.clock[0] = NOW + 4 * HOUR
assert await env.worker.tick() == 1
async def test_no_double_send_after_restart():
env = Env([rule(4)])
env.store(row())
await env.worker.tick()
env.sent.clear()
restarted = env.make_worker()
env.clock[0] = NOW + 60
assert await restarted.tick() == 0 and env.sent == []
async def test_worker_skips_muted_and_downtime_services():
mute = Mute(1, "h1", "*", NOW + HOUR, "", "admin", 0, SOURCE_UI)
env = Env([rule(4)], [mute])
env.store(row())
assert await env.worker.tick() == 0
assert env.state.load()[("h1", "zpool status")].last_reminder_ts == 0
async def test_poller_upsert_keeps_reminder_timestamp():
env = Env([rule(4)])
env.store(row())
await env.worker.tick()
previous = env.state.load()
changed = previous[("h1", "zpool status")]
from dataclasses import replace
stale = replace(changed, output="neu", last_reminder_ts=0)
env.state.apply(previous, [stale], NOW + 30)
assert env.state.load()[("h1", "zpool status")].last_reminder_ts == NOW
async def test_worker_error_in_sink_does_not_stop_run_loop():
import asyncio
env = Env([rule(4)])
env.store(row())
calls = []
async def failing_sink(events):
calls.append(1)
raise RuntimeError("pipeline kaputt")
async def sleep(seconds):
if len(calls) >= 1:
raise asyncio.CancelledError
worker = ReminderWorker(env.state.load, lambda: env.rules, lambda now: [], lambda: 0, failing_sink,
env.state.mark_reminded, clock=lambda: NOW, sleep=sleep)
try:
await worker.run()
except asyncio.CancelledError:
pass
assert calls == [1]