package store import ( "context" "errors" "sync" "testing" "time" ) func createCancellationReminder(t *testing.T, s *Store, fire time.Time, text string) Reminder { t.Helper() id, err := s.CreateReminder(context.Background(), fire, `{"text":"`+text+`"}`, "") if err != nil { t.Fatal(err) } rows, err := s.ListReminders(context.Background(), 1) if err != nil || len(rows) != 1 || rows[0].ID != id { t.Fatalf("created reminder %d, list=%+v err=%v", id, rows, err) } return rows[0] } func TestListPendingRemindersForCancellation(t *testing.T) { ctx := context.Background() s := newTestStore(t) now := time.Date(2026, 8, 15, 9, 0, 0, 0, time.UTC) later := createCancellationReminder(t, s, now.Add(2*time.Hour), "later") earlier := createCancellationReminder(t, s, now.Add(time.Hour), "earlier") fired := createCancellationReminder(t, s, now.Add(3*time.Hour), "already fired") if err := s.MarkReminder(ctx, fired.ID, ReminderFired); err != nil { t.Fatal(err) } all, err := s.ListPendingReminders(ctx, 0) if err != nil { t.Fatal(err) } if len(all) != 2 || all[0].ID != earlier.ID || all[1].ID != later.ID { t.Fatalf("pending firing order = %+v", all) } one, err := s.ListPendingReminders(ctx, 1) if err != nil || len(one) != 1 || one[0].ID != earlier.ID { t.Fatalf("limited pending = %+v, %v", one, err) } } func TestCancelReminderLifecycle(t *testing.T) { ctx := context.Background() s := newTestStore(t) if err := s.CancelReminder(ctx, 999); !errors.Is(err, ErrReminderNotFound) { t.Fatalf("missing cancellation = %v", err) } r := createCancellationReminder(t, s, time.Now().Add(time.Hour), "doctor") if err := s.CancelReminder(ctx, r.ID); err != nil { t.Fatal(err) } rows, err := s.ListReminders(ctx, 1) if err != nil || len(rows) != 1 || rows[0].Status != ReminderCancelled { t.Fatalf("cancelled row = %+v, %v", rows, err) } if err := s.CancelReminder(ctx, r.ID); !errors.Is(err, ErrReminderState) { t.Fatalf("second cancellation = %v", err) } } func TestMarkReminderCannotBypassCancellationSafety(t *testing.T) { ctx := context.Background() s := newTestStore(t) r := createCancellationReminder(t, s, time.Now().Add(time.Hour), "doctor") if err := s.MarkReminder(ctx, r.ID, ReminderCancelled); !errors.Is(err, ErrReminderState) { t.Fatalf("legacy cancelled transition = %v, want ErrReminderState", err) } if got := reminderStatusesForTest(t, s)[r.ID]; got != ReminderPending { t.Fatalf("legacy transition changed status to %q", got) } } func reminderStatusesForTest(t *testing.T, s *Store) map[int64]string { t.Helper() rows, err := s.ListReminders(context.Background(), 100) if err != nil { t.Fatal(err) } out := make(map[int64]string, len(rows)) for _, row := range rows { out[row.ID] = row.Status } return out } func TestCancelReminderInvalidatesCollapsedPhrase(t *testing.T) { ctx := context.Background() s := newTestStore(t) now := time.Date(2026, 8, 15, 9, 0, 0, 0, time.UTC) a := createCancellationReminder(t, s, now.Add(time.Hour), "doctor") b := createCancellationReminder(t, s, now.Add(2*time.Hour), "bread") originals, err := s.ListPendingReminders(ctx, 0) if err != nil { t.Fatal(err) } const group = "reminder:collapsed-cancel-test" if err := s.CacheReminderPhrase(ctx, originals, group, "two reminders", "two", "neutral"); err != nil { t.Fatal(err) } attempt, err := s.BeginDeliveryAttempt(ctx, "reminder", "", a.ID, group, "telegram", "hash", now) if err != nil { t.Fatal(err) } if err := s.CompleteDeliveryAttempt(ctx, attempt, DeliveryFailed, now); err != nil { t.Fatal(err) } if err := s.CancelReminder(ctx, a.ID); err != nil { t.Fatal(err) } rows, err := s.ListReminders(ctx, 10) if err != nil { t.Fatal(err) } byID := make(map[int64]Reminder, len(rows)) for _, row := range rows { byID[row.ID] = row } if got := byID[a.ID]; got.Status != ReminderCancelled || got.HasDeliveryPhrase() { t.Fatalf("cancelled member retained presentation: %+v", got) } if got := byID[b.ID]; got.Status != ReminderPending || got.HasDeliveryPhrase() || got.DeliveryGroup != "" { t.Fatalf("surviving member retained stale bundle: %+v", got) } } func TestCancelReminderRefusesAmbiguousDelivery(t *testing.T) { for _, status := range []string{DeliveryPending, DeliveryUnknown, DeliverySent} { t.Run(status, func(t *testing.T) { ctx := context.Background() s := newTestStore(t) now := time.Date(2026, 8, 15, 9, 0, 0, 0, time.UTC) r := createCancellationReminder(t, s, now.Add(time.Hour), status) group := "reminder:" + status if err := s.CacheReminderPhrase(ctx, []Reminder{r}, group, status, status, "neutral"); err != nil { t.Fatal(err) } attempt, err := s.BeginDeliveryAttempt(ctx, "reminder", "", r.ID, group, "telegram", "hash", now) if err != nil { t.Fatal(err) } switch status { case DeliveryUnknown: if _, err := s.ReconcileStaleDeliveryAttempts(ctx, now.Add(time.Minute)); err != nil { t.Fatal(err) } case DeliverySent: if err := s.CompleteDeliveryAttempt(ctx, attempt, DeliverySent, now); err != nil { t.Fatal(err) } } if err := s.CancelReminder(ctx, r.ID); !errors.Is(err, ErrReminderInFlight) { t.Fatalf("cancel with %s attempt = %v", status, err) } }) } } func TestCancelReminderScopesLegacyAttemptsToTheCurrentOccurrence(t *testing.T) { ctx := context.Background() now := time.Date(2026, 8, 15, 9, 0, 0, 0, time.UTC) t.Run("historical recurring send does not block the series", func(t *testing.T) { s := newTestStore(t) fire := now.Add(24 * time.Hour) id, err := s.CreateReminder(ctx, fire, `{"text":"daily medicine"}`, "0 9 * * *") if err != nil { t.Fatal(err) } if _, err := s.db.ExecContext(ctx, ` INSERT INTO delivery_attempts (kind, reminder_id, delivery_group, channel, body_hash, status, created_ts, completed_ts) VALUES ('reminder', ?, '', 'telegram', 'legacy', 'sent', ?, ?)`, id, now.UnixMilli(), now.UnixMilli()); err != nil { t.Fatal(err) } if err := s.CancelReminder(ctx, id); err != nil { t.Fatalf("historical blank-group attempt blocked recurrence: %v", err) } }) t.Run("current legacy ambiguity still refuses", func(t *testing.T) { s := newTestStore(t) fire := now.Add(time.Hour) r := createCancellationReminder(t, s, fire, "legacy current") if _, err := s.db.ExecContext(ctx, ` INSERT INTO delivery_attempts (kind, reminder_id, delivery_group, channel, body_hash, status, created_ts) VALUES ('reminder', ?, '', 'telegram', 'legacy', 'unknown', ?)`, r.ID, fire.Add(time.Second).UnixMilli()); err != nil { t.Fatal(err) } if err := s.CancelReminder(ctx, r.ID); !errors.Is(err, ErrReminderInFlight) { t.Fatalf("current blank-group ambiguity = %v, want ErrReminderInFlight", err) } }) } func TestReminderCancellationAndDeliveryBeginAreMutuallyExclusive(t *testing.T) { for i := 0; i < 20; i++ { s := newTestStore(t) ctx := context.Background() now := time.Date(2026, 8, 15, 9, 0, 0, i, time.UTC) r := createCancellationReminder(t, s, now.Add(time.Hour), "race") group := "reminder:race" if err := s.CacheReminderPhrase(ctx, []Reminder{r}, group, "race", "race", "neutral"); err != nil { t.Fatal(err) } start := make(chan struct{}) var cancelErr, beginErr error var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() <-start cancelErr = s.CancelReminder(ctx, r.ID) }() go func() { defer wg.Done() <-start _, beginErr = s.BeginDeliveryAttempt(ctx, "reminder", "", r.ID, group, "telegram", "hash", now) }() close(start) wg.Wait() if (cancelErr == nil) == (beginErr == nil) { t.Fatalf("iteration %d: cancel=%v begin=%v; exactly one must win", i, cancelErr, beginErr) } if cancelErr == nil && !errors.Is(beginErr, ErrReminderState) { t.Fatalf("iteration %d: cancellation won, begin=%v", i, beginErr) } if beginErr == nil && !errors.Is(cancelErr, ErrReminderInFlight) { t.Fatalf("iteration %d: delivery won, cancel=%v", i, cancelErr) } } }