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) } }