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) } } }