Files
Maven/internal/store/reminders_cancel_test.go
claude 0b057df2a3 Give reminder cancellation its own store and IPC path (V-719)
CancelReminder replaces the cancelled half of MarkReminder, which stays
delivery-only. Cancellation has to win against the start of an external
send, so it refuses when the occurrence has a pending, sent or unknown
outbox row, and clears the delivery group inside the same transaction.
BeginDeliveryAttempt takes the mirror lock for reminder sends, so no
interleaving lets both operations report success.

Cancelling one member of a collapsed catch-up bundle invalidates the
cached phrase on every pending sibling; a later retry would otherwise keep
saying "three reminders" after one was removed.

Legacy rows carry the empty delivery group from migration 25, so they only
count as this occurrence when they began at or after its next-fire
boundary. Without that bound one old success would make a recurring series
permanently uncancellable.

ListPendingReminders returns cancellable rows in firing order, with no
limit by default, because spoken resolution must not miss an old reminder
that newer fired history pushed out of ListReminders' window.

Cancellation is ordinary authenticated write authority: it prevents a
future send and cannot create one. cmd/e2eprobe drives both from outside.

--no-verify: master is the working branch this session by the owner's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:19:13 +04:00

244 lines
7.9 KiB
Go

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