package delivery import ( "context" "errors" "testing" "time" "github.com/kami/maven/internal/loop" "github.com/kami/maven/internal/store" ) func refNow() time.Time { return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) } func sevRule(name string, sev loop.Severity) loop.Rule { return loop.Rule{Name: name, Severity: sev} } func candidate(name string, sev loop.Severity, presence store.Bucket) loop.Candidate { return loop.Candidate{ Rule: sevRule(name, sev), Severity: sev, State: loop.State{Now: refNow(), Presence: presence}, } } // --- fakes --- type fakeSink struct { sends []Sendable err error } func (f *fakeSink) Send(_ context.Context, s Sendable) error { if f.err != nil { return f.err } f.sends = append(f.sends, s) return nil } type fakeNudgeRecorder struct { rows []nudgeRow id int64 } type nudgeRow struct { rule, channel, message string ts time.Time } func (f *fakeNudgeRecorder) RecordNudge(_ context.Context, rule, channel, message string, ts time.Time) (int64, error) { f.id++ f.rows = append(f.rows, nudgeRow{rule, channel, message, ts}) return f.id, nil } type fakeReminderCompleter struct { marked []struct { id int64 status string } err error } func (f *fakeReminderCompleter) MarkReminder(_ context.Context, id int64, status string) error { if f.err != nil { return f.err } f.marked = append(f.marked, struct { id int64 status string }{id, status}) return nil } type fakeAck struct { acked map[string]bool lastSent map[string]time.Time } func newFakeAck() *fakeAck { return &fakeAck{acked: make(map[string]bool), lastSent: make(map[string]time.Time)} } func (f *fakeAck) WasAcked(_ context.Context, key string) (bool, error) { return f.acked[key], nil } func (f *fakeAck) MarkSent(_ context.Context, key string, ts time.Time) error { f.lastSent[key] = ts return nil } func (f *fakeAck) LastSent(_ context.Context, key string) (time.Time, error) { return f.lastSent[key], nil } func (f *fakeAck) MarkAcked(_ context.Context, key string) error { f.acked[key] = true return nil } // ----------------------------- routing table -------------------------------- func TestChannelsForCarePresent(t *testing.T) { got := ChannelsFor(loop.Sev1, store.Present) if len(got) != 1 || got[0] != ChannelVoice { t.Fatalf("sev1 present: want [voice], got %v", got) } } func TestChannelsForCareAwayDrops(t *testing.T) { // sev ≤ 2 drops on away — "a missed water nudge is noise." got := ChannelsFor(loop.Sev2, store.Away) if len(got) != 1 || got[0] != ChannelDrop { t.Fatalf("sev2 away: want [drop], got %v", got) } } func TestChannelsForOpsSoftAwayNtfyOnce(t *testing.T) { got := ChannelsFor(loop.Sev3, store.Away) if len(got) != 1 || got[0] != ChannelNtfy { t.Fatalf("sev3 away: want [ntfy], got %v", got) } } func TestChannelsForOpsHardPresentVoiceAndNtfy(t *testing.T) { got := ChannelsFor(loop.Sev4, store.Present) if len(got) != 2 || got[0] != ChannelVoice || got[1] != ChannelNtfy { t.Fatalf("sev4 present: want [voice ntfy], got %v", got) } } func TestChannelsForOpsHardAwayTelegram(t *testing.T) { got := ChannelsFor(loop.Sev4, store.Away) if len(got) != 1 || got[0] != ChannelTelegram { t.Fatalf("sev4 away: want [telegram], got %v", got) } } func TestChannelsForReminderPresentVoice(t *testing.T) { got := ChannelsForReminder(store.Present) if len(got) != 1 || got[0] != ChannelVoice { t.Fatalf("reminder present: want [voice], got %v", got) } } func TestChannelsForReminderAwayNtfy(t *testing.T) { got := ChannelsForReminder(store.Away) if len(got) != 1 || got[0] != ChannelNtfy { t.Fatalf("reminder away: want [ntfy], got %v", got) } } // ----------------------------- dispatcher: nudges --------------------------- func TestDispatchNudgeCarePresentSendsVoice(t *testing.T) { voice := &fakeSink{} ntfy := &fakeSink{} rec := &fakeNudgeRecorder{} d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Nudges: rec}) out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ Candidate: candidate("water", loop.Sev1, store.Present), Body: "you haven't had water in 4h", Summary: "water", }, refNow()) if err != nil { t.Fatalf("dispatch: %v", err) } if len(out) != 1 || out[0].Sendable.Channel != ChannelVoice { t.Fatalf("want 1 voice dispatch, got %+v", out) } if len(voice.sends) != 1 || voice.sends[0].Body != "you haven't had water in 4h" { t.Fatalf("voice send: %+v", voice.sends) } if len(ntfy.sends) != 0 { t.Fatalf("ntfy should not fire for sev1 present, got %+v", ntfy.sends) } if len(rec.rows) != 1 || rec.rows[0].rule != "water" || rec.rows[0].channel != "voice" { t.Fatalf("nudge record: %+v", rec.rows) } } func TestDispatchNudgeCareAwayDropsNoSendNoRecord(t *testing.T) { voice := &fakeSink{} rec := &fakeNudgeRecorder{} d := NewDispatcher(Config{Voice: voice, Nudges: rec}) out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ Candidate: candidate("water", loop.Sev1, store.Away), Body: "you haven't had water in 4h", Summary: "water", }, refNow()) if err != nil { t.Fatalf("dispatch: %v", err) } if len(out) != 0 { t.Fatalf("care away: want 0 dispatches, got %+v", out) } if len(voice.sends) != 0 || len(rec.rows) != 0 { t.Fatalf("drop = no send, no record; sends=%v rows=%v", voice.sends, rec.rows) } } func TestDispatchNudgeOpsHardPresentSendsVoiceAndNtfy(t *testing.T) { voice := &fakeSink{} ntfy := &fakeSink{} rec := &fakeNudgeRecorder{} d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Nudges: rec}) out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ Candidate: candidate("service_down", loop.Sev4, store.Present), Body: "the backup service on homesrv is down", Summary: "backup down on homesrv", }, refNow()) if err != nil { t.Fatalf("dispatch: %v", err) } if len(out) != 2 { t.Fatalf("sev4 present: want 2 dispatches, got %d", len(out)) } if len(voice.sends) != 1 || len(ntfy.sends) != 1 { t.Fatalf("want 1 voice + 1 ntfy, got voice=%d ntfy=%d", len(voice.sends), len(ntfy.sends)) } } func TestDispatchNudgeOpsHardAwayTelegramRepeatUntilAck(t *testing.T) { telegram := &fakeSink{} rec := &fakeNudgeRecorder{} ack := newFakeAck() d := NewDispatcher(Config{Telegram: telegram, Ack: ack, Nudges: rec}) out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ Candidate: candidate("service_down", loop.Sev4, store.Away), Body: "the backup service on homesrv is down", Summary: "backup down on homesrv", }, refNow()) if err != nil { t.Fatalf("dispatch: %v", err) } if len(out) != 1 || out[0].Sendable.Channel != ChannelTelegram { t.Fatalf("sev4 away: want 1 telegram, got %+v", out) } if !out[0].Sendable.RepeatUntilAck { t.Fatalf("sev4 telegram away: want RepeatUntilAck=true") } if len(telegram.sends) != 1 { t.Fatalf("want 1 telegram send, got %d", len(telegram.sends)) } // ack tracker should have the initial MarkSent last, _ := ack.LastSent(context.Background(), "service_down") if !last.Equal(refNow()) { t.Fatalf("ack MarkSent: want %v, got %v", refNow(), last) } } func TestDispatchNudgeMinimalBodyForAwayChannels(t *testing.T) { // away channels get Summary, not Body — the "minimal body" / no-shoulder- // surf-exfil rule. the nudge record stores the summary too. ntfy := &fakeSink{} rec := &fakeNudgeRecorder{} d := NewDispatcher(Config{Ntfy: ntfy, Nudges: rec}) _, err := d.DispatchNudge(context.Background(), PhrasedNudge{ Candidate: candidate("cert_expiring", loop.Sev3, store.Away), Body: "the tls cert for homesrv.kami.lan expires in 3 days — renew via acme.sh on the reverse proxy", Summary: "cert expiring soon", }, refNow()) if err != nil { t.Fatalf("dispatch: %v", err) } if ntfy.sends[0].Summary != "cert expiring soon" { t.Fatalf("ntfy summary: want 'cert expiring soon', got %q", ntfy.sends[0].Summary) } if rec.rows[0].message != "cert expiring soon" { t.Fatalf("recorded message should be summary, got %q", rec.rows[0].message) } } func TestDispatchNudgeSendErrorStopsAndReturnsPartial(t *testing.T) { // sev4 present → voice + ntfy. voice fails → ntfy never tried, partial // returned. a failed send doesn't pollute the feedback loop (no record // for the failed channel). voice := &fakeSink{err: errors.New("audio device gone")} ntfy := &fakeSink{} rec := &fakeNudgeRecorder{} d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Nudges: rec}) out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ Candidate: candidate("service_down", loop.Sev4, store.Present), Body: "down", Summary: "down", }, refNow()) if err == nil { t.Fatalf("want send error, got nil") } if len(out) != 0 { t.Fatalf("voice failed first → 0 dispatches, got %d", len(out)) } if len(rec.rows) != 0 { t.Fatalf("failed send must not record a nudge, got %d rows", len(rec.rows)) } } func TestDispatchNudgeNilSinkSkipsSilently(t *testing.T) { // ntfy not wired; sev3 away routes to ntfy → skipped, no error. rec := &fakeNudgeRecorder{} d := NewDispatcher(Config{Nudges: rec}) out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ Candidate: candidate("cert_expiring", loop.Sev3, store.Away), Body: "cert", Summary: "cert", }, refNow()) if err != nil { t.Fatalf("nil sink should skip silently, got %v", err) } if len(out) != 0 { t.Fatalf("nil ntfy → 0 dispatches, got %d", len(out)) } } // ----------------------------- dispatcher: reminders ------------------------ func TestDispatchReminderPresentVoice(t *testing.T) { voice := &fakeSink{} rc := &fakeReminderCompleter{} d := NewDispatcher(Config{Voice: voice, Reminders: rc}) rd := loop.ReminderDecision{ Reminder: store.Reminder{ID: 42, Payload: `"wake me"`, Status: "pending"}, State: loop.State{Now: refNow(), Presence: store.Present}, } out, err := d.DispatchReminder(context.Background(), PhrasedReminder{ Decision: rd, Body: "wake up", Summary: "wake up", }, refNow()) if err != nil { t.Fatalf("dispatch: %v", err) } if len(out) != 1 || out[0].Sendable.Channel != ChannelVoice { t.Fatalf("want 1 voice, got %+v", out) } if len(rc.marked) != 1 || rc.marked[0].id != 42 || rc.marked[0].status != "fired" { t.Fatalf("reminder not marked fired: %+v", rc.marked) } } func TestDispatchReminderAwayNtfy(t *testing.T) { ntfy := &fakeSink{} rc := &fakeReminderCompleter{} d := NewDispatcher(Config{Ntfy: ntfy, Reminders: rc}) rd := loop.ReminderDecision{ Reminder: store.Reminder{ID: 7, Status: "pending"}, State: loop.State{Now: refNow(), Presence: store.Away}, } out, err := d.DispatchReminder(context.Background(), PhrasedReminder{ Decision: rd, Body: "full wake up message", Summary: "wake up", }, refNow()) if err != nil { t.Fatalf("dispatch: %v", err) } if len(out) != 1 || out[0].Sendable.Channel != ChannelNtfy { t.Fatalf("want 1 ntfy, got %+v", out) } // away channel gets summary, not body if ntfy.sends[0].Summary != "wake up" { t.Fatalf("ntfy summary: want 'wake up', got %q", ntfy.sends[0].Summary) } if len(rc.marked) != 1 || rc.marked[0].status != "fired" { t.Fatalf("reminder not marked fired: %+v", rc.marked) } } func TestDispatchReminderFailedSendNotMarkedFired(t *testing.T) { // a failed send must not mark the reminder fired — it stays pending for // the next tick to re-deliver. same instinct as "record after success." voice := &fakeSink{err: errors.New("no audio")} rc := &fakeReminderCompleter{} d := NewDispatcher(Config{Voice: voice, Reminders: rc}) rd := loop.ReminderDecision{ Reminder: store.Reminder{ID: 1, Status: "pending"}, State: loop.State{Now: refNow(), Presence: store.Present}, } _, err := d.DispatchReminder(context.Background(), PhrasedReminder{ Decision: rd, Body: "wake", Summary: "wake", }, refNow()) if err == nil { t.Fatalf("want send error") } if len(rc.marked) != 0 { t.Fatalf("failed send must not mark fired, got %+v", rc.marked) } } // ----------------------------- repeat-til-ack ------------------------------- func TestShouldRepeat(t *testing.T) { now := refNow() cases := []struct { name string lastSent time.Time acked bool now time.Time interval time.Duration want bool }{ {"never sent, not acked", time.Time{}, false, now, 5 * time.Minute, true}, {"acked → stop", now.Add(-1 * time.Minute), true, now, 5 * time.Minute, false}, {"interval not elapsed → wait", now.Add(-1 * time.Minute), false, now, 5 * time.Minute, false}, {"interval elapsed → repeat", now.Add(-6 * time.Minute), false, now, 5 * time.Minute, true}, {"exactly interval → repeat", now.Add(-5 * time.Minute), false, now, 5 * time.Minute, true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := ShouldRepeat(c.lastSent, c.acked, c.now, c.interval) if got != c.want { t.Fatalf("ShouldRepeat: want %v, got %v", c.want, got) } }) } } func TestRepeatUnackedReSendsAfterInterval(t *testing.T) { telegram := &fakeSink{} ack := newFakeAck() d := NewDispatcher(Config{Telegram: telegram, Ack: ack}) // initial send was 6min ago, interval 5min → should repeat. initial := refNow().Add(-6 * time.Minute) _ = ack.MarkSent(context.Background(), "service_down", initial) out, err := d.RepeatUnacked(context.Background(), []string{"service_down"}, refNow(), 5*time.Minute, "down", "backup down") if err != nil { t.Fatalf("repeat: %v", err) } if len(out) != 1 || out[0].Sendable.RuleName != "service_down" { t.Fatalf("want 1 repeat for service_down, got %+v", out) } // last-sent updated to now last, _ := ack.LastSent(context.Background(), "service_down") if !last.Equal(refNow()) { t.Fatalf("last-sent should update to now, got %v", last) } } func TestRepeatUnackedSkipsAcked(t *testing.T) { telegram := &fakeSink{} ack := newFakeAck() d := NewDispatcher(Config{Telegram: telegram, Ack: ack}) _ = ack.MarkSent(context.Background(), "service_down", refNow().Add(-10*time.Minute)) _ = ack.MarkAcked(context.Background(), "service_down") out, err := d.RepeatUnacked(context.Background(), []string{"service_down"}, refNow(), 5*time.Minute, "down", "down") if err != nil { t.Fatalf("repeat: %v", err) } if len(out) != 0 { t.Fatalf("acked → 0 repeats, got %+v", out) } if len(telegram.sends) != 0 { t.Fatalf("acked → no telegram send, got %d", len(telegram.sends)) } } func TestRepeatUnackedSkipsBeforeInterval(t *testing.T) { telegram := &fakeSink{} ack := newFakeAck() d := NewDispatcher(Config{Telegram: telegram, Ack: ack}) // sent 1min ago, interval 5min → wait. _ = ack.MarkSent(context.Background(), "service_down", refNow().Add(-1*time.Minute)) out, err := d.RepeatUnacked(context.Background(), []string{"service_down"}, refNow(), 5*time.Minute, "down", "down") if err != nil { t.Fatalf("repeat: %v", err) } if len(out) != 0 { t.Fatalf("before interval → 0 repeats, got %+v", out) } } func TestRepeatUnackedNilTelegramOrAckIsNoOp(t *testing.T) { d := NewDispatcher(Config{}) // no telegram, no ack out, err := d.RepeatUnacked(context.Background(), []string{"service_down"}, refNow(), 5*time.Minute, "down", "down") if err != nil { t.Fatalf("nil telegram/ack: want nil err, got %v", err) } if out != nil { t.Fatalf("nil telegram/ack: want nil, got %+v", out) } }