Files
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

712 lines
24 KiB
Go
Raw Permalink 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.
package main
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/routine"
"github.com/kami/maven/internal/store"
)
// fakeSink — captures every Send for assertion. implements delivery.Sink.
type fakeSink struct {
sends []delivery.Sendable
}
func (f *fakeSink) Send(_ context.Context, s delivery.Sendable) error {
f.sends = append(f.sends, s)
return nil
}
func newTestStore(t *testing.T) *store.Store {
t.Helper()
path := filepath.Join(t.TempDir(), "mavend_test.db")
st, err := store.Open(context.Background(), path)
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func newTestTickLoop(t *testing.T, st *store.Store, sink delivery.Sink, digestCfg *config.DigestConfig) *tickLoop {
t.Helper()
rules := loop.DefaultRules()
g := loop.NewGatherer(st, rules)
d := delivery.NewDispatcher(delivery.Config{
Voice: sink,
Ntfy: sink,
Telegram: sink,
Nudges: st,
Reminders: st,
})
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil, nil, nil)
}
func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
// A routine scheduled for 12:00 daily. On the first tick it seeds (cold-start
// guard — no fire); on a tick past the next 12:00 crossing it fires through
// the dispatcher with its literal body and severity.
st := newTestStore(t)
ctx := context.Background()
now := refNow() // 2026-06-30 12:00 UTC
markPresent(t, st, ctx, now)
rules := loop.DefaultRules()
g := loop.NewGatherer(st, rules)
sink := &fakeSink{}
d := delivery.NewDispatcher(delivery.Config{Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st})
rs := []routine.Routine{{Name: "morning", Cron: "0 12 * * *", Body: "полдень, время воды", Severity: 1}}
tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs, nil, nil)
// first tick: seeds, does not fire the routine.
tl.tick(ctx, now)
for _, s := range sink.sends {
if s.RuleName == "routine:morning" {
t.Fatal("routine fired on the seeding tick (cold-start guard failed)")
}
}
// next day, just past 12:00 — the schedule crossed.
next := now.Add(24 * time.Hour).Add(time.Minute)
markPresent(t, st, ctx, next)
sink.sends = nil
tl.tick(ctx, next)
var got *delivery.Sendable
for i := range sink.sends {
if sink.sends[i].RuleName == "routine:morning" {
got = &sink.sends[i]
}
}
if got == nil {
t.Fatalf("routine did not fire after its schedule crossed; sends=%+v", sink.sends)
}
if got.Body != "полдень, время воды" {
t.Errorf("routine body = %q, want the literal config body", got.Body)
}
if got.Channel != delivery.ChannelVoice {
t.Errorf("routine channel = %v, want voice (sev1 present)", got.Channel)
}
}
// TestTickFiresAcceptedRoutineEveryInterval — Vikunja #366. An accepted routine
// with a 3-day interval must nudge every 3 days, not once. It also must not
// replay the occurrences it slept through: after a 30-day gap it nudges once.
func TestTickFiresAcceptedRoutineEveryInterval(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
accepted := refNow()
id, err := st.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, accepted)
if err != nil {
t.Fatalf("CreateProposedRoutine: %v", err)
}
if err := st.AcceptProposedRoutine(ctx, id, accepted); err != nil {
t.Fatalf("AcceptProposedRoutine: %v", err)
}
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
const rule = "routine:полить цветы"
// Same day as the accept: not due yet.
markPresent(t, st, ctx, accepted)
tl.tick(ctx, accepted.Add(time.Hour))
if n := countSends(sink, rule); n != 0 {
t.Fatalf("routine fired %d times before its first interval passed, want 0", n)
}
// Three days later: the first nudge.
first := accepted.Add(3 * 24 * time.Hour)
markPresent(t, st, ctx, first)
tl.tick(ctx, first)
if n := countSends(sink, rule); n != 1 {
t.Fatalf("first interval: sends = %d, want 1", n)
}
// Next day: still inside the interval, silent.
sink.sends = nil
markPresent(t, st, ctx, first.Add(24*time.Hour))
tl.tick(ctx, first.Add(24*time.Hour))
if n := countSends(sink, rule); n != 0 {
t.Fatalf("mid-interval: sends = %d, want 0", n)
}
// Three days after the first nudge: it fires again. This is the bug —
// a one-shot reminder would never come back.
second := first.Add(3 * 24 * time.Hour)
markPresent(t, st, ctx, second)
tl.tick(ctx, second)
if n := countSends(sink, rule); n != 1 {
t.Fatalf("second interval: sends = %d, want 1 (a routine repeats)", n)
}
// A long silence must not turn into a backlog of missed nudges.
sink.sends = nil
late := second.Add(30 * 24 * time.Hour)
markPresent(t, st, ctx, late)
tl.tick(ctx, late)
if n := countSends(sink, rule); n != 1 {
t.Fatalf("after a 30-day gap: sends = %d, want exactly 1 (no backlog)", n)
}
}
// TestTickAcceptedRoutineRespectsQuietHours — routines are not reminders: they
// do not inherit the reminder gate bypass. Away presence drops a care-class
// nudge, and the routine stays due so it nudges once the user is back.
func TestTickAcceptedRoutineRespectsGate(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
accepted := refNow()
id, err := st.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, accepted)
if err != nil {
t.Fatalf("CreateProposedRoutine: %v", err)
}
if err := st.AcceptProposedRoutine(ctx, id, accepted); err != nil {
t.Fatalf("AcceptProposedRoutine: %v", err)
}
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
const rule = "routine:полить цветы"
// No presence probes at all ⇒ away ⇒ the care gate blocks the nudge.
due := accepted.Add(3 * 24 * time.Hour)
tl.tick(ctx, due)
if n := countSends(sink, rule); n != 0 {
t.Fatalf("away: sends = %d, want 0 (routine must not bypass the gate)", n)
}
// Back at the desk a minute later: the nudge that was held now goes out.
back := due.Add(time.Minute)
markPresent(t, st, ctx, back)
tl.tick(ctx, back)
if n := countSends(sink, rule); n != 1 {
t.Fatalf("present again: sends = %d, want 1", n)
}
}
// countSends counts captured sends for one rule name.
func countSends(sink *fakeSink, rule string) int {
n := 0
for _, s := range sink.sends {
if s.RuleName == rule {
n++
}
}
return n
}
// refNow — fixed tick time so presence decay + since durations are deterministic.
func refNow() time.Time { return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) }
// markPresent seeds desk_active + page_heartbeat with fresh ts so presence
// resolves to Present for the given tick time (cold-start is away; ENTER at
// 0.55 — a fresh desk_active alone gives 0.90, well over).
func markPresent(t *testing.T, st *store.Store, ctx context.Context, now time.Time) {
t.Helper()
for _, key := range []string{"desk_active", "page_heartbeat"} {
if _, err := st.SetValue(ctx, store.KindSelf, key, "tap:desk", map[string]bool{key: true}, now); err != nil {
t.Fatalf("seed %s: %v", key, err)
}
}
}
func TestTickColdStoreSendsNothing(t *testing.T) {
// The "shuts up when uncertain" floor: no facts ⇒ every rule's
// InertWhenNoData keys are missing ⇒ gate skips them. the loop's silence
// is the default outcome of a tick on an empty store.
st := newTestStore(t)
ctx := context.Background()
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
tl.tick(ctx, refNow())
if len(sink.sends) != 0 {
t.Fatalf("cold-store tick sent %d; want 0 (shuts up when no data)", len(sink.sends))
}
}
func TestTickWaterFiresWhenDueAndPresent(t *testing.T) {
// water fact 4h ago ⇒ since(water)=4h ≥ 3h ⇒ predicate true. presence
// present ⇒ sev1 care gate holds (no quiet/cal/cooldown). routing for
// sev1 present is [voice] — one send captured.
st := newTestStore(t)
ctx := context.Background()
now := refNow()
markPresent(t, st, ctx, now)
if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", map[string]int{"ml": 0}, now.Add(-4*time.Hour)); err != nil {
t.Fatalf("seed water: %v", err)
}
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
tl.tick(ctx, now)
if len(sink.sends) != 1 {
t.Fatalf("tick sends = %d, want 1 (water, voice only)", len(sink.sends))
}
if got, want := sink.sends[0].RuleName, "water"; got != want {
t.Errorf("send rule = %q, want %q", got, want)
}
if got, want := sink.sends[0].Channel, delivery.ChannelVoice; got != want {
t.Errorf("send channel = %v, want voice (sev1 present)", got)
}
if sink.sends[0].Body == "" {
t.Error("phraser Stub produced an empty body for the water nudge")
}
// one nudge row recorded with channel=voice — verify the dispatch path
// wrote through to the store (the feedback loop's only input). RecentOutcomes
// filters to resolved rows, so confirm the recorded nudge exists via LastNudge.
n, err := st.LastNudge(ctx, "water")
if err != nil {
t.Fatalf("LastNudge: %v", err)
}
if n.Channel != string(delivery.ChannelVoice) {
t.Errorf("recorded nudge channel = %q, want %q", n.Channel, delivery.ChannelVoice)
}
}
func TestTickCooldownSuppressesSecondSend(t *testing.T) {
// After a water nudge, the gate's cooldown (DefaultRules sets water
// base cooldown = 30m) suppresses the same rule on the next tick.
st := newTestStore(t)
ctx := context.Background()
now := refNow()
markPresent(t, st, ctx, now)
if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", map[string]int{"ml": 0}, now.Add(-4*time.Hour)); err != nil {
t.Fatalf("seed water: %v", err)
_ = err
}
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
tl.tick(ctx, now) // fires
tl.tick(ctx, now.Add(time.Minute)) // still within 30m cooldown ⇒ suppressed
if len(sink.sends) != 1 {
t.Fatalf("sends after second tick = %d, want 1 (cooldown should suppress)", len(sink.sends))
}
}
func TestTickReminderFiresOnceAndMarkedFired(t *testing.T) {
// A due reminder bypasses the gate. away (no presence probes ⇒ cold
// start away) routes the reminder to [ntfy]. the dispatcher marks the
// reminder fired only after at least one channel succeeded; verify by
// re-ticking and confirming it isn't re-dispatched (DueReminders returns
// only `status == pending AND fire_ts <= now`).
st := newTestStore(t)
ctx := context.Background()
now := refNow()
if _, err := st.CreateReminder(ctx, now.Add(-time.Minute), `{"text":"stand up"}`, ""); err != nil {
t.Fatalf("CreateReminder: %v", err)
}
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
tl.tick(ctx, now)
if got, want := len(sink.sends), 1; got != want {
t.Fatalf("reminder tick sends = %d, want 1 (away → ntfy)", got)
}
if sink.sends[0].Channel != delivery.ChannelNtfy {
t.Errorf("reminder channel = %v, want ntfy (away)", sink.sends[0].Channel)
}
if sink.sends[0].Body != "stand up" {
t.Errorf("reminder body = %q, want %q (router payload text)", sink.sends[0].Body, "stand up")
}
if sink.sends[0].Kind != delivery.KindReminder {
t.Errorf("reminder kind = %v, want %v", sink.sends[0].Kind, delivery.KindReminder)
}
// re-tick: the reminder is no longer pending (marked fired) ⇒ not in
// DueReminders ⇒ the reminder path is silent.
sink.sends = nil
tl.tick(ctx, now.Add(time.Minute))
if len(sink.sends) != 0 {
t.Fatalf("second reminder tick sends = %d, want 0 (fired once)", len(sink.sends))
}
}
// TestTuneWritesFeedbackCooldownToStore — the daemon's impure tune() step end
// to end: seed a rule with ≥ TuneMinOutcomes resolved `ignored` outcomes,
// call tune(), assert it wrote a `cooldown:<rule>` (source=feedback) fact that
// the gatherer then reads back as the active cooldown base. This is the closed
// feedback loop: outcomes → tune → write → gather → gate sees the tuned base.
func TestTuneWritesFeedbackCooldownToStore(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
// seed enough resolved `ignored` nudge outcomes to trip the tuner (above
// TuneMinOutcomes). all-ignored ⇒ factor 1.5 ⇒ base × 1.5; clamped to Max.
r := loop.WaterRule()
for i := 0; i < loop.TuneSampleN; i++ {
id, err := st.RecordNudge(ctx, r.Name, "voice", "drink water", now.Add(-time.Hour))
if err != nil {
t.Fatalf("RecordNudge %d: %v", i, err)
}
if err := st.ResolveNudge(ctx, id, store.NudgeIgnored, now); err != nil {
t.Fatalf("ResolveNudge %d: %v", i, err)
}
}
tl.tune(ctx)
// the gatherer should now use the tuned base (clamped to WaterRule.Max =
// 6h) as the cooldown base for `water`. verify via the cooldown-until
// field by seeding a water nudge + asserting cooldown = sendTs + Max.
// (we read the persisted tuned base directly rather than going via gather
// so the assertion isolates tune()'s write from the gatherer path.)
fb, err := st.LatestFactBySource(ctx, loop.FeedbackKey(r), loop.FeedbackSource)
if err != nil {
t.Fatalf("LatestFactBySource: %v (no feedback fact written?)", err)
}
tuned, ok := loop.ParseCooldownFact(fb)
if !ok {
t.Fatalf("ParseCooldownFact: not ok (value %q)", fb.Value)
}
if tuned != 45*time.Minute {
t.Fatalf("all-ignored: base 30m × 1.5 = 45m (no — under WaterRule.Max 6h so unclamped): want 45m, got %v", tuned)
}
// idempotent: a second tune() with the same outcomes writes nothing new
// (RecentOutcomes is steady between resolves; the persisted value equals
// the computed one ⇒ skip).
fb1 := fb
tl.tune(ctx)
fb2, err := st.LatestFactBySource(ctx, loop.FeedbackKey(r), loop.FeedbackSource)
if err != nil {
t.Fatalf("LatestFactBySource second call: %v", err)
}
if fb2.ID != fb1.ID {
t.Fatalf("tune() re-wrote identical value: fact id %d → %d (should skip when unchanged)",
fb1.ID, fb2.ID)
}
// flip the outcomes pattern to `acted`: next tune() writes a new value,
// shorter than Max. RecentOutcomes is sorted DESC ts,id, so re-seeding
// newer-acted nudges makes them dominate the older-ignored set.
for i := 0; i < loop.TuneSampleN; i++ {
id, err := st.RecordNudge(ctx, r.Name, "voice", "drink water", now.Add(time.Minute+time.Duration(i)*time.Second))
if err != nil {
t.Fatalf("RecordNudge acted %d: %v", i, err)
}
if err := st.ResolveNudge(ctx, id, store.NudgeActed, now); err != nil {
t.Fatalf("ResolveNudge acted %d: %v", i, err)
}
}
tl.tune(ctx)
fb3, err := st.LatestFactBySource(ctx, loop.FeedbackKey(r), loop.FeedbackSource)
if err != nil {
t.Fatalf("LatestFactBySource post-flip: %v", err)
}
tuned3, ok := loop.ParseCooldownFact(fb3)
if !ok {
t.Fatalf("ParseCooldownFact post-flip: not ok (value %q)", fb3.Value)
}
if tuned3 >= r.Cooldown.Max {
t.Fatalf("acted-dominated outcomes should shrink cooldown below Max: got %v (Max %v)",
tuned3, r.Cooldown.Max)
}
if tuned3 < r.Cooldown.Min {
t.Fatalf("tuned cooldown below Min envelope: got %v (Min %v) — clamp broken",
tuned3, r.Cooldown.Min)
}
// the gatherer actually reads it back: assert CooldownUntil for water is
// derived from the tuned base (not the rule's static Base) when a real
// nudge exists. seed a water nudge now and gather.
nudgeTs := now.Add(2 * time.Minute)
if _, err := st.RecordNudge(ctx, r.Name, "voice", "drink water", nudgeTs); err != nil {
t.Fatalf("final RecordNudge: %v", err)
}
g := loop.NewGatherer(st, loop.DefaultRules())
snap, _, err := g.GatherState(ctx, now.Add(3*time.Minute))
if err != nil {
t.Fatalf("GatherState: %v", err)
}
wantUntil := nudgeTs.Add(tuned3)
if got := snap.CooldownUntil[r.Name]; got != wantUntil {
t.Fatalf("gatherer used tuned base: CooldownUntil want %v, got %v",
wantUntil, got)
}
}
// ----------------------------- digest / batching ----------------------------
func TestDigestQueuesEligibleNudge(t *testing.T) {
// sev1 (water) with digest enabled → queued, not dispatched.
st := newTestStore(t)
ctx := context.Background()
now := refNow()
markPresent(t, st, ctx, now)
if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", map[string]int{"ml": 0}, now.Add(-4*time.Hour)); err != nil {
t.Fatalf("seed water: %v", err)
}
sink := &fakeSink{}
dc := &config.DigestConfig{
Enabled: true,
Window: config.Duration(30 * time.Minute),
MaxItems: 5,
SeverityCeiling: 2,
}
tl := newTestTickLoop(t, st, sink, dc)
tl.tick(ctx, now)
if len(sink.sends) != 0 {
t.Fatalf("digest: eligible nudge should not dispatch immediately, got %d sends", len(sink.sends))
}
if len(tl.digestQ) != 1 {
t.Fatalf("digestQ length = %d, want 1", len(tl.digestQ))
}
if tl.digestQ[0].Rule != "water" {
t.Errorf("queued rule = %q, want %q", tl.digestQ[0].Rule, "water")
}
}
func TestDigestFlushesAfterWindow(t *testing.T) {
// Queue a sev1 nudge, advance past the window, tick again → flush.
// Re-mark presence on the second tick so the flush dispatch has a
// non-away presence route (care nudges drop on away).
st := newTestStore(t)
ctx := context.Background()
now := refNow()
markPresent(t, st, ctx, now)
if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", map[string]int{"ml": 0}, now.Add(-4*time.Hour)); err != nil {
t.Fatalf("seed water: %v", err)
}
sink := &fakeSink{}
dc := &config.DigestConfig{
Enabled: true,
Window: config.Duration(30 * time.Minute),
MaxItems: 5,
SeverityCeiling: 2,
}
tl := newTestTickLoop(t, st, sink, dc)
// Tick 1: water queues, nothing dispatched.
tl.tick(ctx, now)
if len(sink.sends) != 0 {
t.Fatalf("tick 1: want 0 sends (queued), got %d", len(sink.sends))
}
if len(tl.digestQ) != 1 {
t.Fatalf("tick 1: digestQ = %d, want 1", len(tl.digestQ))
}
// Tick 2: window elapsed → flush. Re-mark presence to keep routing
// present (care nudge away → drop).
sink.sends = nil
later := now.Add(31 * time.Minute)
markPresent(t, st, ctx, later)
tl.tick(ctx, later)
if len(sink.sends) != 1 {
t.Fatalf("tick 2 (flush): sends = %d, want 1", len(sink.sends))
}
if sink.sends[0].RuleName != "digest" {
t.Errorf("flush rule = %q, want %q", sink.sends[0].RuleName, "digest")
}
if sink.sends[0].Body == "" {
t.Error("digest body must not be empty")
}
if len(tl.digestQ) != 0 {
t.Errorf("digestQ should be empty after flush, got %d", len(tl.digestQ))
}
}
func TestDigestFlushesAtMaxItems(t *testing.T) {
// Queue water via tick, then manually push a second item, then tick again
// so the before-candidate maybeFlush sees len >= MaxItems and flushes.
st := newTestStore(t)
ctx := context.Background()
now := refNow()
markPresent(t, st, ctx, now)
if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", map[string]int{"ml": 0}, now.Add(-4*time.Hour)); err != nil {
t.Fatalf("seed water: %v", err)
}
sink := &fakeSink{}
dc := &config.DigestConfig{
Enabled: true,
Window: config.Duration(30 * time.Minute),
MaxItems: 2,
SeverityCeiling: 2,
}
tl := newTestTickLoop(t, st, sink, dc)
// Tick 1: water queues (1 item, below MaxItems).
tl.tick(ctx, now)
if len(tl.digestQ) != 1 {
t.Fatalf("tick 1: digestQ = %d, want 1", len(tl.digestQ))
}
if len(sink.sends) != 0 {
t.Fatalf("tick 1: sends = %d, want 0", len(sink.sends))
}
// Manually push a second item to hit MaxItems.
tl.digestQ = append(tl.digestQ, QueuedNudge{
Rule: "meal", Severity: 1, Body: "eat something", Key: "meal", QueuedAt: now.Add(time.Second),
})
// Tick 2: after-candidate maybeFlush sees len=2 >= MaxItems=2 → flush.
sink.sends = nil
tl.tick(ctx, now.Add(2*time.Second))
if len(sink.sends) != 1 {
t.Fatalf("after flush tick: sends = %d, want 1", len(sink.sends))
}
if sink.sends[0].RuleName != "digest" {
t.Errorf("flush rule = %q, want %q", sink.sends[0].RuleName, "digest")
}
if len(tl.digestQ) != 0 {
t.Errorf("digestQ should be empty after flush, got %d", len(tl.digestQ))
}
}
func TestDigestSev4BypassesQueue(t *testing.T) {
// Sev4 (service_down) with digest enabled → dispatches immediately.
st := newTestStore(t)
ctx := context.Background()
now := refNow()
markPresent(t, st, ctx, now)
// Older than loop.MinDownAge, so this tests the digest bypass and not the
// flap debounce (Vikunja #536).
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", "down", now.Add(-5*time.Minute)); err != nil {
t.Fatalf("seed service_down: %v", err)
}
sink := &fakeSink{}
dc := &config.DigestConfig{
Enabled: true,
Window: config.Duration(30 * time.Minute),
MaxItems: 5,
SeverityCeiling: 2,
}
tl := newTestTickLoop(t, st, sink, dc)
tl.tick(ctx, now)
if len(sink.sends) == 0 {
t.Fatal("sev4 nudge must dispatch immediately, bypassing digest queue")
}
if len(tl.digestQ) != 0 {
t.Errorf("digestQ should be empty (sev4 bypassed), got %d", len(tl.digestQ))
}
}
func TestDigestDisabledSendsImmediately(t *testing.T) {
// Digest disabled (nil config) → sev1 dispatches immediately.
st := newTestStore(t)
ctx := context.Background()
now := refNow()
markPresent(t, st, ctx, now)
if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", map[string]int{"ml": 0}, now.Add(-4*time.Hour)); err != nil {
t.Fatalf("seed water: %v", err)
}
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
tl.tick(ctx, now)
if len(sink.sends) != 1 {
t.Fatalf("digest disabled: sends = %d, want 1", len(sink.sends))
}
if sink.sends[0].RuleName != "water" {
t.Errorf("send rule = %q, want %q", sink.sends[0].RuleName, "water")
}
}
func TestDigestDeduplicatesByRule(t *testing.T) {
// Same rule (water) fires in two ticks; only one copy in the queue.
st := newTestStore(t)
ctx := context.Background()
now := refNow()
markPresent(t, st, ctx, now)
if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", map[string]int{"ml": 0}, now.Add(-4*time.Hour)); err != nil {
t.Fatalf("seed water: %v", err)
}
sink := &fakeSink{}
dc := &config.DigestConfig{
Enabled: true,
Window: config.Duration(30 * time.Minute),
MaxItems: 5,
SeverityCeiling: 2,
}
tl := newTestTickLoop(t, st, sink, dc)
// Tick 1: water queues.
tl.tick(ctx, now)
if len(tl.digestQ) != 1 {
t.Fatalf("tick 1: digestQ = %d, want 1", len(tl.digestQ))
}
// Tick 2: water still in cooldown → gate suppresses, but if cooldown
// weren't active the dedup check would prevent a second copy. We verify
// by making a second direct call to queueNudge with the same rule name.
// We also tick again with a different time to see if water fires again
// through the normal path — but cooldown should suppress it. Instead,
// directly verify dedup by calling queueNudge with a synthetic candidate.
tl.queueNudge(ctx, &loop.Candidate{
Rule: loop.Rule{Name: "water", Severity: loop.Sev1},
Severity: loop.Sev1,
}, loop.State{}, now.Add(time.Minute))
if len(tl.digestQ) != 1 {
t.Fatalf("after duplicate queue attempt: digestQ = %d, want 1 (dedup)", len(tl.digestQ))
}
}
// The repeat path reads the nudges table, not the rule set, so a rule turned
// off in `disabled_rules` used to keep re-sending its last un-acked telegram
// nudge every repeat_interval. Two arrived after the rule was off on
// 2026-08-01. A disabled rule must be unreachable on every path.
func TestRepeatableRulesDropsDisabledRules(t *testing.T) {
tl := &tickLoop{rules: mustRules(t, []string{"service_down"})}
got := tl.repeatableRules([]string{"service_down", "water"})
if len(got) != 1 || got[0] != "water" {
t.Fatalf("repeatableRules = %v, want [water]", got)
}
}
// An orphan row for a rule that no longer exists in the code goes quiet too:
// nothing can ack what the UI cannot show.
func TestRepeatableRulesDropsUnknownRules(t *testing.T) {
tl := &tickLoop{rules: loop.DefaultRules()}
if got := tl.repeatableRules([]string{"rule_deleted_last_year"}); len(got) != 0 {
t.Fatalf("repeatableRules = %v, want none", got)
}
}
func TestRepeatableRulesKeepsWiredRules(t *testing.T) {
tl := &tickLoop{rules: loop.DefaultRules()}
got := tl.repeatableRules([]string{"service_down", "water"})
if len(got) != 2 {
t.Fatalf("repeatableRules = %v, want both", got)
}
}
// mustRules returns DefaultRules minus the named ones, failing if a name
// matched nothing — a typo here would make the test pass for the wrong reason.
func mustRules(t *testing.T, disabled []string) []loop.Rule {
t.Helper()
rules, dropped := loop.RulesExcept(disabled)
if len(dropped) != len(disabled) {
t.Fatalf("dropped %v, want %v", dropped, disabled)
}
return rules
}