Files
Maven/cmd/mavend/alarm_stop_test.go
T
claude 0560684b35 kuma: a monitor must stay down before it wakes him (V-536)
Technitium read down on one poll and up on the next, sixty seconds apart, and
the sev4 arrived after the service was already back.

mavpoll writes a service_down fact only when the state changes, so the fact's
timestamp IS the moment the monitor went down and its age is how long it has
stayed there. The debounce is that age against MinDownAge, 90s — one poll
interval plus jitter. No history to keep and no counter to persist.

It bounds the alarm and not the truth: DownServices still reports a monitor the
instant it goes down, because /dash showing a fresh outage is right even when
phoning him about it is not. Existing fixtures that seeded a one-minute-old
down fact now seed five, which is what they always meant.
2026-08-05 02:27:22 +04:00

187 lines
6.2 KiB
Go

package main
import (
"context"
"testing"
"time"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/store"
)
// The sev4 repeat path had no off switch (Vikunja #535): it re-sent every
// pending telegram nudge every repeat_interval, and nothing in the tree could
// ever mark one acked. None of what follows can be reproduced by hand without
// sitting in front of the box for hours, so it is covered here or nowhere.
// seedDown writes one kuma monitor fact at ts. value is "down" or "up".
func seedDown(t *testing.T, st *store.Store, ctx context.Context, value string, ts time.Time) {
t.Helper()
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", value, ts); err != nil {
t.Fatalf("seed service_down:db=%s: %v", value, err)
}
}
// newAlarmTickLoop — like newTestTickLoop but with the ack tracker wired, which
// the shared helper leaves nil. Without it RepeatUnacked returns early and the
// repeat these tests are about never happens. The daemon wires it (main.go).
func newAlarmTickLoop(t *testing.T, st *store.Store, sink delivery.Sink) *tickLoop {
t.Helper()
rules := loop.DefaultRules()
g := loop.NewGatherer(st, rules)
d := delivery.NewDispatcher(delivery.Config{
Voice: sink, Ntfy: sink, Telegram: sink,
Ack: st, Nudges: st, Reminders: st,
})
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, nil, nil, nil)
}
// telegramSends counts sends that went out on the telegram reach.
func telegramSends(sink *fakeSink, rule string) int {
n := 0
for _, s := range sink.sends {
if s.RuleName == rule {
n++
}
}
return n
}
// outcomes returns the outcome of every nudge row for a rule, newest first.
func outcomes(t *testing.T, st *store.Store, ctx context.Context, rule string) []string {
t.Helper()
rows, err := st.RecentNudges(ctx, 50)
if err != nil {
t.Fatalf("recent nudges: %v", err)
}
var out []string
for _, n := range rows {
if n.Rule == rule {
out = append(out, n.Outcome)
}
}
return out
}
func TestAlarmStopsWhenTheServiceComesBackUp(t *testing.T) {
// The condition clearing is the ending that should happen. StillTrue reads
// the same DownServices helper the phraser reads, so the repeat stops on
// exactly the monitor he was told about.
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
sink := &fakeSink{}
tl := newAlarmTickLoop(t, st, sink)
tl.tick(ctx, now)
if telegramSends(sink, "service_down") == 0 {
t.Fatal("the alarm never went out; the rest of this test proves nothing")
}
seedDown(t, st, ctx, "up", now.Add(time.Minute))
sink.sends = nil
tl.tick(ctx, now.Add(6*time.Minute)) // past repeat_interval
if n := telegramSends(sink, "service_down"); n != 0 {
t.Fatalf("repeated %d time(s) after the service came back up; want 0", n)
}
for _, o := range outcomes(t, st, ctx, "service_down") {
if o != store.NudgeResolved {
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeResolved)
}
}
}
func TestAlarmStopsAtTheAgeCapWhileStillDown(t *testing.T) {
// Still down, still un-acked, and nobody has answered in two hours. That is
// not one more repeat away from being answered.
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
sink := &fakeSink{}
tl := newAlarmTickLoop(t, st, sink)
tl.tick(ctx, now)
sink.sends = nil
tl.tick(ctx, now.Add(6*time.Minute))
if n := telegramSends(sink, "service_down"); n == 0 {
t.Fatal("no repeat inside the cap; the cap is not what stopped it later")
}
sink.sends = nil
tl.tick(ctx, now.Add(maxAlarmAge+time.Minute))
if n := telegramSends(sink, "service_down"); n != 0 {
t.Fatalf("repeated %d time(s) past the %s cap; want 0", n, maxAlarmAge)
}
// Ignored, not resolved: nothing says the service got better.
for _, o := range outcomes(t, st, ctx, "service_down") {
if o != store.NudgeIgnored {
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeIgnored)
}
}
}
func TestAFlapRaisesAFreshAlarmRatherThanReviveTheClosedOne(t *testing.T) {
// Down, up, down again. Closing the first run must not make the second run
// unreportable, and must not silently reopen the closed rows either.
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
sink := &fakeSink{}
tl := newAlarmTickLoop(t, st, sink)
tl.tick(ctx, now)
first := len(outcomes(t, st, ctx, "service_down"))
seedDown(t, st, ctx, "up", now.Add(time.Minute))
tl.tick(ctx, now.Add(2*time.Minute))
if got := outcomes(t, st, ctx, "service_down"); len(got) != first {
t.Fatalf("closing the run changed the row count: %d → %d", first, len(got))
}
seedDown(t, st, ctx, "down", now.Add(25*time.Minute))
sink.sends = nil
tl.tick(ctx, now.Add(31*time.Minute))
if n := telegramSends(sink, "service_down"); n == 0 {
t.Fatal("the second outage said nothing; the first alarm's ending swallowed it")
}
got := outcomes(t, st, ctx, "service_down")
if len(got) <= first {
t.Fatalf("no new nudge row for the second outage (%d rows, was %d)", len(got), first)
}
}
func TestARuleThatSaysNothingAboutItsConditionOnlyStopsOnAge(t *testing.T) {
// StillTrue == nil means "I cannot tell you", never "it cleared". A rule
// that says nothing must keep its alarm until the age cap, or a rule author
// silences their own alarm by omission.
st := newTestStore(t)
ctx := context.Background()
now := refNow()
sink := &fakeSink{}
tl := newAlarmTickLoop(t, st, sink)
tl.rules = []loop.Rule{{Name: "mute", Severity: loop.Sev4}} // no StillTrue
if _, err := st.RecordNudge(ctx, "mute", string(delivery.ChannelTelegram), "still bad", now); err != nil {
t.Fatalf("record nudge: %v", err)
}
live := tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(time.Minute))
if len(live) != 1 {
t.Fatalf("a nil StillTrue was read as resolved: live = %v", live)
}
live = tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(maxAlarmAge+time.Minute))
if len(live) != 0 {
t.Fatalf("the age cap did not stop a rule with no StillTrue: live = %v", live)
}
}