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>
96 lines
3.2 KiB
Go
96 lines
3.2 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestDroppedDeliveryAttemptRoundTrips — Vikunja #370. A suppressed nudge is
|
|
// recorded as 'dropped'. The status column has a CHECK constraint, so this
|
|
// only works if migration #12 widened it; a fake outbox in a unit test would
|
|
// not catch that.
|
|
func TestDroppedDeliveryAttemptRoundTrips(t *testing.T) {
|
|
s := newTestStore(t)
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
|
|
id, err := s.BeginDeliveryAttempt(ctx, "nudge", "water", 0, "", "drop", "abc123", now)
|
|
if err != nil {
|
|
t.Fatalf("BeginDeliveryAttempt: %v", err)
|
|
}
|
|
if err := s.CompleteDeliveryAttempt(ctx, id, DeliveryDropped, now); err != nil {
|
|
t.Fatalf("CompleteDeliveryAttempt: %v", err)
|
|
}
|
|
|
|
var status string
|
|
err = s.db.QueryRowContext(ctx, `SELECT status FROM delivery_attempts WHERE id = ?`, id).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("read back: %v", err)
|
|
}
|
|
if status != DeliveryDropped {
|
|
t.Fatalf("status: want %q, got %q", DeliveryDropped, status)
|
|
}
|
|
}
|
|
|
|
// TestListDeliveryAttempts — the read path the outbox lacked until #390. The
|
|
// two questions it must answer are "what was dropped" and "what is pending".
|
|
func TestListDeliveryAttempts(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
base := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
|
|
|
|
sent, err := s.BeginDeliveryAttempt(ctx, "nudge", "water", 0, "", "telegram", "h1", base)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.CompleteDeliveryAttempt(ctx, sent, DeliverySent, base.Add(time.Second)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dropped, err := s.BeginDeliveryAttempt(ctx, "nudge", "care", 0, "", "telegram", "h2", base.Add(time.Minute))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.CompleteDeliveryAttempt(ctx, dropped, DeliveryDropped, base.Add(time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reminderID, err := s.CreateReminder(ctx, base.Add(time.Hour), `{"text":"test"}`, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reminders, err := s.ListReminders(ctx, 1)
|
|
if err != nil || len(reminders) != 1 {
|
|
t.Fatalf("list reminder: %+v, %v", reminders, err)
|
|
}
|
|
if err := s.CacheReminderPhrase(ctx, reminders, "reminder:test", "test", "test", "neutral"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.BeginDeliveryAttempt(ctx, "reminder", "", reminderID, "reminder:test", "voice", "h3", base.Add(2*time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
all, err := s.ListDeliveryAttempts(ctx, "", 10)
|
|
if err != nil || len(all) != 3 {
|
|
t.Fatalf("ListDeliveryAttempts = %d rows, err=%v, want 3", len(all), err)
|
|
}
|
|
// Newest first.
|
|
if all[0].Kind != "reminder" || all[0].ReminderID != reminderID {
|
|
t.Fatalf("newest row is %+v, want the reminder", all[0])
|
|
}
|
|
if all[0].HasComplete {
|
|
t.Fatalf("a pending row must have no completion time: %+v", all[0])
|
|
}
|
|
if !all[2].HasComplete || !all[2].Completed.Equal(base.Add(time.Second)) {
|
|
t.Fatalf("completed row lost its time: %+v", all[2])
|
|
}
|
|
|
|
only, err := s.ListDeliveryAttempts(ctx, DeliveryDropped, 10)
|
|
if err != nil || len(only) != 1 || only[0].Rule != "care" {
|
|
t.Fatalf("dropped filter = %+v, err=%v", only, err)
|
|
}
|
|
pending, err := s.ListDeliveryAttempts(ctx, DeliveryPending, 10)
|
|
if err != nil || len(pending) != 1 || pending[0].Kind != "reminder" {
|
|
t.Fatalf("pending filter = %+v, err=%v", pending, err)
|
|
}
|
|
}
|