Test the away-channel minimal body and the panicking sink (#368, #369)

The integration branch names one test TestAwayFallsBackToFullBodyWhenSummaryEmpty,
which describes the old bug; it is here as TestAwaySendsGenericLineWhenSummaryEmpty
and asserts the generic line instead of the body. Also covers: a normal summary
goes out unchanged, voice keeps the full body, and one panicking sink does not
eat the other channel for the same nudge. Reformatted one pre-existing struct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
This commit is contained in:
kami
2026-07-31 02:39:38 +04:00
parent 215aa331c5
commit 5fd25d7ad7
+224 -5
View File
@@ -639,11 +639,11 @@ func TestDispatchRecurringReminderReschedules(t *testing.T) {
// ----------------------------- durable outbox --------------------------------
type outboxAttempt struct {
kind, rule string
reminderID int64
channel, hash string
status string
begunAt, doneAt time.Time
kind, rule string
reminderID int64
channel, hash string
status string
begunAt, doneAt time.Time
}
// fakeOutbox — an in-memory Outbox that also lets a test simulate a crash
@@ -784,3 +784,222 @@ func TestDispatchNudge_OutboxBeginFailureDoesNotBlockSend(t *testing.T) {
t.Fatalf("send should still happen despite outbox begin failure: sends=%v out=%v", voice.sends, out)
}
}
// ----------------------- away channels carry no detail -----------------------
// panicSink — a broken sink. models the #369 case: the sink blows up mid-send.
type panicSink struct{ calls int }
func (p *panicSink) Send(_ context.Context, _ Sendable) error {
p.calls++
panic("sink is broken")
}
// TestAwaySendsGenericLineWhenSummaryEmpty — #368. The phraser is a small
// model and drops fields often. An empty Summary must NOT put the full body
// on a channel that leaves the box; the away sendable gets a fixed generic
// line plus the rule name instead.
func TestAwaySendsGenericLineWhenSummaryEmpty(t *testing.T) {
ntfy := &fakeSink{}
rec := &fakeNudgeRecorder{}
d := NewDispatcher(Config{Ntfy: ntfy, Nudges: rec})
body := "disk /dev/sda1 at 96%, 2.1G free, largest offender /var/lib/docker"
_, err := d.DispatchNudge(context.Background(), PhrasedNudge{
Candidate: candidate("disk-low", loop.Sev3, store.Away),
Body: body,
Summary: "",
}, refNow())
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if len(ntfy.sends) != 1 {
t.Fatalf("want 1 ntfy send, got %d", len(ntfy.sends))
}
want := GenericAwayMessage + ": disk-low"
got := messageForChannel(ntfy.sends[0])
if got != want {
t.Fatalf("away message: want %q, got %q", want, got)
}
if ntfy.sends[0].Body == body {
t.Fatal("away sendable still carries the full body")
}
if len(rec.rows) != 1 || rec.rows[0].message != want {
t.Fatalf("recorded message: want %q, got %+v", want, rec.rows)
}
}
// TestSev4AwaySendableCarriesNoDetail — the rule is enforced at the
// dispatcher, not in each sink. A sink added later must not be able to leak
// the body just by reading the wrong field, so neither field may hold detail.
func TestSev4AwaySendableCarriesNoDetail(t *testing.T) {
tg := &fakeSink{}
d := NewDispatcher(Config{Telegram: tg, Ack: newFakeAck()})
body := "backup job failed: rsync exit 23 on /home/kami, see /var/log/backup.log"
_, err := d.DispatchNudge(context.Background(), PhrasedNudge{
Candidate: candidate("backup-failed", loop.Sev4, store.Away),
Body: body,
Summary: "бэкап не прошёл",
}, refNow())
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if len(tg.sends) != 1 {
t.Fatalf("want 1 telegram send, got %d", len(tg.sends))
}
s := tg.sends[0]
if s.Body != "бэкап не прошёл" || s.Summary != "бэкап не прошёл" {
t.Fatalf("away sendable should hold only the summary, got body=%q summary=%q", s.Body, s.Summary)
}
}
// TestAwayKeepsANonEmptySummary — the normal path is untouched.
func TestAwayKeepsANonEmptySummary(t *testing.T) {
ntfy := &fakeSink{}
d := NewDispatcher(Config{Ntfy: ntfy})
_, err := d.DispatchNudge(context.Background(), PhrasedNudge{
Candidate: candidate("cert", loop.Sev3, store.Away),
Body: "cert for maven.local expires in 3 days, issuer letsencrypt",
Summary: "сертификат истекает",
}, refNow())
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if got := messageForChannel(ntfy.sends[0]); got != "сертификат истекает" {
t.Fatalf("want the summary unchanged, got %q", got)
}
}
// TestVoiceStillGetsTheFullBody — voice never leaves the box, so it keeps the
// full phrased message even when Summary is empty.
func TestVoiceStillGetsTheFullBody(t *testing.T) {
voice := &fakeSink{}
d := NewDispatcher(Config{Voice: voice})
body := "ты не пил воду четыре часа"
_, err := d.DispatchNudge(context.Background(), PhrasedNudge{
Candidate: candidate("water", loop.Sev1, store.Present),
Body: body,
Summary: "",
}, refNow())
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if len(voice.sends) != 1 || voice.sends[0].Body != body {
t.Fatalf("voice should get the full body, got %+v", voice.sends)
}
if got := messageForChannel(voice.sends[0]); got != body {
t.Fatalf("voice message: want %q, got %q", body, got)
}
}
// TestReminderAwaySendsGenericLineWhenSummaryEmpty — the reminder path crosses
// the same boundary and has its own Sendable construction.
func TestReminderAwaySendsGenericLineWhenSummaryEmpty(t *testing.T) {
ntfy := &fakeSink{}
d := NewDispatcher(Config{Ntfy: ntfy})
_, err := d.DispatchReminder(context.Background(), PhrasedReminder{
Decision: loop.ReminderDecision{
Reminder: store.Reminder{ID: 5},
State: loop.State{Now: refNow(), Presence: store.Away},
},
Body: "позвонить в клинику по поводу анализов",
Summary: "",
}, refNow())
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if len(ntfy.sends) != 1 {
t.Fatalf("want 1 ntfy send, got %d", len(ntfy.sends))
}
if got := messageForChannel(ntfy.sends[0]); got != GenericAwayMessage {
t.Fatalf("away reminder message: want %q, got %q", GenericAwayMessage, got)
}
}
// --------------------------- a panicking sink -------------------------------
// TestPanicMidSendResolvesTheAttempt — #369. A panic used to unwind past
// completeOutbox and leave the row pending forever, because reconciliation
// only runs at daemon startup and core is long-lived.
func TestPanicMidSendResolvesTheAttempt(t *testing.T) {
ob := &fakeOutbox{}
d := NewDispatcher(Config{Voice: &panicSink{}, Outbox: ob})
_, err := d.DispatchNudge(context.Background(), PhrasedNudge{
Candidate: candidate("water", loop.Sev1, store.Present),
Body: "body", Summary: "sum",
}, refNow())
if err != nil {
t.Fatalf("a panicking sink must not fail the dispatch: %v", err)
}
if len(ob.attempts) != 1 {
t.Fatalf("want 1 outbox attempt, got %d", len(ob.attempts))
}
if ob.attempts[0].status != store.DeliveryFailed {
t.Fatalf("want status failed after a panic, got %q", ob.attempts[0].status)
}
}
// TestPanicInOneSinkStillDeliversTheOther — sev4 present is voice + ntfy. One
// broken sink must not eat the other channel for the same nudge.
func TestPanicInOneSinkStillDeliversTheOther(t *testing.T) {
bad := &panicSink{}
ntfy := &fakeSink{}
ob := &fakeOutbox{}
d := NewDispatcher(Config{Voice: bad, Ntfy: ntfy, Outbox: ob})
out, err := d.DispatchNudge(context.Background(), PhrasedNudge{
Candidate: candidate("backup-failed", loop.Sev4, store.Present),
Body: "long detailed body", Summary: "бэкап не прошёл",
}, refNow())
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if bad.calls != 1 {
t.Fatalf("want the voice sink called once, got %d", bad.calls)
}
if len(ntfy.sends) != 1 {
t.Fatalf("ntfy should still get the nudge, got %d sends", len(ntfy.sends))
}
if len(out) != 1 || out[0].Sendable.Channel != ChannelNtfy {
t.Fatalf("want only the ntfy dispatch reported, got %+v", out)
}
if len(ob.attempts) != 2 {
t.Fatalf("want 2 outbox attempts, got %d", len(ob.attempts))
}
if ob.attempts[0].status != store.DeliveryFailed || ob.attempts[1].status != store.DeliverySent {
t.Fatalf("want [failed, sent], got %q %q", ob.attempts[0].status, ob.attempts[1].status)
}
}
// TestPanicInReminderSinkResolvesTheAttempt — the reminder path has its own
// send call, and a panic there must not leave the reminder marked fired.
func TestPanicInReminderSinkResolvesTheAttempt(t *testing.T) {
ob := &fakeOutbox{}
rc := &fakeReminderCompleter{}
d := NewDispatcher(Config{Voice: &panicSink{}, Reminders: rc, Outbox: ob})
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
Decision: loop.ReminderDecision{
Reminder: store.Reminder{ID: 9},
State: loop.State{Now: refNow(), Presence: store.Present},
},
Body: "звонок", Summary: "звонок",
}, refNow())
if err != nil {
t.Fatalf("a panicking sink must not fail the dispatch: %v", err)
}
if len(out) != 0 {
t.Fatalf("nothing was delivered, want no dispatches, got %+v", out)
}
if len(ob.attempts) != 1 || ob.attempts[0].status != store.DeliveryFailed {
t.Fatalf("want 1 attempt with status failed, got %+v", ob.attempts)
}
if len(rc.marked) != 0 {
t.Fatalf("reminder must stay pending after a panic, got %+v", rc.marked)
}
}