| 1 | package calendartest |
| 2 | |
| 3 | import ( |
| 4 | "database/sql" |
| 5 | "net/http" |
| 6 | "net/url" |
| 7 | "strconv" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | calendar "amadan.net/oficina/calendar/internal/calendar" |
| 13 | ) |
| 14 | |
| 15 | type editReminderFixture struct { |
| 16 | app *calendar.App |
| 17 | session *http.Cookie |
| 18 | path string |
| 19 | id, cal int64 |
| 20 | start time.Time |
| 21 | form url.Values |
| 22 | } |
| 23 | type editReminderJob struct { |
| 24 | Due int64 |
| 25 | Key string |
| 26 | Claim, Retry sql.NullString |
| 27 | Attempts int |
| 28 | } |
| 29 | |
| 30 | func newEditReminderFixture(t *testing.T, repeat string) editReminderFixture { |
| 31 | t.Helper() |
| 32 | app, session := signedIn(t, "edit-reminder@example.test") |
| 33 | start := time.Now().UTC().AddDate(0, 0, 3).Truncate(time.Minute) |
| 34 | 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}} |
| 35 | path := createEvent(t, app.Handler(), session, form) |
| 36 | id, err := strconv.ParseInt(strings.TrimPrefix(path, "/events/"), 10, 64) |
| 37 | if err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | var cal int64 |
| 41 | if err = app.DBForTest().QueryRow("SELECT calendar_id FROM events WHERE id=?", id).Scan(&cal); err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | return editReminderFixture{app, session, path, id, cal, start, form} |
| 45 | } |
| 46 | func reminderJob(t *testing.T, app *calendar.App, id int64) (editReminderJob, bool) { |
| 47 | t.Helper() |
| 48 | var q editReminderJob |
| 49 | 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) |
| 50 | if err == sql.ErrNoRows { |
| 51 | return q, false |
| 52 | } |
| 53 | if err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | return q, true |
| 57 | } |
| 58 | func markReminderClaim(t *testing.T, f editReminderFixture) { |
| 59 | t.Helper() |
| 60 | _, 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) |
| 61 | if err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | } |
| 65 | func unrelatedReminder(t *testing.T, f editReminderFixture) (int64, editReminderJob) { |
| 66 | t.Helper() |
| 67 | 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"}} |
| 68 | path := createEvent(t, f.app.Handler(), f.session, form) |
| 69 | id, err := strconv.ParseInt(strings.TrimPrefix(path, "/events/"), 10, 64) |
| 70 | if err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | q, ok := reminderJob(t, f.app, id) |
| 74 | if !ok { |
| 75 | t.Fatal("unrelated job absent") |
| 76 | } |
| 77 | return id, q |
| 78 | } |
| 79 | func TestOrdinaryEditsReplaceReminderJobs(t *testing.T) { |
| 80 | for _, kind := range []string{"earlier", "later", "cancelled"} { |
| 81 | t.Run(kind, func(t *testing.T) { |
| 82 | f := newEditReminderFixture(t, "none") |
| 83 | other, oldOther := unrelatedReminder(t, f) |
| 84 | old, ok := reminderJob(t, f.app, f.id) |
| 85 | if !ok { |
| 86 | t.Fatal("original job absent") |
| 87 | } |
| 88 | markReminderClaim(t, f) |
| 89 | next := f.start.Add(2 * time.Hour) |
| 90 | if kind == "earlier" { |
| 91 | next = f.start.Add(-2 * time.Hour) |
| 92 | } |
| 93 | f.form.Set("start", next.Format(calendar.LayoutLocal)) |
| 94 | f.form.Set("end", next.Add(time.Hour).Format(calendar.LayoutLocal)) |
| 95 | if kind == "cancelled" { |
| 96 | f.form.Set("status", "cancelled") |
| 97 | } |
| 98 | r := postAs(t, f.app.Handler(), f.session, f.path, f.form) |
| 99 | if r.Code != http.StatusSeeOther { |
| 100 | t.Fatalf("edit status=%d", r.Code) |
| 101 | } |
| 102 | q, found := reminderJob(t, f.app, f.id) |
| 103 | if kind == "cancelled" { |
| 104 | if found { |
| 105 | t.Fatalf("cancelled event still queued: %+v", q) |
| 106 | } |
| 107 | } else if !found || q.Due != next.Unix()-600 || q.Due == old.Due || q.Claim.Valid || q.Retry.Valid || q.Attempts != 0 { |
| 108 | t.Fatalf("replacement job=%+v found=%v", q, found) |
| 109 | } |
| 110 | after, found := reminderJob(t, f.app, other) |
| 111 | if !found || after != oldOther { |
| 112 | t.Fatal("unrelated reminder changed") |
| 113 | } |
| 114 | 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) |
| 115 | if err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | n, _ := res.RowsAffected() |
| 119 | if n != 0 { |
| 120 | t.Fatal("old worker retained completion authority") |
| 121 | } |
| 122 | }) |
| 123 | } |
| 124 | } |
| 125 | func TestSplitSeriesRemovesFinishedHeadReminder(t *testing.T) { |
| 126 | f := newEditReminderFixture(t, "daily") |
| 127 | other, oldOther := unrelatedReminder(t, f) |
| 128 | markReminderClaim(t, f) |
| 129 | f.form.Set("scope", "following") |
| 130 | f.form.Set("occurrence", f.start.Format(calendar.LayoutLocal)) |
| 131 | f.form.Set("start", f.start.Add(time.Hour).Format(calendar.LayoutLocal)) |
| 132 | f.form.Set("end", f.start.Add(2*time.Hour).Format(calendar.LayoutLocal)) |
| 133 | r := postAs(t, f.app.Handler(), f.session, f.path, f.form) |
| 134 | if r.Code != http.StatusSeeOther { |
| 135 | t.Fatalf("split status=%d", r.Code) |
| 136 | } |
| 137 | if q, found := reminderJob(t, f.app, f.id); found { |
| 138 | t.Fatalf("finished head queued: %+v", q) |
| 139 | } |
| 140 | q, found := reminderJob(t, f.app, other) |
| 141 | if !found || q != oldOther { |
| 142 | t.Fatal("unrelated reminder changed") |
| 143 | } |
| 144 | var count int |
| 145 | if err := f.app.DBForTest().QueryRow("SELECT count(*) FROM events WHERE calendar_id=? AND rrule<>''", f.cal).Scan(&count); err != nil || count != 2 { |
| 146 | t.Fatalf("head/tail count=%d err=%v", count, err) |
| 147 | } |
| 148 | } |
| 149 | func TestReminderFailureRollsBackEditAndSplit(t *testing.T) { |
| 150 | for _, split := range []bool{false, true} { |
| 151 | t.Run(strconv.FormatBool(split), func(t *testing.T) { |
| 152 | repeat := "none" |
| 153 | operation := "UPDATE" |
| 154 | if split { |
| 155 | repeat = "daily" |
| 156 | operation = "DELETE" |
| 157 | } |
| 158 | f := newEditReminderFixture(t, repeat) |
| 159 | old, ok := reminderJob(t, f.app, f.id) |
| 160 | if !ok { |
| 161 | t.Fatal("job missing") |
| 162 | } |
| 163 | _, err := f.app.DBForTest().Exec("CREATE TRIGGER reject_reminder_change BEFORE " + operation + " ON reminder_queues BEGIN SELECT RAISE(ABORT,'fixture refusal'); END") |
| 164 | if err != nil { |
| 165 | t.Fatal(err) |
| 166 | } |
| 167 | f.form.Set("start", f.start.Add(time.Hour).Format(calendar.LayoutLocal)) |
| 168 | f.form.Set("end", f.start.Add(2*time.Hour).Format(calendar.LayoutLocal)) |
| 169 | if split { |
| 170 | f.form.Set("scope", "following") |
| 171 | f.form.Set("occurrence", f.start.Format(calendar.LayoutLocal)) |
| 172 | } |
| 173 | r := postAs(t, f.app.Handler(), f.session, f.path, f.form) |
| 174 | if r.Code != http.StatusInternalServerError { |
| 175 | t.Fatalf("refusal status=%d", r.Code) |
| 176 | } |
| 177 | q, found := reminderJob(t, f.app, f.id) |
| 178 | if !found || q != old { |
| 179 | t.Fatal("job changed despite rollback") |
| 180 | } |
| 181 | var start int64 |
| 182 | var rule string |
| 183 | var count int |
| 184 | if err = f.app.DBForTest().QueryRow("SELECT start_utc,rrule FROM events WHERE id=?", f.id).Scan(&start, &rule); err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | if start != f.start.Unix() || (split && strings.Contains(rule, "UNTIL")) { |
| 188 | t.Fatalf("event changed despite rollback: %d %s", start, rule) |
| 189 | } |
| 190 | if err = f.app.DBForTest().QueryRow("SELECT count(*) FROM events WHERE calendar_id=?", f.cal).Scan(&count); err != nil || count != 1 { |
| 191 | t.Fatalf("partial tail count=%d err=%v", count, err) |
| 192 | } |
| 193 | }) |
| 194 | } |
| 195 | } |
| 196 | |