package calendartest import ( "context" "errors" "log/slog" "net/http" "net/url" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" calendar "amadan.net/oficina/calendar/internal/calendar" ) // Regressions from the SECOND code review of the booking branch. // A browser submits a textarea's line breaks as CRLF. Two paragraphs // are two paragraphs, not a control character (review 2, B3). func TestAMultilineNoteFromABrowserIsAccepted(t *testing.T) { app, _, path, _ := publishedPage(t) h := app.Handler() _, starts := futureDaySlots(t, h, path) form := holdForm(starts[0], "Doc", "doc@tito.io") form.Set("note", "First paragraph.\r\n\r\nSecond paragraph.") if rec := strangerPost(t, h, path, form); rec.Code != http.StatusOK { t.Fatalf("a two-paragraph note: %d\n%s", rec.Code, rec.Body.String()) } var id int64 app.DBForTest().QueryRow(`SELECT id FROM bookings`).Scan(&id) v, err := app.StoreForTest().BookingByID(context.Background(), id, false) if err != nil || v.Note != "First paragraph.\n\nSecond paragraph." { t.Errorf("stored note = %q (err %v), want LF-normalised", v.Note, err) } } // The snapshot is sealed from the page AS RE-READ behind the write // lock, so the booking and its event describe one version of the page // (review 2, B4). func TestTheSnapshotIsSealedBehindTheLock(t *testing.T) { app, host, page := hostWithPage(t) st := app.StoreForTest() ctx := context.Background() now := time.Now() slot := slotsFor(t, app, page, host, now, 3)[0] ws, _ := calendar.ParseAvailability("every day 9-5") app.SetAfterSlotCheckForTest(func() { app.SetAfterSlotCheckForTest(nil) if err := st.UpdateBookingPage(ctx, host.ID, page.ID, calendar.BookingPageInput{ Title: "Renamed", Description: "Half an hour.", Availability: "every day 9-5", DurationMin: 30, HorizonDays: 30}, ws); err != nil { t.Error(err) } }) b, err := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip"), now) if err != nil { t.Fatal(err) } v, _ := st.BookingByID(ctx, b.ID, false) ev, _ := st.Event(ctx, host.ID, b.EventID) if v.Title != "Renamed" || ev.Title != "Renamed — Doc" { t.Errorf("booking says %q, event says %q: two versions of one page", v.Title, ev.Title) } } // A reschedule re-reads the page and its windows behind the lock: hours // the host withdrew, or a page they closed, cannot be moved into // (review 2, A6). func TestARescheduleIntoWithdrawnHoursIsRefused(t *testing.T) { app, host, page := hostWithPage(t) st := app.StoreForTest() ctx := context.Background() now := time.Now() slots := slotsFor(t, app, page, host, now, 3) b, _ := st.HoldBooking(ctx, holdInput(page, host, slots[0], "doc@tito.io", "ip"), now) st.ConfirmBooking(ctx, b.ID, now) loc, err := calendar.Zone(host.Zone) if err != nil { t.Fatal(err) } var late calendar.Slot for _, slot := range slots { if time.Unix(slot.StartUTC, 0).In(loc).Hour() >= 10 { late = slot } } if late.StartUTC == 0 { t.Fatal("no slot outside the withdrawn hours") } ws, _ := calendar.ParseAvailability("every day 9-10") app.SetAfterSlotCheckForTest(func() { app.SetAfterSlotCheckForTest(nil) if err := st.UpdateBookingPage(ctx, host.ID, page.ID, calendar.BookingPageInput{ Title: "Chat", Description: "Half an hour.", Availability: "every day 9-10", DurationMin: 30, HorizonDays: 30}, ws); err != nil { t.Error(err) } }) if _, err := st.RescheduleBooking(ctx, b.ID, late.StartUTC, late.EndUTC, 1, now); !errors.Is(err, calendar.ErrSlotTaken) { t.Errorf("moving into withdrawn hours: %v, want ErrSlotTaken", err) } if err := st.PublishBookingPage(ctx, host.ID, page.ID, false); err != nil { t.Fatal(err) } if _, err := st.RescheduleBooking(ctx, b.ID, slots[1].StartUTC, slots[1].EndUTC, 1, now); !errors.Is(err, calendar.ErrSlotTaken) { t.Errorf("moving on a closed page: %v, want ErrSlotTaken", err) } v, _ := st.BookingByID(ctx, b.ID, false) if v.Event.StartUTC != slots[0].StartUTC || v.Reschedules != 0 { t.Error("the meeting moved anyway") } } // A sender's error text stops at the bounded sender. rastrillo's SMTP // sender writes the recipient into its error and its sign-in handler // logs what it is handed (review 2, B1). func TestASenderErrorReachesNoLogWithItsText(t *testing.T) { var log strings.Builder mailer := &pickyMailer{refuse: "allowed@example.com"} app, err := calendar.New(calendar.Config{ DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: testOrigin, AllowedAddresses: "allowed@example.com", Mailer: mailer, Logger: slog.New(slog.NewTextHandler(&log, nil)), }) if err != nil { t.Fatalf("calendar.New: %v", err) } t.Cleanup(func() { app.Close() }) post(t, app.Handler(), "/signin", url.Values{"address": {"allowed@example.com"}}) if out := log.String(); strings.Contains(out, "nobody@example.com") || strings.Contains(out, "connection refused") { t.Errorf("the sender's own text reached the log:\n%s", out) } } // stuckMailer, once armed, holds every send until released, so the // bounded sender's slots can be filled on purpose. Before that (the // host's sign-in) it delivers at once. type stuckMailer struct { armed atomic.Bool entered chan struct{} release chan struct{} } func (m *stuckMailer) Send(_ context.Context, _, _, _ string) error { if !m.armed.Load() { return nil } m.entered <- struct{}{} <-m.release return nil } // With every sender slot taken, a hold's immediate attempt is DEFERRED // to the drain: the hold stands, and the page says so rather than // "sent" (review 2, B5). func TestADeferredHoldMailIsNotReportedSent(t *testing.T) { mailer := &stuckMailer{entered: make(chan struct{}, 8), release: make(chan struct{})} app, err := calendar.New(calendar.Config{ DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: testOrigin, Mailer: mailer, Logger: slog.New(slog.NewTextHandler(&strings.Builder{}, nil)), }) if err != nil { t.Fatal(err) } t.Cleanup(func() { app.Close() }) session := app.SignInForTest(t, "host@example.com") h := app.Handler() postAs(t, h, session, "/settings", url.Values{"zone": {"UTC"}, "week_start": {"1"}, "default_reminder_min": {"10"}, "default_all_day_min": {"0"}, "handle": {"paul"}}) postAs(t, h, session, "/settings/booking", url.Values{"title": {"Chat"}, "description": {"Half an hour."}, "availability": {"every day 0-24"}, "duration_min": {"30"}, "buffer_min": {"0"}, "notice_min": {"0"}, "horizon_days": {"30"}}) postAs(t, h, session, "/settings/booking/1/publish", url.Values{"publish": {"1"}}) path := strings.TrimSpace(rePublicURL.FindString(getAs(t, h, session, "/settings/booking").Body.String())) _, starts := futureDaySlots(t, h, path) if len(starts) < 5 { t.Fatalf("only %d slots on the first day", len(starts)) } // Four holds whose mail is stuck in the sender: every slot taken. mailer.armed.Store(true) var wg sync.WaitGroup for i := 0; i < 4; i++ { wg.Add(1) go func(i int) { defer wg.Done() strangerPost(t, h, path, holdForm(starts[i], "Doc", "doc"+string(rune('a'+i))+"@tito.io")) }(i) } for i := 0; i < 4; i++ { select { case <-mailer.entered: case <-time.After(10 * time.Second): t.Fatal("the sender slots never filled") } } rec := strangerPost(t, h, path, holdForm(starts[4], "Doc", "doce@tito.io")) close(mailer.release) wg.Wait() if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "If nothing arrives") { t.Errorf("the fifth hold, deferred, was reported sent: %d\n%s", rec.Code, rec.Body.String()) } var n int64 app.DBForTest().QueryRow(`SELECT COUNT(*) FROM booking_mails WHERE sent_at IS NULL AND attempts = 0`).Scan(&n) if n != 1 { t.Errorf("owed rows with no attempt = %d, want the deferred one", n) } } // "Nothing claimable" is not "everything sent": a row another worker // holds a lease on is still owed, and the page must not say "sent" // about it (review 3, C1). func TestAForeignClaimIsNotReportedSent(t *testing.T) { app, host, page := hostWithPage(t) st := app.StoreForTest() ctx := context.Background() now := time.Now() slot := slotsFor(t, app, page, host, now, 3)[0] b, err := st.HoldBooking(ctx, holdInput(page, host, slot, "doc@tito.io", "ip"), now) if err != nil { t.Fatal(err) } // Another worker — a tick — claims the hold mail first. row, ok, err := st.ClaimBookingMailFor(ctx, b.ID, now.Unix()) if err != nil || !ok { t.Fatalf("claim: ok=%v err=%v", ok, err) } if app.TrySendNowForTest(b.ID) { t.Error("with the row leased elsewhere, the attempt reported everything sent") } if ok, _ := st.MarkBookingMailSent(ctx, row.ID, row.ClaimID, now.Unix()); !ok { t.Fatal("mark sent") } if !app.TrySendNowForTest(b.ID) { t.Error("with nothing owed, the attempt reported something unsent") } } // A page the host closed offers no times to move to: the reschedule // picker says so instead of listing slots every one of which the store // would refuse (review 3, C2). And a refused slot is explained on the // picker it returns to. func TestTheReschedulePickerOnAClosedPageSaysSo(t *testing.T) { app, mailer, path, _ := publishedPage(t) h := app.Handler() session := app.SignInForTest(t, "host@example.com") mailer.reset() // the sign-in link is not the mail under test _, starts := futureDaySlots(t, h, path) strangerPost(t, h, path, holdForm(starts[0], "Doc", "doc@tito.io")) link := reManage.FindString(mailer.body(0)) strangerPost(t, h, link+"/confirm", nil) body := strangerGet(t, h, link+"/reschedule?gone=1").Body.String() if !strings.Contains(body, "That time has just gone") { t.Error("a refused move returns to a picker that does not say why") } postAs(t, h, session, "/settings/booking/1/publish", url.Values{"publish": {"0"}}) rec := strangerGet(t, h, link+"/reschedule") if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "no longer taking bookings") || strings.Contains(rec.Body.String(), `name="start"`) { t.Errorf("reschedule picker on a closed page: %d\n%s", rec.Code, rec.Body.String()) } rec = strangerPost(t, h, link+"/reschedule", url.Values{"start": {starts[1]}, "zone": {"UTC"}, "rev": {"1"}}) if rec.Code != http.StatusSeeOther { t.Errorf("a move on a closed page: %d, want a redirect back to the explanation", rec.Code) } if strangerGet(t, h, link).Code != http.StatusOK { t.Error("the booking itself no longer opens") } }