package calendartest import ( "database/sql" "net/http" "net/url" "strconv" "strings" "testing" "time" calendar "amadan.net/oficina/calendar/internal/calendar" ) type editReminderFixture struct { app *calendar.App session *http.Cookie path string id, cal int64 start time.Time form url.Values } type editReminderJob struct { Due int64 Key string Claim, Retry sql.NullString Attempts int } func newEditReminderFixture(t *testing.T, repeat string) editReminderFixture { t.Helper() app, session := signedIn(t, "edit-reminder@example.test") start := time.Now().UTC().AddDate(0, 0, 3).Truncate(time.Minute) form := url.Values{"title": {"Future fixture"}, "start": {start.Format(calendar.LayoutLocal)}, "end": {start.Add(time.Hour).Format(calendar.LayoutLocal)}, "tz": {"UTC"}, "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {repeat}} path := createEvent(t, app.Handler(), session, form) id, err := strconv.ParseInt(strings.TrimPrefix(path, "/events/"), 10, 64) if err != nil { t.Fatal(err) } var cal int64 if err = app.DBForTest().QueryRow("SELECT calendar_id FROM events WHERE id=?", id).Scan(&cal); err != nil { t.Fatal(err) } return editReminderFixture{app, session, path, id, cal, start, form} } func reminderJob(t *testing.T, app *calendar.App, id int64) (editReminderJob, bool) { t.Helper() var q editReminderJob err := app.DBForTest().QueryRow(`SELECT q.due_at,q.occurrence_key,q.claim_id,q.retry_at,q.attempts FROM reminder_queues q JOIN reminders r ON r.id=q.reminder_id WHERE r.event_id=?`, id).Scan(&q.Due, &q.Key, &q.Claim, &q.Retry, &q.Attempts) if err == sql.ErrNoRows { return q, false } if err != nil { t.Fatal(err) } return q, true } func markReminderClaim(t *testing.T, f editReminderFixture) { t.Helper() _, err := f.app.DBForTest().Exec(`UPDATE reminder_queues SET claim_id='old-worker',claimed_at=?,retry_at=?,attempts=2 WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id=?)`, time.Now().Unix(), f.start.Unix(), f.id) if err != nil { t.Fatal(err) } } func unrelatedReminder(t *testing.T, f editReminderFixture) (int64, editReminderJob) { t.Helper() form := url.Values{"title": {"Unrelated fixture"}, "start": {f.start.AddDate(0, 0, 1).Format(calendar.LayoutLocal)}, "end": {f.start.AddDate(0, 0, 1).Add(time.Hour).Format(calendar.LayoutLocal)}, "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"none"}} path := createEvent(t, f.app.Handler(), f.session, form) id, err := strconv.ParseInt(strings.TrimPrefix(path, "/events/"), 10, 64) if err != nil { t.Fatal(err) } q, ok := reminderJob(t, f.app, id) if !ok { t.Fatal("unrelated job absent") } return id, q } func TestOrdinaryEditsReplaceReminderJobs(t *testing.T) { for _, kind := range []string{"earlier", "later", "cancelled"} { t.Run(kind, func(t *testing.T) { f := newEditReminderFixture(t, "none") other, oldOther := unrelatedReminder(t, f) old, ok := reminderJob(t, f.app, f.id) if !ok { t.Fatal("original job absent") } markReminderClaim(t, f) next := f.start.Add(2 * time.Hour) if kind == "earlier" { next = f.start.Add(-2 * time.Hour) } f.form.Set("start", next.Format(calendar.LayoutLocal)) f.form.Set("end", next.Add(time.Hour).Format(calendar.LayoutLocal)) if kind == "cancelled" { f.form.Set("status", "cancelled") } r := postAs(t, f.app.Handler(), f.session, f.path, f.form) if r.Code != http.StatusSeeOther { t.Fatalf("edit status=%d", r.Code) } q, found := reminderJob(t, f.app, f.id) if kind == "cancelled" { if found { t.Fatalf("cancelled event still queued: %+v", q) } } else if !found || q.Due != next.Unix()-600 || q.Due == old.Due || q.Claim.Valid || q.Retry.Valid || q.Attempts != 0 { t.Fatalf("replacement job=%+v found=%v", q, found) } after, found := reminderJob(t, f.app, other) if !found || after != oldOther { t.Fatal("unrelated reminder changed") } res, err := f.app.DBForTest().Exec(`UPDATE reminder_queues SET fired_at=1 WHERE claim_id='old-worker' AND reminder_id IN (SELECT id FROM reminders WHERE event_id=?)`, f.id) if err != nil { t.Fatal(err) } n, _ := res.RowsAffected() if n != 0 { t.Fatal("old worker retained completion authority") } }) } } func TestSplitSeriesRemovesFinishedHeadReminder(t *testing.T) { f := newEditReminderFixture(t, "daily") other, oldOther := unrelatedReminder(t, f) markReminderClaim(t, f) f.form.Set("scope", "following") f.form.Set("occurrence", f.start.Format(calendar.LayoutLocal)) f.form.Set("start", f.start.Add(time.Hour).Format(calendar.LayoutLocal)) f.form.Set("end", f.start.Add(2*time.Hour).Format(calendar.LayoutLocal)) r := postAs(t, f.app.Handler(), f.session, f.path, f.form) if r.Code != http.StatusSeeOther { t.Fatalf("split status=%d", r.Code) } if q, found := reminderJob(t, f.app, f.id); found { t.Fatalf("finished head queued: %+v", q) } q, found := reminderJob(t, f.app, other) if !found || q != oldOther { t.Fatal("unrelated reminder changed") } var count int if err := f.app.DBForTest().QueryRow("SELECT count(*) FROM events WHERE calendar_id=? AND rrule<>''", f.cal).Scan(&count); err != nil || count != 2 { t.Fatalf("head/tail count=%d err=%v", count, err) } } func TestReminderFailureRollsBackEditAndSplit(t *testing.T) { for _, split := range []bool{false, true} { t.Run(strconv.FormatBool(split), func(t *testing.T) { repeat := "none" operation := "UPDATE" if split { repeat = "daily" operation = "DELETE" } f := newEditReminderFixture(t, repeat) old, ok := reminderJob(t, f.app, f.id) if !ok { t.Fatal("job missing") } _, err := f.app.DBForTest().Exec("CREATE TRIGGER reject_reminder_change BEFORE " + operation + " ON reminder_queues BEGIN SELECT RAISE(ABORT,'fixture refusal'); END") if err != nil { t.Fatal(err) } f.form.Set("start", f.start.Add(time.Hour).Format(calendar.LayoutLocal)) f.form.Set("end", f.start.Add(2*time.Hour).Format(calendar.LayoutLocal)) if split { f.form.Set("scope", "following") f.form.Set("occurrence", f.start.Format(calendar.LayoutLocal)) } r := postAs(t, f.app.Handler(), f.session, f.path, f.form) if r.Code != http.StatusInternalServerError { t.Fatalf("refusal status=%d", r.Code) } q, found := reminderJob(t, f.app, f.id) if !found || q != old { t.Fatal("job changed despite rollback") } var start int64 var rule string var count int if err = f.app.DBForTest().QueryRow("SELECT start_utc,rrule FROM events WHERE id=?", f.id).Scan(&start, &rule); err != nil { t.Fatal(err) } if start != f.start.Unix() || (split && strings.Contains(rule, "UNTIL")) { t.Fatalf("event changed despite rollback: %d %s", start, rule) } if err = f.app.DBForTest().QueryRow("SELECT count(*) FROM events WHERE calendar_id=?", f.cal).Scan(&count); err != nil || count != 1 { t.Fatalf("partial tail count=%d err=%v", count, err) } }) } }