| 1 | package calendartest |
| 2 | |
| 3 | import ( |
| 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). |
| 23 | func 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). |
| 43 | func 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). |
| 72 | func 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). |
| 121 | func 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. |
| 145 | type stuckMailer struct { |
| 146 | armed atomic.Bool |
| 147 | entered chan struct{} |
| 148 | release chan struct{} |
| 149 | } |
| 150 | |
| 151 | func (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). |
| 163 | func 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). |
| 219 | func 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. |
| 249 | func 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 | |