oficina / calendar Public

booking: holds expire on the tick, owed mail is retried, reminders keep their half, and a stranger with no script can book and confirm

The drain is one pass in one deadline, cheap work first: expire holds,
forget day-old IP fingerprints, then reminders until half the budget is
gone, then booking mail with what is left. A page view does exactly
one thing. The one-rule test gains a booker: name, address, note, the
page's text and the management token are grepped from the raw bytes
after a checkpoint. A failed send leaves a stage and a code in the log
and nothing else.

make browser gains the stranger's whole journey with JavaScript OFF:
days, slot, name, address, the check-your-mail page, the mailed link,
ONE button, Reschedule. And the host's pane with script on. Two node
invocations, because the link between them lives in a mail only the Go
side can read.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 456b28eb17634ea3f70961b73c6bc65dc6b8022c parent 23e951d
8 files changed, +433 −14
  • Makefile +1 −1
  • internal/calendar/handlers_jobs.go +67 −10
  • internal/calendartest/booking_http_test.go +7 −2
  • internal/calendartest/booking_sweep_test.go +124 −0
  • internal/calendartest/events_test.go +25 −0
  • internal/calendartest/playwright_booking_test.go +93 −0
  • package.json +1 −1
  • test/browser/booking.mjs +115 −0
diff --git a/Makefile b/Makefile
index 68c94d6..86ae38b 100644
--- a/Makefile
+++ b/Makefile
@@ -117,4 +117,4 @@ ci: vet fmt-check test migration-check
# change to the box.
browser:
npm install --no-save playwright
- go test -tags browser -run TestQuickAddInARealBrowser ./internal/calendartest/
+ go test -tags browser -run InARealBrowser ./internal/calendartest/
diff --git a/internal/calendar/handlers_jobs.go b/internal/calendar/handlers_jobs.go
index 3f42c14..04cede7 100644
--- a/internal/calendar/handlers_jobs.go
+++ b/internal/calendar/handlers_jobs.go
@@ -44,15 +44,40 @@ func (h *handlers) drainReminders(w http.ResponseWriter, r *http.Request) {
// wake takes it.
const drainDeadline = 45 * time.Second
-// drain sends every reminder that has come due.
+// drain is one pass of everything scheduled, in order, inside one
+// deadline: the cheap booking sweeps first, then reminders until half
+// the budget is gone, then booking mail with what is left (booking
+// spec §8). Reminders get the first half so a queue of booking mail
+// behind a slow relay cannot starve them.
+func (h *handlers) drain(ctx context.Context, now time.Time) (int, error) {
+ deadline := now.Add(drainDeadline)
+ if n, err := h.store.ExpireHolds(ctx, now, 200); err != nil {
+ return 0, err
+ } else if n > 0 {
+ h.logger.Info("calendar: holds expired", "n", n)
+ }
+ if n, err := h.store.ForgetOldIPs(ctx, now, 500); err != nil {
+ return 0, err
+ } else if n > 0 {
+ h.logger.Info("calendar: ip fingerprints forgotten", "n", n)
+ }
+ sent, err := h.drainReminderQueue(ctx, now, now.Add(drainDeadline/2))
+ if err != nil {
+ return sent, err
+ }
+ mailed, err := h.drainBookingMail(ctx, now, deadline)
+ return sent + mailed, err
+}
+
+// drainReminderQueue sends every reminder that has come due, until the
+// deadline.
//
// Delivery is AT LEAST ONCE, deliberately. A reminder that never
// arrives is worse than one that arrives twice, so every ambiguous case
// resolves towards sending: a claim that expired, a process killed
// between the send and the mark, a tick delivered twice. The
// occurrence key is what keeps "twice" rare rather than routine.
-func (h *handlers) drain(ctx context.Context, now time.Time) (int, error) {
- deadline := now.Add(drainDeadline)
+func (h *handlers) drainReminderQueue(ctx context.Context, now, deadline time.Time) (int, error) {
sent := 0
seen := map[string]bool{}
for time.Now().Before(deadline) {
@@ -155,22 +180,34 @@ func (h *handlers) drainOpportunistically(next http.Handler) http.Handler {
})
}
-// DrainOnRequest sends at most one overdue reminder.
+// DrainOnRequest does ONE thing: expires one lapsed hold if there is
+// one, else sends one overdue reminder, else one owed booking mail.
//
-// Three ways a reminder gets sent, and the redundancy is the point:
+// Three ways scheduled work gets done, and the redundancy is the point:
// the platform's own wake from NextDue, the declared schedule's tick,
// and this. If the first two are misconfigured the app still reminds
// people, just less punctually, rather than silently never reminding
// anybody.
//
-// Bounded to a single reminder per request so that loading a page never
-// becomes slow because a queue got long.
+// Bounded to a single item and five seconds so that loading a page
+// never becomes slow because a queue got long.
func (h *handlers) DrainOnRequest(ctx context.Context) {
- due, ok, err := h.store.ClaimDueReminder(ctx, time.Now().Unix())
- if err != nil || !ok {
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ now := time.Now()
+ if n, err := h.store.ExpireHolds(ctx, now, 1); err != nil || n > 0 {
+ return
+ }
+ due, ok, err := h.store.ClaimDueReminder(ctx, now.Unix())
+ if err != nil {
+ return
+ }
+ if !ok {
+ if row, ok, err := h.store.ClaimBookingMail(ctx, now.Unix()); err == nil && ok {
+ _ = h.sendBookingMail(ctx, row, now)
+ }
return
}
- now := time.Now()
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())
@@ -185,3 +222,23 @@ func (h *handlers) DrainOnRequest(ctx context.Context) {
h.logger.Error("calendar: mark reminder sent", "stage", "reminders.mark", "err", scrubErr(err))
}
}
+
+// drainBookingMail sends every owed booking mail that is claimable,
+// until the deadline. Each send is bounded by the sender; each outcome
+// — sent, deferred, failed — is the row's own.
+func (h *handlers) drainBookingMail(ctx context.Context, now, deadline time.Time) (int, error) {
+ sent := 0
+ for time.Now().Before(deadline) {
+ row, ok, err := h.store.ClaimBookingMail(ctx, time.Now().Unix())
+ if err != nil {
+ return sent, err
+ }
+ if !ok {
+ return sent, nil
+ }
+ if err := h.sendBookingMail(ctx, row, time.Now()); err == nil {
+ sent++
+ }
+ }
+ return sent, nil
+}
diff --git a/internal/calendartest/booking_http_test.go b/internal/calendartest/booking_http_test.go
index d5114ab..6d37680 100644
--- a/internal/calendartest/booking_http_test.go
+++ b/internal/calendartest/booking_http_test.go
@@ -19,6 +19,11 @@ import (
// publishedPage is a host with a handle and a published every-day 9–5
// page, seen through a picky mailer so the tests can read what went out.
func publishedPage(t *testing.T) (*calendar.App, *pickyMailer, string, calendar.Person) {
+ t.Helper()
+ return publishedPageTitled(t, "Quick chat", "Half an hour.", "every day 9-5")
+}
+
+func publishedPageTitled(t *testing.T, title, desc, avail string) (*calendar.App, *pickyMailer, string, calendar.Person) {
t.Helper()
mailer := &pickyMailer{}
app := newAppWithMailer(t, mailer)
@@ -26,8 +31,8 @@ func publishedPage(t *testing.T) (*calendar.App, *pickyMailer, string, calendar.
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"}})
- rec := postAs(t, h, session, "/settings/booking", url.Values{"title": {"Quick chat"}, "description": {"Half an hour."},
- "availability": {"every day 9-5"}, "duration_min": {"30"}, "buffer_min": {"0"}, "notice_min": {"0"}, "horizon_days": {"30"}})
+ rec := postAs(t, h, session, "/settings/booking", url.Values{"title": {title}, "description": {desc},
+ "availability": {avail}, "duration_min": {"30"}, "buffer_min": {"0"}, "notice_min": {"0"}, "horizon_days": {"30"}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("create page: %d\n%s", rec.Code, rec.Body.String())
}
diff --git a/internal/calendartest/booking_sweep_test.go b/internal/calendartest/booking_sweep_test.go
new file mode 100644
index 0000000..b638e97
--- /dev/null
+++ b/internal/calendartest/booking_sweep_test.go
@@ -0,0 +1,124 @@
+package calendartest
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+// The sweep through the router (booking spec §8), and the two rules
+// about what must never reach a log.
+
+func TestTheTickExpiresHoldsAndWakesForThem(t *testing.T) {
+ t.Setenv("CARLOS_ADMIN_TOKEN", "test-token")
+ app, _, path, _ := publishedPage(t)
+ h := app.Handler()
+ _, starts := firstDaySlots(t, h, path)
+ strangerPost(t, h, path, holdForm(starts[0], "A", "a@example.com"))
+ if _, err := app.DBForTest().Exec(`UPDATE bookings SET hold_expires_at = ?`, time.Now().Add(-time.Minute).Unix()); err != nil {
+ t.Fatal(err)
+ }
+ if !app.NextDue().Before(time.Now()) {
+ t.Error("NextDue does not see the expired hold")
+ }
+ if rec := tick(t, h); rec.Code != http.StatusNoContent {
+ t.Fatalf("tick: %d", rec.Code)
+ }
+ if again := strangerPost(t, h, path, holdForm(starts[0], "B", "b@example.com")); again.Code != 200 {
+ t.Errorf("the slot did not come back after the sweep: %d", again.Code)
+ }
+}
+
+func TestTheTickRetriesOwedMailAndDrainOnRequestDoesOneThing(t *testing.T) {
+ t.Setenv("CARLOS_ADMIN_TOKEN", "test-token")
+ app, mailer, path, _ := publishedPage(t)
+ h := app.Handler()
+ _, starts := firstDaySlots(t, h, path)
+ strangerPost(t, h, path, holdForm(starts[0], "Doc", "doc@tito.io"))
+ link := reManage.FindString(mailer.body(0))
+ // The host's mailbox bounces for now.
+ mailer.refuse = "host@example.com"
+ strangerPost(t, h, link+"/confirm", nil)
+ if mailer.count() != 1 {
+ t.Fatalf("mails after a refused confirm = %d, want the hold mail only", mailer.count())
+ }
+ if !app.NextDue().Before(time.Now().Add(6 * time.Minute)) {
+ t.Error("an owed mail is not on NextDue")
+ }
+ // Age the row past its lease and let the tick retry it, now that
+ // the mailbox is back.
+ mailer.refuse = ""
+ app.DBForTest().Exec(`UPDATE booking_mails SET due_at = ? WHERE sent_at IS NULL`, time.Now().Add(-time.Second).Unix())
+ if rec := tick(t, h); rec.Code != http.StatusNoContent {
+ t.Fatalf("tick: %d", rec.Code)
+ }
+ if mailer.count() != 2 || mailer.to(1) != "host@example.com" {
+ t.Errorf("the tick did not deliver the owed host mail: %d to %q", mailer.count(), mailer.to(1))
+ }
+
+ // DrainOnRequest: a signed-in page view does one thing. Queue two
+ // mails (a reschedule), refuse nothing, age them, load one page.
+ session := app.SignInForTest(t, "host@example.com")
+ picker := strangerGet(t, h, link+"/reschedule?day="+time.Now().UTC().AddDate(0, 0, 1).Format("2006-01-02")+"&zone=UTC").Body.String()
+ rev := regexp.MustCompile(`name="rev" value="(\d+)"`).FindStringSubmatch(picker)
+ next := reStart.FindAllStringSubmatch(picker, -1)
+ mailer.refuse = "nobody-yet" // stop the immediate try from sending
+ mailer.refuse = "@" // refuse everything for the immediate try
+ strangerPost(t, h, link+"/reschedule", url.Values{"start": {next[1][1]}, "zone": {"UTC"}, "rev": {rev[1]}})
+ mailer.refuse = ""
+ before := mailer.count()
+ app.DBForTest().Exec(`UPDATE booking_mails SET due_at = ? WHERE sent_at IS NULL`, time.Now().Add(-time.Second).Unix())
+ getAs(t, h, session, "/today")
+ if mailer.count() != before+1 {
+ t.Errorf("a page view sent %d mails, want exactly one", mailer.count()-before)
+ }
+}
+
+// No address, no token, no title, in any log line, after a failed send.
+func TestNothingSealedReachesTheLog(t *testing.T) {
+ var buf bytes.Buffer
+ mailer := &pickyMailer{refuse: "dermot"}
+ app, err := calendar.New(calendar.Config{
+ DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: testOrigin,
+ Mailer: mailer, Logger: slog.New(slog.NewTextHandler(&buf, 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": {"Interrogation"}, "availability": {"every day 9-5"},
+ "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 := regexp.MustCompile(`/book/paul/[a-z0-9]{8}`).FindString(getAs(t, h, session, "/settings/booking").Body.String())
+ _, starts := firstDaySlots(t, h, path)
+ strangerPost(t, h, path, holdForm(starts[0], "Dermot Lacey", "dermot@elsewhere.example"))
+
+ log := buf.String()
+ if !strings.Contains(log, "booking.mail") {
+ t.Fatalf("the failed send was not logged at all:\n%s", log)
+ }
+ for _, never := range []string{"dermot", "Dermot", "Interrogation"} {
+ if strings.Contains(log, never) {
+ t.Errorf("the log carries %q:\n%s", never, log)
+ }
+ }
+ if regexp.MustCompile(`[0-9a-f]{64}`).MatchString(log) {
+ t.Error("the log carries something shaped like a token")
+ }
+ _ = io.Discard
+ _ = context.Background
+}
diff --git a/internal/calendartest/events_test.go b/internal/calendartest/events_test.go
index 5845f6b..02208ce 100644
--- a/internal/calendartest/events_test.go
+++ b/internal/calendartest/events_test.go
@@ -106,6 +106,31 @@ func TestTheDatabaseNeverHoldsAPlaintextTitle(t *testing.T) {
}
}
+// The same rule for a booking: the page's text, the booker's name,
+// address and note, and the management token are all content (booking
+// spec §2.4, §9). A stranger's identity is who this team MEETS WITH,
+// which is exactly what the bucket must not give up.
+func TestTheDatabaseNeverHoldsAPlaintextBooker(t *testing.T) {
+ app, mailer, path, _ := publishedPageTitled(t, "Interrogation about the acquisition", "Bring nothing", "mondays 12-3, every day 9-5")
+ h := app.Handler()
+ _, starts := firstDaySlots(t, h, path)
+ form := holdForm(starts[0], "Dermot Lacey", "dermot.lacey@elsewhere.example")
+ form.Set("note", "The folder")
+ if rec := strangerPost(t, h, path, form); rec.Code != 200 {
+ t.Fatalf("hold: %d", rec.Code)
+ }
+ token := strings.TrimPrefix(reManage.FindString(mailer.body(0)), "/book/m/")
+ if _, err := app.DBForTest().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
+ t.Fatalf("checkpoint: %v", err)
+ }
+ for _, needle := range []string{"Interrogation about the acquisition", "Bring nothing", "mondays 12-3",
+ "Dermot Lacey", "dermot.lacey@elsewhere.example", "The folder", token} {
+ if found, where := app.GrepDatabaseForTest(t, needle); found {
+ t.Errorf("%q appears in plaintext in %s", needle, where)
+ }
+ }
+}
+
// A row that isn't yours doesn't exist — 404, never 403, so no id can
// be enumerated by watching which ones answer differently.
func TestAnotherPersonsEventIsNotFound(t *testing.T) {
diff --git a/internal/calendartest/playwright_booking_test.go b/internal/calendartest/playwright_booking_test.go
new file mode 100644
index 0000000..58c63c8
--- /dev/null
+++ b/internal/calendartest/playwright_booking_test.go
@@ -0,0 +1,93 @@
+//go:build browser
+
+package calendartest
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "testing"
+ "time"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+// TestBookingInARealBrowser drives the stranger's whole journey in
+// Chromium with JavaScript OFF — the only proof the scriptless floor
+// is real — and the host's availability pane with it on.
+//
+// The hold and the confirm are two node invocations because the link
+// between them is in a mail only the Go side can read.
+func TestBookingInARealBrowser(t *testing.T) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ origin := "http://" + ln.Addr().String()
+
+ app, err := calendar.New(calendar.Config{
+ DBPath: filepath.Join(t.TempDir(), "calendar.db"),
+ Env: "development",
+ Origin: origin,
+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ })
+ if err != nil {
+ t.Fatalf("calendar.New: %v", err)
+ }
+ t.Cleanup(func() { app.Close() })
+ srv := &http.Server{Handler: app.Handler()}
+ go func() { _ = srv.Serve(ln) }()
+ t.Cleanup(func() { _ = srv.Close() })
+
+ session := signInOverHTTP(t, app, origin, "host@example.com")
+
+ // The host, the handle and a published page, through the store —
+ // the browser passes are about the stranger's side.
+ ctx := context.Background()
+ st := app.StoreForTest()
+ host := app.PersonByAddressForTest(t, "host@example.com")
+ if err := st.SaveHandle(ctx, host.ID, "paul"); err != nil {
+ t.Fatal(err)
+ }
+ ws, _ := calendar.ParseAvailability("every day 9-5")
+ page, err := st.CreateBookingPage(ctx, host.ID, calendar.BookingPageInput{
+ Title: "Quick chat", Description: "Half an hour.", Availability: "every day 9-5",
+ DurationMin: 30, HorizonDays: 30}, ws)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := st.PublishBookingPage(ctx, host.ID, page.ID, true); err != nil {
+ t.Fatal(err)
+ }
+ public := "/book/paul/" + page.Code
+
+ node := func(args ...string) {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "node", append([]string{"test/browser/booking.mjs"}, args...)...)
+ cmd.Dir = repoRootForBrowser(t)
+ out, err := cmd.CombinedOutput()
+ t.Logf("node %s:\n%s", args[0], out)
+ if err != nil {
+ t.Fatalf("the browser checks failed: %v", err)
+ }
+ }
+
+ node("hold", origin, public)
+ body, err := app.LastMailForTest()
+ if err != nil {
+ t.Fatal(err)
+ }
+ link := regexp.MustCompile(`/book/m/[0-9a-f]{64}`).FindString(body)
+ if link == "" {
+ t.Fatalf("no manage link in the hold mail:\n%s", body)
+ }
+ node("confirm", origin, link)
+ node("pane", origin, session.Name, session.Value)
+}
diff --git a/package.json b/package.json
index 979c352..a0221ad 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"description": "The JS test harness. No part of the app is built from here — the app is one Go binary, and this exists only to drive a real browser at it.",
"type": "module",
"scripts": {
- "test:browser": "go test -tags browser -run TestQuickAddInARealBrowser ./internal/calendartest/"
+ "test:browser": "go test -tags browser -run InARealBrowser ./internal/calendartest/"
},
"devDependencies": {
"playwright": "1.62.1"
diff --git a/test/browser/booking.mjs b/test/browser/booking.mjs
new file mode 100644
index 0000000..9f72073
--- /dev/null
+++ b/test/browser/booking.mjs
@@ -0,0 +1,115 @@
+// booking.mjs — a stranger books, in a real browser, with NO SCRIPT.
+//
+// Driven by TestBookingInARealBrowser, which starts the app, publishes a
+// page, and calls this three times:
+//
+// hold <origin> <publicPath> JS off, no cookie: pick a day,
+// a slot, type a name and an
+// address, read "Check your mail".
+// confirm <origin> <manageLink> JS off, no cookie: open the
+// mailed link, see ONE button,
+// press it, see Reschedule.
+// pane <origin> <cookieName> <cookieValue>
+// JS on, signed in: type an
+// availability sentence and watch
+// the pane fill in from the server.
+//
+// Go holds the mail recorder, which is why the hold and the confirm are
+// separate invocations: only the Go side can read the link out of the
+// mail that the hold sent.
+import { chromium } from 'playwright';
+
+const [mode, origin, a, b] = process.argv.slice(2);
+if (!mode || !origin) {
+ console.error('usage: booking.mjs hold|confirm|pane <origin> ...');
+ process.exit(2);
+}
+
+const failures = [];
+function check(ok, what) {
+ if (ok) return;
+ failures.push(what);
+ console.error('FAIL: ' + what);
+}
+
+const browser = await chromium.launch();
+
+async function contextWith(javaScriptEnabled, cookie) {
+ const ctx = await browser.newContext({ javaScriptEnabled });
+ if (cookie) {
+ await ctx.addCookies([{ name: cookie.name, value: cookie.value, url: origin, httpOnly: true }]);
+ }
+ return ctx;
+}
+
+if (mode === 'hold') {
+ const ctx = await contextWith(false);
+ const page = await ctx.newPage();
+ await page.goto(origin + a);
+ check(await page.locator('[cal-book-days] a').count() > 0, 'the page lists days with free slots');
+
+ await page.locator('[cal-book-days] a').first().click();
+ const slots = page.locator('[cal-book-slot]');
+ check(await slots.count() > 0, 'the day page lists slots');
+
+ // The slot's painted size: a button the size of nothing is a button
+ // nobody can press.
+ const rect = await slots.first().boundingBox();
+ check(rect !== null && rect.width > 30 && rect.height > 20,
+ 'a slot button has real painted size (got ' + JSON.stringify(rect) + ')');
+
+ await slots.first().click();
+ check(await page.locator('[cal-book-details]').isVisible(), 'picking a slot shows the details form');
+
+ await page.fill('#name', 'Doc');
+ await page.fill('#address', 'doc@tito.io');
+ await page.fill('#note', 'From a real browser, with no script.');
+ await Promise.all([
+ page.waitForNavigation(),
+ page.locator('[cal-book-details] button[type=submit]').click(),
+ ]);
+ const body = await page.locator('body').innerText();
+ check(body.includes('Check your mail'), 'the hold lands on "Check your mail"');
+ check(body.includes('doc@tito.io'), 'the page echoes the address the mail went to');
+ await ctx.close();
+}
+
+if (mode === 'confirm') {
+ const ctx = await contextWith(false);
+ const page = await ctx.newPage();
+ await page.goto(origin + a);
+ // The page's own forms, not the shell's theme switcher: exactly one
+ // POST to /confirm, and no other way to change anything.
+ const buttons = page.locator('form[action$="/confirm"] button[type=submit]');
+ check(await buttons.count() === 1, 'the mailed link shows exactly one confirm button');
+ check((await buttons.first().innerText()).includes('Confirm'), 'and it says Confirm');
+ check(await page.locator('form[action*="/book/m/"]').count() === 1,
+ 'the hold page has no other booking form — nothing else can change on a GET');
+
+ await Promise.all([page.waitForNavigation(), buttons.first().click()]);
+ const body = await page.locator('body').innerText();
+ check(body.includes('Reschedule'), 'pressing Confirm lands on the manage page');
+ check(body.includes('Add to calendar'), 'the manage page offers the ICS');
+ await ctx.close();
+}
+
+if (mode === 'pane') {
+ const ctx = await contextWith(true, { name: a, value: b });
+ const page = await ctx.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(String(e)));
+ await page.goto(origin + '/settings/booking/new', { waitUntil: 'networkidle' });
+ await page.fill('[cal-book-text]', 'tues 12-2, 5-7');
+ await page.locator('[cal-book-pane]').getByText('Tuesday 12:00').waitFor({ timeout: 5000 });
+ const pane = await page.locator('[cal-book-pane]').innerText();
+ check(pane.includes('Tuesday 12:00–14:00, 17:00–19:00'), 'the pane describes both windows (got ' + pane + ')');
+ check(errors.length === 0, 'no page errors (' + errors.join('; ') + ')');
+ await ctx.close();
+}
+
+await browser.close();
+if (failures.length > 0) {
+ console.error(failures.length + ' check(s) failed');
+ process.exit(1);
+}
+console.log('booking ' + mode + ': all checks passed');