package delivery import ( "context" "testing" "time" "github.com/kami/maven/internal/loop" "github.com/kami/maven/internal/store" ) // This file walks every cell of the docs/design.md § "Delivery / channel routing" // table, once as the pure table and once through the dispatcher, so a change // to either side has to break a named cell. // // present away // sev1-2 (care) voice drop // sev3 (soft) voice ntfy, once // sev4 (hard) voice + ntfy telegram, repeat til ack type tableCell struct { name string sev loop.Severity presence store.Bucket want []Channel } func allTableCells() []tableCell { return []tableCell{ {"sev1 present", loop.Sev1, store.Present, []Channel{ChannelVoice}}, {"sev2 present", loop.Sev2, store.Present, []Channel{ChannelVoice}}, {"sev3 present", loop.Sev3, store.Present, []Channel{ChannelVoice}}, {"sev4 present", loop.Sev4, store.Present, []Channel{ChannelVoice, ChannelNtfy}}, {"sev1 away", loop.Sev1, store.Away, []Channel{ChannelDrop}}, {"sev2 away", loop.Sev2, store.Away, []Channel{ChannelDrop}}, {"sev3 away", loop.Sev3, store.Away, []Channel{ChannelNtfy}}, {"sev4 away", loop.Sev4, store.Away, []Channel{ChannelTelegram}}, } } func sameChannels(got, want []Channel) bool { if len(got) != len(want) { return false } for i := range got { if got[i] != want[i] { return false } } return true } func TestChannelsForEveryTableCell(t *testing.T) { for _, c := range allTableCells() { t.Run(c.name, func(t *testing.T) { got := ChannelsFor(c.sev, c.presence) if !sameChannels(got, c.want) { t.Fatalf("%s: want %v, got %v", c.name, c.want, got) } }) } } // TestDispatchNudgeEveryTableCell — the same eight cells end to end: exactly // the wanted channels get a send, and every other channel gets none. func TestDispatchNudgeEveryTableCell(t *testing.T) { for _, c := range allTableCells() { t.Run(c.name, func(t *testing.T) { voice, ntfy, telegram := &fakeSink{}, &fakeSink{}, &fakeSink{} rec := &fakeNudgeRecorder{} d := NewDispatcher(Config{ Voice: voice, Ntfy: ntfy, Telegram: telegram, Ack: newFakeAck(), Nudges: rec, }) out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ Candidate: candidate("some_rule", c.sev, c.presence), Body: "full detail body", Summary: "short form", }, refNow()) if err != nil { t.Fatalf("dispatch: %v", err) } sent := map[Channel]int{ ChannelVoice: len(voice.sends), ChannelNtfy: len(ntfy.sends), ChannelTelegram: len(telegram.sends), } for ch, n := range sent { want := 0 for _, w := range c.want { if w == ch { want = 1 } } if n != want { t.Fatalf("%s: channel %s got %d sends, want %d", c.name, ch, n, want) } } // one dispatch and one nudge row per real (non-drop) channel. wantDispatches := 0 for _, w := range c.want { if w != ChannelDrop { wantDispatches++ } } if len(out) != wantDispatches { t.Fatalf("%s: want %d dispatches, got %d", c.name, wantDispatches, len(out)) } if len(rec.rows) != wantDispatches { t.Fatalf("%s: want %d nudge rows, got %d", c.name, wantDispatches, len(rec.rows)) } }) } } // TestSev3AwayIsNtfyExactlyOnce — "ntfy, once": one send, and nothing on the // sendable asks for a repeat, so the daemon's repeat driver has no reason to // pick it up. func TestSev3AwayIsNtfyExactlyOnce(t *testing.T) { ntfy := &fakeSink{} ack := newFakeAck() d := NewDispatcher(Config{Ntfy: ntfy, Telegram: &fakeSink{}, Ack: ack}) out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ Candidate: candidate("cert_expiring", loop.Sev3, store.Away), Body: "cert detail", Summary: "cert expiring", }, refNow()) if err != nil { t.Fatalf("dispatch: %v", err) } if len(ntfy.sends) != 1 { t.Fatalf("sev3 away: want exactly 1 ntfy send, got %d", len(ntfy.sends)) } if out[0].Sendable.RepeatUntilAck { t.Fatalf("sev3 away must not repeat til ack") } if _, ok := ack.lastSent["cert_expiring"]; ok { t.Fatalf("sev3 away must not enter the ack/repeat tracker") } } // TestSev4AwayRepeatsUntilAcked — "telegram, repeat til ack": the initial send // arms the ack clock, the repeat driver re-sends while un-acked, and an ack // stops it. func TestSev4AwayRepeatsUntilAcked(t *testing.T) { telegram := &fakeSink{} ack := newFakeAck() d := NewDispatcher(Config{Telegram: telegram, Ack: ack}) ctx := context.Background() if _, err := d.DispatchNudge(ctx, PhrasedNudge{ Candidate: candidate("disk_low", loop.Sev4, store.Away), Body: "disk detail", Summary: "disk low on homesrv", }, refNow()); err != nil { t.Fatalf("dispatch: %v", err) } // two intervals pass, still un-acked → two more sends. for i := 1; i <= 2; i++ { at := refNow().Add(time.Duration(i) * 10 * time.Minute) if _, err := d.RepeatUnacked(ctx, []string{"disk_low"}, at, 5*time.Minute, "disk detail", "disk low on homesrv"); err != nil { t.Fatalf("repeat %d: %v", i, err) } } if len(telegram.sends) != 3 { t.Fatalf("want 1 initial + 2 repeats = 3 telegram sends, got %d", len(telegram.sends)) } // acked → no further sends, however long we wait. _ = ack.MarkAcked(ctx, "disk_low") if _, err := d.RepeatUnacked(ctx, []string{"disk_low"}, refNow().Add(time.Hour), 5*time.Minute, "b", "s"); err != nil { t.Fatalf("repeat after ack: %v", err) } if len(telegram.sends) != 3 { t.Fatalf("ack must stop the repeat; got %d sends", len(telegram.sends)) } } // TestAwayChannelsGetMinimalBody — what leaves the box is the short form, for // every away cell of the table. messageForChannel is the last-mile choice both // away sinks make too. func TestAwayChannelsGetMinimalBody(t *testing.T) { detail := "disk /mnt/hdd1 on homesrv at 97% — 12GB free, biggest offender /var/lib/docker" short := "disk low on homesrv" for _, ch := range []Channel{ChannelNtfy, ChannelTelegram} { t.Run(string(ch), func(t *testing.T) { msg := messageForChannel(Sendable{Channel: ch, Body: detail, Summary: short}) if msg != short { t.Fatalf("%s message: want %q, got %q", ch, short, msg) } }) } if got := messageForChannel(Sendable{Channel: ChannelVoice, Body: detail, Summary: short}); got != detail { t.Fatalf("voice is local and gets the full body, got %q", got) } } // Both away-detail gaps this file used to describe as skipped tests are now // fixed and asserted for real in dispatcher_test.go: // TestSev4AwaySendableCarriesNoDetail and TestAwaySendsGenericLineWhenSummaryEmpty. // An empty Summary no longer means "send the whole body" — it means a short // generic line — so the old expectation here was wrong as well as duplicated. // TestCareAwayDropIsRecorded — docs/design.md's drop is a decision ("a missed water // nudge is noise, a missed backup failure isn't"), so it should be visible // rather than vanish. Today drop is a bare `continue`: no nudge row, no outbox // attempt, no log — nothing an operator can see afterwards. now it leaves a // 'dropped' outbox row. func TestCareAwayDropIsRecorded(t *testing.T) { ob := &fakeOutbox{} d := NewDispatcher(Config{Voice: &fakeSink{}, Nudges: &fakeNudgeRecorder{}, Outbox: ob}) if _, err := d.DispatchNudge(context.Background(), PhrasedNudge{ Candidate: candidate("water", loop.Sev1, store.Away), Body: "drink water", Summary: "water", }, refNow()); err != nil { t.Fatalf("dispatch: %v", err) } if len(ob.attempts) != 1 || ob.attempts[0].channel != string(ChannelDrop) { t.Fatalf("care-away drop should leave a visible record, got %+v", ob.attempts) } }