35c6ff5a71
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.
171 lines
5.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|