oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendartest
2
3import (
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
15type 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}
23type editReminderJob struct {
24 Due int64
25 Key string
26 Claim, Retry sql.NullString
27 Attempts int
28}
29
30func 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}
46func 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}
58func 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}
65func 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}
79func 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}
125func 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}
149func 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