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 "context"
5 "errors"
6 "log/slog"
7 "net/http"
8 "net/url"
9 "path/filepath"
10 "strings"
11 "sync"
12 "sync/atomic"
13 "testing"
14 "time"
15
16 calendar "amadan.net/oficina/calendar/internal/calendar"
17)
18
19// Regressions from the SECOND code review of the booking branch.
20
21// A browser submits a textarea's line breaks as CRLF. Two paragraphs
22// are two paragraphs, not a control character (review 2, B3).
23func TestAMultilineNoteFromABrowserIsAccepted(t *testing.T) {
24 app, _, path, _ := publishedPage(t)
25 h := app.Handler()
26 _, starts := futureDaySlots(t, h, path)
27 form := holdForm(starts[0], "Doc", "doc@tito.io")
28 form.Set("note", "First paragraph.\r\n\r\nSecond paragraph.")
29 if rec := strangerPost(t, h, path, form); rec.Code != http.StatusOK {
30 t.Fatalf("a two-paragraph note: %d\n%s", rec.Code, rec.Body.String())
31 }
32 var id int64
33 app.DBForTest().QueryRow(`SELECT id FROM bookings`).Scan(&id)
34 v, err := app.StoreForTest().BookingByID(context.Background(), id, false)
35 if err != nil || v.Note != "First paragraph.\n\nSecond paragraph." {
36 t.Errorf("stored note = %q (err %v), want LF-normalised", v.Note, err)
37 }
38}
39
40// The snapshot is sealed from the page AS RE-READ behind the write
41// lock, so the booking and its event describe one version of the page
42// (review 2, B4).
43func TestTheSnapshotIsSealedBehindTheLock(t *testing.T) {
44 app, host, page := hostWithPage(t)
45 st := app.StoreForTest()
46 ctx := context.Background()
47 now := time.Now()
48 slot := slotsFor(t, app, page, host, now, 3)[0]
49 ws, _ := calendar.ParseAvailability("every day 9-5")
50 app.SetAfterSlotCheckForTest(func() {
51 app.SetAfterSlotCheckForTest(nil)
52 if err := st.UpdateBookingPage(ctx, host.ID, page.ID, calendar.BookingPageInput{
53 Title: "Renamed", Description: "Half an hour.", Availability: "every day 9-5",
54 DurationMin: 30, HorizonDays: 30}, ws); err != nil {
55 t.Error(err)
56 }
57 })
58 b, err := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip"), now)
59 if err != nil {
60 t.Fatal(err)
61 }
62 v, _ := st.BookingByID(ctx, b.ID, false)
63 ev, _ := st.Event(ctx, host.ID, b.EventID)
64 if v.Title != "Renamed" || ev.Title != "Renamed — Doc" {
65 t.Errorf("booking says %q, event says %q: two versions of one page", v.Title, ev.Title)
66 }
67}
68
69// A reschedule re-reads the page and its windows behind the lock: hours
70// the host withdrew, or a page they closed, cannot be moved into
71// (review 2, A6).
72func TestARescheduleIntoWithdrawnHoursIsRefused(t *testing.T) {
73 app, host, page := hostWithPage(t)
74 st := app.StoreForTest()
75 ctx := context.Background()
76 now := time.Now()
77 slots := slotsFor(t, app, page, host, now, 3)
78 b, _ := st.HoldBooking(ctx, holdInput(page, host, slots[0], "doc@tito.io", "ip"), now)
79 st.ConfirmBooking(ctx, b.ID, now)
80 loc, err := calendar.Zone(host.Zone)
81 if err != nil {
82 t.Fatal(err)
83 }
84 var late calendar.Slot
85 for _, slot := range slots {
86 if time.Unix(slot.StartUTC, 0).In(loc).Hour() >= 10 {
87 late = slot
88 }
89 }
90 if late.StartUTC == 0 {
91 t.Fatal("no slot outside the withdrawn hours")
92 }
93
94 ws, _ := calendar.ParseAvailability("every day 9-10")
95 app.SetAfterSlotCheckForTest(func() {
96 app.SetAfterSlotCheckForTest(nil)
97 if err := st.UpdateBookingPage(ctx, host.ID, page.ID, calendar.BookingPageInput{
98 Title: "Chat", Description: "Half an hour.", Availability: "every day 9-10",
99 DurationMin: 30, HorizonDays: 30}, ws); err != nil {
100 t.Error(err)
101 }
102 })
103 if _, err := st.RescheduleBooking(ctx, b.ID, late.StartUTC, late.EndUTC, 1, now); !errors.Is(err, calendar.ErrSlotTaken) {
104 t.Errorf("moving into withdrawn hours: %v, want ErrSlotTaken", err)
105 }
106 if err := st.PublishBookingPage(ctx, host.ID, page.ID, false); err != nil {
107 t.Fatal(err)
108 }
109 if _, err := st.RescheduleBooking(ctx, b.ID, slots[1].StartUTC, slots[1].EndUTC, 1, now); !errors.Is(err, calendar.ErrSlotTaken) {
110 t.Errorf("moving on a closed page: %v, want ErrSlotTaken", err)
111 }
112 v, _ := st.BookingByID(ctx, b.ID, false)
113 if v.Event.StartUTC != slots[0].StartUTC || v.Reschedules != 0 {
114 t.Error("the meeting moved anyway")
115 }
116}
117
118// A sender's error text stops at the bounded sender. rastrillo's SMTP
119// sender writes the recipient into its error and its sign-in handler
120// logs what it is handed (review 2, B1).
121func TestASenderErrorReachesNoLogWithItsText(t *testing.T) {
122 var log strings.Builder
123 mailer := &pickyMailer{refuse: "allowed@example.com"}
124 app, err := calendar.New(calendar.Config{
125 DBPath: filepath.Join(t.TempDir(), "calendar.db"),
126 Env: "development",
127 Origin: testOrigin,
128 AllowedAddresses: "allowed@example.com",
129 Mailer: mailer,
130 Logger: slog.New(slog.NewTextHandler(&log, nil)),
131 })
132 if err != nil {
133 t.Fatalf("calendar.New: %v", err)
134 }
135 t.Cleanup(func() { app.Close() })
136 post(t, app.Handler(), "/signin", url.Values{"address": {"allowed@example.com"}})
137 if out := log.String(); strings.Contains(out, "nobody@example.com") || strings.Contains(out, "connection refused") {
138 t.Errorf("the sender's own text reached the log:\n%s", out)
139 }
140}
141
142// stuckMailer, once armed, holds every send until released, so the
143// bounded sender's slots can be filled on purpose. Before that (the
144// host's sign-in) it delivers at once.
145type stuckMailer struct {
146 armed atomic.Bool
147 entered chan struct{}
148 release chan struct{}
149}
150
151func (m *stuckMailer) Send(_ context.Context, _, _, _ string) error {
152 if !m.armed.Load() {
153 return nil
154 }
155 m.entered <- struct{}{}
156 <-m.release
157 return nil
158}
159
160// With every sender slot taken, a hold's immediate attempt is DEFERRED
161// to the drain: the hold stands, and the page says so rather than
162// "sent" (review 2, B5).
163func TestADeferredHoldMailIsNotReportedSent(t *testing.T) {
164 mailer := &stuckMailer{entered: make(chan struct{}, 8), release: make(chan struct{})}
165 app, err := calendar.New(calendar.Config{
166 DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: testOrigin,
167 Mailer: mailer, Logger: slog.New(slog.NewTextHandler(&strings.Builder{}, nil)),
168 })
169 if err != nil {
170 t.Fatal(err)
171 }
172 t.Cleanup(func() { app.Close() })
173 session := app.SignInForTest(t, "host@example.com")
174 h := app.Handler()
175 postAs(t, h, session, "/settings", url.Values{"zone": {"UTC"}, "week_start": {"1"},
176 "default_reminder_min": {"10"}, "default_all_day_min": {"0"}, "handle": {"paul"}})
177 postAs(t, h, session, "/settings/booking", url.Values{"title": {"Chat"}, "description": {"Half an hour."},
178 "availability": {"every day 0-24"}, "duration_min": {"30"}, "buffer_min": {"0"}, "notice_min": {"0"}, "horizon_days": {"30"}})
179 postAs(t, h, session, "/settings/booking/1/publish", url.Values{"publish": {"1"}})
180 path := strings.TrimSpace(rePublicURL.FindString(getAs(t, h, session, "/settings/booking").Body.String()))
181 _, starts := futureDaySlots(t, h, path)
182 if len(starts) < 5 {
183 t.Fatalf("only %d slots on the first day", len(starts))
184 }
185
186 // Four holds whose mail is stuck in the sender: every slot taken.
187 mailer.armed.Store(true)
188 var wg sync.WaitGroup
189 for i := 0; i < 4; i++ {
190 wg.Add(1)
191 go func(i int) {
192 defer wg.Done()
193 strangerPost(t, h, path, holdForm(starts[i], "Doc", "doc"+string(rune('a'+i))+"@tito.io"))
194 }(i)
195 }
196 for i := 0; i < 4; i++ {
197 select {
198 case <-mailer.entered:
199 case <-time.After(10 * time.Second):
200 t.Fatal("the sender slots never filled")
201 }
202 }
203 rec := strangerPost(t, h, path, holdForm(starts[4], "Doc", "doce@tito.io"))
204 close(mailer.release)
205 wg.Wait()
206 if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "If nothing arrives") {
207 t.Errorf("the fifth hold, deferred, was reported sent: %d\n%s", rec.Code, rec.Body.String())
208 }
209 var n int64
210 app.DBForTest().QueryRow(`SELECT COUNT(*) FROM booking_mails WHERE sent_at IS NULL AND attempts = 0`).Scan(&n)
211 if n != 1 {
212 t.Errorf("owed rows with no attempt = %d, want the deferred one", n)
213 }
214}
215
216// "Nothing claimable" is not "everything sent": a row another worker
217// holds a lease on is still owed, and the page must not say "sent"
218// about it (review 3, C1).
219func TestAForeignClaimIsNotReportedSent(t *testing.T) {
220 app, host, page := hostWithPage(t)
221 st := app.StoreForTest()
222 ctx := context.Background()
223 now := time.Now()
224 slot := slotsFor(t, app, page, host, now, 3)[0]
225 b, err := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip"), now)
226 if err != nil {
227 t.Fatal(err)
228 }
229 // Another worker — a tick — claims the hold mail first.
230 row, ok, err := st.ClaimBookingMailFor(ctx, b.ID, now.Unix())
231 if err != nil || !ok {
232 t.Fatalf("claim: ok=%v err=%v", ok, err)
233 }
234 if app.TrySendNowForTest(b.ID) {
235 t.Error("with the row leased elsewhere, the attempt reported everything sent")
236 }
237 if ok, _ := st.MarkBookingMailSent(ctx, row.ID, row.ClaimID, now.Unix()); !ok {
238 t.Fatal("mark sent")
239 }
240 if !app.TrySendNowForTest(b.ID) {
241 t.Error("with nothing owed, the attempt reported something unsent")
242 }
243}
244
245// A page the host closed offers no times to move to: the reschedule
246// picker says so instead of listing slots every one of which the store
247// would refuse (review 3, C2). And a refused slot is explained on the
248// picker it returns to.
249func TestTheReschedulePickerOnAClosedPageSaysSo(t *testing.T) {
250 app, mailer, path, _ := publishedPage(t)
251 h := app.Handler()
252 session := app.SignInForTest(t, "host@example.com")
253 mailer.reset() // the sign-in link is not the mail under test
254 _, starts := futureDaySlots(t, h, path)
255 strangerPost(t, h, path, holdForm(starts[0], "Doc", "doc@tito.io"))
256 link := reManage.FindString(mailer.body(0))
257 strangerPost(t, h, link+"/confirm", nil)
258
259 body := strangerGet(t, h, link+"/reschedule?gone=1").Body.String()
260 if !strings.Contains(body, "That time has just gone") {
261 t.Error("a refused move returns to a picker that does not say why")
262 }
263 postAs(t, h, session, "/settings/booking/1/publish", url.Values{"publish": {"0"}})
264 rec := strangerGet(t, h, link+"/reschedule")
265 if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "no longer taking bookings") || strings.Contains(rec.Body.String(), `name="start"`) {
266 t.Errorf("reschedule picker on a closed page: %d\n%s", rec.Code, rec.Body.String())
267 }
268 rec = strangerPost(t, h, link+"/reschedule", url.Values{"start": {starts[1]}, "zone": {"UTC"}, "rev": {"1"}})
269 if rec.Code != http.StatusSeeOther {
270 t.Errorf("a move on a closed page: %d, want a redirect back to the explanation", rec.Code)
271 }
272 if strangerGet(t, h, link).Code != http.StatusOK {
273 t.Error("the booking itself no longer opens")
274 }
275}
276