0b057df2a3
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>
244 lines
7.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|