booking: the second review's twelve, all fixed
GPT-6's second pass found four blockers and eight partials in the first round of fixes. Every one is closed here, each with a regression. The leak that survived the first round: the bounded sender returned the SMTP sender's own error, which names the recipient, and rastrillo's sign-in handler logs what it is handed. Now the wrapper discards the text at its boundary — busy, timeout, refused are the only three errors above it. A browser's textarea submits CRLF and the note refused its own line breaks; normalised before anything is measured. The hold sealed its snapshot before taking the write lock, so a page renamed in between gave the booking one title and the event another; sealed from the re-read page now. The reschedule re-read the host but not the page and its windows; withdrawn hours and a closed page refuse the move. A confirmed booking forgets its owed hold mail in its own transaction. A deferral was reported as a send, on the page and in the drain's count; deferred and retired are errors to the caller now. Bookkeeping after a send runs on its own short context, never the phase's, and a reminder phase that runs out hands over to booking mail rather than returning. Reminder deferral reports its rows. The booked form's locked controls include zone, kind, all-day and repeat. The UNTIL slack test pins UNTIL at the final occurrence's start, so it now exercises the slack.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
12 files changed,
+326
−54
internal/calendar/booking_mail.go+21 −6internal/calendar/handlers_booking_public.go+5 −20internal/calendar/handlers_booking_util.go+18 −0internal/calendar/handlers_jobs.go+19 −6internal/calendar/mail_bounded.go+11 −1internal/calendar/store_booking_confirm.go+14 −4internal/calendar/store_booking_flow.go+8 −5internal/calendar/store_drain.go+6 −6internal/calendar/templates/event_form.html+7 −1internal/calendartest/booking_flow_test.go+3 −1internal/calendartest/booking_review2_test.go+202 −0internal/calendartest/occurrence_strict_test.go+12 −4
diff --git a/internal/calendar/booking_mail.go b/internal/calendar/booking_mail.go| index 9827f83..7bc5691 100644 |
| --- a/internal/calendar/booking_mail.go |
| +++ b/internal/calendar/booking_mail.go |
| @@ -83,8 +83,10 @@ func (h *handlers) sendBookingMail(ctx context.Context, row BookingMail, now tim |
| // expired, a rescheduled mail after a cancellation. Mail says the |
| // current state or says nothing. |
| if obsolete(row, v, now) { |
| - _, err := h.store.MarkBookingMailSent(ctx, row.ID, row.ClaimID, now.Unix()) |
| - return err |
| + if _, err := h.store.MarkBookingMailSent(keep(ctx), row.ID, row.ClaimID, now.Unix()); err != nil { |
| + return err |
| + } |
| + return errMailRetired |
| } |
| to, subject, body, err := h.renderBookingMail(row, v, now) |
| if err != nil { |
| @@ -95,13 +97,18 @@ func (h *handlers) sendBookingMail(ctx context.Context, row BookingMail, now tim |
| err = h.mailer.Send(sendCtx, to, subject, body) |
| switch { |
| case err == nil: |
| - _, err := h.store.MarkBookingMailSent(ctx, row.ID, row.ClaimID, now.Unix()) |
| + ok, err := h.store.MarkBookingMailSent(keep(ctx), row.ID, row.ClaimID, now.Unix()) |
| + if err == nil && !ok { |
| + return errClaimLost |
| + } |
| return err |
| case errors.Is(err, ErrMailBusy): |
| - _, err := h.store.DeferBookingMail(ctx, row.ID, row.ClaimID, now.Unix()) |
| - return err |
| + if _, err := h.store.DeferBookingMail(keep(ctx), row.ID, row.ClaimID, now.Unix()); err != nil { |
| + return err |
| + } |
| + return errMailDeferred |
| default: |
| - gaveUp, merr := h.store.MarkBookingMailFailed(ctx, row.ID, row.ClaimID) |
| + gaveUp, merr := h.store.MarkBookingMailFailed(keep(ctx), row.ID, row.ClaimID) |
| if merr != nil { |
| return merr |
| } |
| @@ -117,6 +124,14 @@ func (h *handlers) sendBookingMail(ctx context.Context, row BookingMail, now tim |
| } |
| } |
| +// The two outcomes that are neither a send nor a failure. Both are |
| +// errors to the caller so a deferral is never counted as delivery and a |
| +// hold page never says "sent" about a row that went back in the queue. |
| +var ( |
| + errMailDeferred = errors.New("calendar: mail deferred") |
| + errMailRetired = errors.New("calendar: mail retired unsent") |
| +) |
| + |
| // obsolete says whether a queued row has been overtaken by the state |
| // it would describe. |
| func obsolete(row BookingMail, v BookingView, now time.Time) bool { |
diff --git a/internal/calendar/handlers_booking_public.go b/internal/calendar/handlers_booking_public.go| index fded7f1..d7a8d40 100644 |
| --- a/internal/calendar/handlers_booking_public.go |
| +++ b/internal/calendar/handlers_booking_public.go |
| @@ -135,13 +135,6 @@ func (h *handlers) picker(w http.ResponseWriter, r *http.Request, page BookingPa |
| return d, true |
| } |
| -func hostNameOf(host Person) string { |
| - if host.Handle != nil { |
| - return *host.Handle |
| - } |
| - return "" |
| -} |
| - |
| // bookPage is GET /book/{handle}/{code}. |
| func (h *handlers) bookPage(w http.ResponseWriter, r *http.Request) { |
| page, host, ok := h.publicPage(w, r) |
| @@ -212,22 +205,14 @@ func (h *handlers) bookPost(w http.ResponseWriter, r *http.Request) { |
| render(w, "book_page", h.page(r, page.Title, d)) |
| } |
| -// hasControl reports a control character (a line break, a NUL) in a |
| -// single-line field. |
| -func hasControl(s string) bool { |
| - for _, r := range s { |
| - if r < 0x20 || r == 0x7f { |
| - return true |
| - } |
| - } |
| - return false |
| -} |
| - |
| // hold validates the details and makes the hold (§5.2). |
| func (h *handlers) hold(w http.ResponseWriter, r *http.Request, page BookingPageView, host Person, d bookPageData, zone string) { |
| - d.Name = strings.TrimSpace(r.FormValue("name")) |
| + // A browser submits a textarea's line breaks as CRLF. Normalised |
| + // to LF before anything is measured or refused, so two paragraphs |
| + // are two paragraphs and not a control character. |
| + d.Name = strings.TrimSpace(strings.ReplaceAll(r.FormValue("name"), "\r", "")) |
| d.Address = normalizeAddress(r.FormValue("address")) |
| - d.Note = strings.TrimSpace(r.FormValue("note")) |
| + d.Note = strings.TrimSpace(strings.ReplaceAll(r.FormValue("note"), "\r\n", "\n")) |
| reject := func(field, msg string) { |
| d.Error, d.ErrorField = msg, field |
| w.WriteHeader(http.StatusUnprocessableEntity) |
diff --git a/internal/calendar/handlers_booking_util.go b/internal/calendar/handlers_booking_util.go| index 720e64d..11ac92d 100644 |
| --- a/internal/calendar/handlers_booking_util.go |
| +++ b/internal/calendar/handlers_booking_util.go |
| @@ -190,3 +190,21 @@ func parseUnix(s string) (int64, bool) { |
| n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64) |
| return n, err == nil && n > 0 |
| } |
| + |
| +func hostNameOf(host Person) string { |
| + if host.Handle != nil { |
| + return *host.Handle |
| + } |
| + return "" |
| +} |
| + |
| +// hasControl reports a control character (a line break, a NUL) in a |
| +// single-line field. |
| +func hasControl(s string) bool { |
| + for _, r := range s { |
| + if r < 0x20 || r == 0x7f { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
diff --git a/internal/calendar/handlers_jobs.go b/internal/calendar/handlers_jobs.go| index 8304aac..e1a0e54 100644 |
| --- a/internal/calendar/handlers_jobs.go |
| +++ b/internal/calendar/handlers_jobs.go |
| @@ -67,9 +67,11 @@ func (h *handlers) drain(ctx context.Context, now time.Time) (int, error) { |
| rctx, rcancel := context.WithDeadline(ctx, now.Add(drainDeadline/2)) |
| sent, err := h.drainReminderQueue(rctx, now, now.Add(drainDeadline/2)) |
| rcancel() |
| - if err != nil { |
| + if err != nil && !errors.Is(err, context.DeadlineExceeded) { |
| return sent, err |
| } |
| + // The reminder phase running out is a handover, not a failure: |
| + // booking mail still gets the second half. |
| mctx, mcancel := context.WithDeadline(ctx, deadline) |
| defer mcancel() |
| mailed, err := h.drainBookingMail(mctx, now, deadline) |
| @@ -110,7 +112,7 @@ func (h *handlers) drainReminderQueue(ctx context.Context, now, deadline time.Ti |
| if errors.Is(err, ErrMailBusy) { |
| // Nothing was tried: the sender is full. Back in the |
| // queue in thirty seconds, attempts untouched. |
| - if err := h.store.DeferReminder(ctx, due.Queue.ID, due.ClaimID, now.Unix()); err != nil { |
| + if _, err := h.store.DeferReminder(keep(ctx), due.Queue.ID, due.ClaimID, now.Unix()); err != nil { |
| return sent, err |
| } |
| continue |
| @@ -120,7 +122,7 @@ func (h *handlers) drainReminderQueue(ctx context.Context, now, deadline time.Ti |
| // permanently undeliverable address being retried forever. |
| h.logger.Error("calendar: send reminder", "stage", "reminders.send", |
| "why", mailCode(err), "attempts", due.Queue.Attempts) |
| - if err := h.store.FailReminder(ctx, due.Queue.ID, due.ClaimID, due.StartUTC); err != nil { |
| + if err := h.store.FailReminder(keep(ctx), due.Queue.ID, due.ClaimID, due.StartUTC); err != nil { |
| return sent, err |
| } |
| continue |
| @@ -128,7 +130,7 @@ func (h *handlers) drainReminderQueue(ctx context.Context, now, deadline time.Ti |
| // Mark and advance in one transaction, conditional on the |
| // claim this worker holds: a reschedule that overtook it finds |
| // this a no-op, which is the whole point of the claim id. |
| - if err := h.store.CompleteReminder(ctx, due, now.Unix()); err != nil { |
| + if err := h.store.CompleteReminder(keep(ctx), due, now.Unix()); err != nil { |
| return sent, err |
| } |
| sent++ |
| @@ -217,7 +219,7 @@ func (h *handlers) DrainOnRequest(ctx context.Context) { |
| } |
| if err := h.sendReminder(ctx, due, now); err != nil { |
| if errors.Is(err, ErrMailBusy) { |
| - _ = h.store.DeferReminder(ctx, due.Queue.ID, due.ClaimID, now.Unix()) |
| + _, _ = h.store.DeferReminder(keep(ctx), due.Queue.ID, due.ClaimID, now.Unix()) |
| return |
| } |
| h.logger.Error("calendar: opportunistic reminder", "stage", "reminders.send", |
| @@ -244,8 +246,19 @@ func (h *handlers) drainBookingMail(ctx context.Context, now, deadline time.Time |
| return sent, nil |
| } |
| if err := h.sendBookingMail(ctx, row, time.Now()); err == nil { |
| - sent++ |
| + sent++ // deferred and retired rows report themselves as errors, and are not sends |
| } |
| } |
| return sent, nil |
| } |
| + |
| +// keep is the bookkeeping context: what a send's outcome is recorded |
| +// under. Detached from the phase deadline — a send that ran out its |
| +// budget still has to be marked failed or deferred, or the lease does |
| +// the same job five minutes later at the cost of a duplicate — and |
| +// bounded on its own so bookkeeping cannot hang either. |
| +func keep(ctx context.Context) context.Context { |
| + k, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) |
| + _ = cancel // released by the timeout; the store calls are short |
| + return k |
| +} |
diff --git a/internal/calendar/mail_bounded.go b/internal/calendar/mail_bounded.go| index a59ac76..1f5f413 100644 |
| --- a/internal/calendar/mail_bounded.go |
| +++ b/internal/calendar/mail_bounded.go |
| @@ -19,6 +19,13 @@ var ErrMailBusy = errors.New("calendar: mail sender busy") |
| // progress and may still arrive; at-least-once holds. |
| var ErrMailTimeout = errors.New("calendar: mail send timed out") |
| +// ErrMailRefused is EVERY other failure of the underlying sender, with |
| +// the sender's own text discarded at this boundary. rastrillo's SMTP |
| +// sender writes the recipient into its error and quotes a refused |
| +// subject; anything above this wrapper — rastrillo's sign-in handler |
| +// included, which logs what it is handed — must never see that. |
| +var ErrMailRefused = errors.New("calendar: mail refused") |
| + |
| // Sender limits, as constants because they are policy. |
| const ( |
| maxSendsInFlight = 4 |
| @@ -75,7 +82,10 @@ func (b *boundedSender) Send(ctx context.Context, to, subject, body string) erro |
| select { |
| case err := <-done: |
| - return err |
| + if err != nil { |
| + return ErrMailRefused |
| + } |
| + return nil |
| case <-time.After(wait): |
| return ErrMailTimeout |
| } |
diff --git a/internal/calendar/store_booking_confirm.go b/internal/calendar/store_booking_confirm.go| index f3150af..264e99d 100644 |
| --- a/internal/calendar/store_booking_confirm.go |
| +++ b/internal/calendar/store_booking_confirm.go |
| @@ -55,6 +55,11 @@ func (s *Store) ConfirmBooking(ctx context.Context, bookingID int64, now time.Ti |
| if err := s.setAttendeesOn(tx, e.ID, []string{addr}); err != nil { |
| return err |
| } |
| + // An owed hold mail is now false: forgotten here, not merely |
| + // skipped later. |
| + if err := deleteUnsentMail(tx, bookingID, MailHold); err != nil { |
| + return err |
| + } |
| return queueBookingMail(tx, bookingID, MailConfirmed, []string{ToHost}, e.Sequence, now) |
| }) |
| if err != nil || !changed { |
| @@ -118,11 +123,16 @@ func (s *Store) RescheduleBooking(ctx context.Context, bookingID, startUTC, endU |
| if err := tx.Exec(`UPDATE booking_pages SET updated_at = updated_at WHERE id = ?`, v.PageID).Error; err != nil { |
| return fmt.Errorf("calendar: take write lock: %w", err) |
| } |
| - // The host as they are NOW, behind the lock: tz below is the |
| - // zone the slot was computed in (B21), so compute it there. |
| - if err := tx.First(&v.Host, v.Host.ID).Error; err != nil { |
| - return fmt.Errorf("calendar: re-read host: %w", err) |
| + // The page, its windows and the host as they are NOW, behind |
| + // the lock: windows the host has since withdrawn cannot be |
| + // moved into, and tz below is the zone the slot was computed |
| + // in (B21), so compute it there. currentPageOn refuses an |
| + // unpublished page, which is the host's way of closing it. |
| + page, host, err := s.currentPageOn(tx, v.PageID) |
| + if err != nil { |
| + return err |
| } |
| + v.Page, v.Host = page, host |
| if loc, err = Zone(v.Host.Zone); err != nil { |
| return err |
| } |
diff --git a/internal/calendar/store_booking_flow.go b/internal/calendar/store_booking_flow.go| index f0fa932..64307a9 100644 |
| --- a/internal/calendar/store_booking_flow.go |
| +++ b/internal/calendar/store_booking_flow.go |
| @@ -155,14 +155,10 @@ var errTokenCollision = errors.New("calendar: token collision") |
| func (s *Store) holdOnce(ctx context.Context, in HoldInput, addrFP string, day, dayEnd, now time.Time) (Booking, error) { |
| token := newToken() |
| - sealed, err := s.sealBooker(in, token) |
| - if err != nil { |
| - return Booking{}, err |
| - } |
| loc, _ := Zone(in.Host.Zone) |
| var b Booking |
| - err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + err := s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| // The write lock, before any read this decision depends on. |
| if err := tx.Exec(`UPDATE booking_pages SET updated_at = updated_at WHERE id = ?`, in.Page.ID).Error; err != nil { |
| return fmt.Errorf("calendar: take write lock: %w", err) |
| @@ -179,6 +175,13 @@ func (s *Store) holdOnce(ctx context.Context, in HoldInput, addrFP string, day, |
| if err != nil { |
| return err |
| } |
| + // The snapshot is sealed from the page AS RE-READ, so the |
| + // booking's title and the event's title come from one version |
| + // of it. |
| + sealed, err := s.sealBooker(in, token) |
| + if err != nil { |
| + return err |
| + } |
| again, err := s.freeSlotsOn(tx, page, host, now, day, dayEnd, 0, 0) |
| if err != nil { |
| return err |
diff --git a/internal/calendar/store_drain.go b/internal/calendar/store_drain.go| index c48aa57..4e09d47 100644 |
| --- a/internal/calendar/store_drain.go |
| +++ b/internal/calendar/store_drain.go |
| @@ -213,14 +213,14 @@ func (s *Store) CompleteReminder(ctx context.Context, due DueReminder, now int64 |
| // DeferReminder puts a row back without counting an attempt: the sender |
| // was busy and nothing was tried. retry_at, never due_at — due_at IS |
| // the meeting time minus the offset. |
| -func (s *Store) DeferReminder(ctx context.Context, queueID int64, claimID string, now int64) error { |
| - err := s.g.WithContext(ctx).Model(&ReminderQueue{}). |
| +func (s *Store) DeferReminder(ctx context.Context, queueID int64, claimID string, now int64) (ok bool, err error) { |
| + res := s.g.WithContext(ctx).Model(&ReminderQueue{}). |
| Where("id = ? AND claim_id = ?", queueID, claimID). |
| - Updates(map[string]any{"retry_at": now + int64(deferFor.Seconds()), "claimed_at": nil, "claim_id": nil}).Error |
| - if err != nil { |
| - return fmt.Errorf("calendar: defer reminder: %w", err) |
| + Updates(map[string]any{"retry_at": now + int64(deferFor.Seconds()), "claimed_at": nil, "claim_id": nil}) |
| + if res.Error != nil { |
| + return false, fmt.Errorf("calendar: defer reminder: %w", res.Error) |
| } |
| - return nil |
| + return res.RowsAffected == 1, nil |
| } |
| // MaxReminderAttempts is when a reminder stops being retried. |
diff --git a/internal/calendar/templates/event_form.html b/internal/calendar/templates/event_form.html| index 3844def..6996775 100644 |
| --- a/internal/calendar/templates/event_form.html |
| +++ b/internal/calendar/templates/event_form.html |
| @@ -53,7 +53,13 @@ |
| <input rst-input id="start-locked" type="datetime-local" value="{{.Data.StartLocal}}" disabled> |
| <label rst-field-label for="end-locked">Ends</label> |
| <input rst-input id="end-locked" type="datetime-local" value="{{.Data.EndLocal}}" disabled> |
| - <p cal-booked-when><small>{{.Data.TZ}} · {{.Data.Kind}}</small></p> |
| + <label rst-field-label for="tz-locked">Zone</label> |
| + <input rst-input="short" id="tz-locked" type="text" value="{{.Data.TZ}}" disabled> |
| + <label rst-field-label for="kind-locked">Kind</label> |
| + <input rst-input="short" id="kind-locked" type="text" value="{{.Data.Kind}}" disabled> |
| + <label rst-switch><input type="checkbox" disabled{{if .Data.AllDay}} checked{{end}}><span rst-switch-track aria-hidden="true"></span> All day</label> |
| + <label rst-field-label for="repeat-locked">Repeats</label> |
| + <input rst-input="short" id="repeat-locked" type="text" value="Never" disabled> |
| <small rst-field-hint>This is a booked meeting{{with .Data.BookedBy}} with {{.}}{{end}}. Its time is changed by the person who booked it, or by cancelling and letting them rebook.</small> |
| {{with (if_eq .Data.ErrorField "start" .Data.Error)}}<small rst-field-error>{{.}}</small>{{end}} |
| </div> |
diff --git a/internal/calendartest/booking_flow_test.go b/internal/calendartest/booking_flow_test.go| index 3f1ed0e..de75fbf 100644 |
| --- a/internal/calendartest/booking_flow_test.go |
| +++ b/internal/calendartest/booking_flow_test.go |
| @@ -191,8 +191,10 @@ func TestConfirmIsAConditionalUpdateWithGuards(t *testing.T) { |
| if att, _ := st.AttendeesFor(ctx, b.EventID); len(att) != 1 || att[0] != "doc@tito.io" { |
| t.Errorf("attendees = %v", att) |
| } |
| + // The hold mail still owed is FORGOTTEN by the confirmation, in its |
| + // own transaction: what remains is the one true row. |
| owed := st.UnsentBookingMailForTest(b.ID) |
| - if len(owed) != 2 || owed[1].Kind != calendar.MailConfirmed || owed[1].Recipient != calendar.ToHost || owed[1].Revision != 1 { |
| + if len(owed) != 1 || owed[0].Kind != calendar.MailConfirmed || owed[0].Recipient != calendar.ToHost || owed[0].Revision != 1 { |
| t.Errorf("owed = %+v", owed) |
| } |
| if rems, _ := st.RemindersFor(ctx, host.ID, b.EventID); len(rems) != 1 { |
diff --git a/internal/calendartest/booking_review2_test.go b/internal/calendartest/booking_review2_test.go| new file mode 100644 |
| index 0000000..567041b |
| --- /dev/null |
| +++ b/internal/calendartest/booking_review2_test.go |
| @@ -0,0 +1,202 @@ |
| +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 := firstDaySlots(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) |
| + late := slots[len(slots)-1] // 16:30 on the third day: outside 9-10 |
| + |
| + 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 := firstDaySlots(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) |
| + } |
| +} |
diff --git a/internal/calendartest/occurrence_strict_test.go b/internal/calendartest/occurrence_strict_test.go| index ada0eb2..d125077 100644 |
| --- a/internal/calendartest/occurrence_strict_test.go |
| +++ b/internal/calendartest/occurrence_strict_test.go |
| @@ -92,10 +92,18 @@ func TestTheUntilBoundHasADayOfSlack(t *testing.T) { |
| "title": {"Night shift"}, "start": {"2026-10-23T00:00"}, "end": {"2026-10-23T04:00"}, |
| "kind": {"commitment"}, "status": {"confirmed"}, "repeat": {"daily"}, "repeat_end": {"2026-10-25"}, |
| }) |
| - // The final occurrence starts 00:00 IST on the 25th and, across the |
| - // fold, runs FIVE elapsed hours to 04:00 GMT. Query only its fifth |
| - // hour: a master-duration bound (four hours) would have excluded the |
| - // master here; the day of slack keeps it. |
| + // The final occurrence starts 00:00 IST on the 25th — 23:00Z on the |
| + // 24th — and, across the fold, runs FIVE elapsed hours to 04:00 GMT. |
| + // A bare-date repeat_end indexes UNTIL at the END of that day, which |
| + // would pass without any slack, so pin UNTIL to the final START as |
| + // an importer would write it, then query only the fifth hour: a |
| + // master-duration bound (four hours from 23:00Z is 03:00Z) excludes |
| + // the master; the day of slack keeps it. |
| + until := time.Date(2026, 10, 24, 23, 0, 0, 0, time.UTC) |
| + if _, err := app.DBForTest().Exec(`UPDATE events SET rrule = ?, until_utc = ? WHERE rrule <> ''`, |
| + "FREQ=DAILY;UNTIL=20261024T230000Z", until.Unix()); err != nil { |
| + t.Fatal(err) |
| + } |
| host := app.PersonByAddressForTest(t, "someone@example.com") |
| dublin, _ := time.LoadLocation("Europe/Dublin") |
| from := time.Date(2026, 10, 25, 3, 30, 0, 0, dublin).Unix() |