| new file mode 100644 |
| index 0000000..b690a67 |
| --- /dev/null |
| +++ b/internal/calendartest/edit_reminder_reschedule_test.go |
| @@ -0,0 +1,195 @@ |
| +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) |
| + } |
| + }) |
| + } |
| +} |