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>
8 files changed,
+433
−14
Makefile+1 −1internal/calendar/handlers_jobs.go+67 −10internal/calendartest/booking_http_test.go+7 −2internal/calendartest/booking_sweep_test.go+124 −0internal/calendartest/events_test.go+25 −0internal/calendartest/playwright_booking_test.go+93 −0package.json+1 −1test/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'); |