Files
Maven/cmd/mavend/tick_reminder_delivery_test.go
T
claude 35c6ff5a71 Make delivery and integration failures explicit
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
2026-08-13 02:50:59 +04:00

171 lines
5.4 KiB
Go

package main
import (
"context"
"errors"
"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"
)
type reminderCountingPhraser struct {
phraser.Phraser
calls int
body string
summary string
mood string
}
func (p *reminderCountingPhraser) PhraseReminder(_ context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) {
p.calls++
return delivery.PhrasedReminder{
Decision: d,
Body: p.body,
Summary: p.summary,
Mood: p.mood,
}, nil
}
type reminderFailSink struct {
sends int
}
func (s *reminderFailSink) Send(_ context.Context, _ delivery.Sendable) error {
s.sends++
return errors.New("transport unavailable")
}
func newReminderDeliveryLoop(t *testing.T, st *store.Store, sink delivery.Sink, p phraser.Phraser) *tickLoop {
t.Helper()
rules := loop.DefaultRules()
return newTickLoop(
st,
loop.NewGatherer(st, rules),
delivery.NewDispatcher(delivery.Config{
Voice: sink, Ntfy: sink, Telegram: sink,
Nudges: st, Reminders: st, Outbox: st,
}),
p,
rules,
time.Second,
5*time.Minute,
0,
nil, nil, nil, nil,
)
}
func TestTickReminderRetryUsesPersistedPhraseAfterRestart(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
if _, err := st.CreateReminder(ctx, now.Add(-time.Minute), `{"text":"позвонить маме"}`, ""); err != nil {
t.Fatal(err)
}
fail := &reminderFailSink{}
firstPhraser := &reminderCountingPhraser{
Phraser: phraser.NewStub(), body: "Не забудь позвонить маме.",
summary: "Позвонить маме", mood: "warm",
}
tl := newReminderDeliveryLoop(t, st, fail, firstPhraser)
tl.tick(ctx, now)
if firstPhraser.calls != 1 {
t.Fatalf("first tick phrased %d times, want 1", firstPhraser.calls)
}
rows, err := st.ListReminders(ctx, 1)
if err != nil || len(rows) != 1 {
t.Fatalf("list = %d, err=%v", len(rows), err)
}
if !rows[0].HasDeliveryPhrase() || rows[0].DeliveryAttempts != 1 {
t.Fatalf("failed delivery state was not persisted: %+v", rows[0])
}
if want := now.Add(store.ReminderRetryBase); !rows[0].NextAttemptTs.Equal(want) {
t.Fatalf("next attempt = %s, want %s", rows[0].NextAttemptTs, want)
}
// A normal tick inside the wait does no transport work and no model work.
sendsAfterFirst := fail.sends
tl.tick(ctx, now.Add(30*time.Second))
if firstPhraser.calls != 1 || fail.sends != sendsAfterFirst {
t.Fatalf("retry wait did work: phrase calls=%d, sends=%d (was %d)", firstPhraser.calls, fail.sends, sendsAfterFirst)
}
// Constructing a new loop is the daemon-restart boundary. Its phraser would
// say something different if called; the stored phrase must win instead.
success := &fakeSink{}
afterRestart := &reminderCountingPhraser{
Phraser: phraser.NewStub(), body: "WRONG NEW PHRASE", summary: "WRONG", mood: "neutral",
}
restarted := newReminderDeliveryLoop(t, st, success, afterRestart)
restarted.tick(ctx, now.Add(store.ReminderRetryBase))
if afterRestart.calls != 0 {
t.Fatalf("restart re-phrased the reminder %d times", afterRestart.calls)
}
if len(success.sends) != 1 {
t.Fatalf("retry sends = %d, want 1", len(success.sends))
}
if got := success.sends[0].Body; got != "Позвонить маме" {
t.Fatalf("away retry body = %q, want persisted summary", got)
}
rows, err = st.ListReminders(ctx, 1)
if err != nil || rows[0].Status != store.ReminderFired {
t.Fatalf("successful retry did not fire reminder: rows=%+v err=%v", rows, err)
}
}
func TestTickCollapsedReminderRetriesOnePhraseAndCompletesOriginals(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
for _, text := range []string{"полить цветы", "записаться к врачу"} {
if _, err := st.CreateReminder(ctx, now.Add(-time.Minute), text, ""); err != nil {
t.Fatal(err)
}
}
fail := &reminderFailSink{}
firstPhraser := &reminderCountingPhraser{
Phraser: phraser.NewStub(), body: "У тебя два напоминания.",
summary: "Два напоминания", mood: "neutral",
}
newReminderDeliveryLoop(t, st, fail, firstPhraser).tick(ctx, now)
if firstPhraser.calls != 1 {
t.Fatalf("collapsed bundle phrased %d times, want 1", firstPhraser.calls)
}
rows, err := st.ListReminders(ctx, 10)
if err != nil || len(rows) != 2 {
t.Fatalf("list = %d, err=%v", len(rows), err)
}
for _, r := range rows {
if r.DeliveryGroup == "" || r.DeliveryGroup != rows[0].DeliveryGroup ||
r.PhraseBody != "У тебя два напоминания." || r.DeliveryAttempts != 1 {
t.Fatalf("collapsed original lost shared state: %+v", r)
}
}
success := &fakeSink{}
afterRestart := &reminderCountingPhraser{
Phraser: phraser.NewStub(), body: "WRONG", summary: "WRONG", mood: "neutral",
}
newReminderDeliveryLoop(t, st, success, afterRestart).tick(ctx, now.Add(store.ReminderRetryBase))
if afterRestart.calls != 0 {
t.Fatalf("collapsed retry re-phrased %d times", afterRestart.calls)
}
if len(success.sends) != 1 || success.sends[0].ReminderID != 0 {
t.Fatalf("collapsed retry sends = %+v, want one synthetic delivery", success.sends)
}
rows, err = st.ListReminders(ctx, 10)
if err != nil {
t.Fatal(err)
}
for _, r := range rows {
if r.Status != store.ReminderFired {
t.Fatalf("collapsed original %d status = %q, want fired", r.ID, r.Status)
}
}
}