calcomblock/field_routing_test.go
Alex Dunmow 18b7825592 Extract calcomblock into a standalone wasm plugin repo
Convert the bundled backend/internal/plugins/calcomblock package into a
standalone reactor-mode wasm plugin, dropping every git.dev.alexdunmow.com/block/cms
import (forbidden in standalone plugins):

- package calcomblock -> package main; add wasip1 main.go with
  wasmguest.Serve(Registration). go.mod module
  git.dev.alexdunmow.com/block/calcomblock, block/core v0.18.2, no replace
  directives.
- Settings persistence: replace the pool-backed poolQuerier (direct SQL against
  the public-schema `settings` table, which a sandboxed plugin role cannot read)
  with capabilityQuerier over the SDK settings.Settings / settings.Updater
  capabilities. GetSiteTimezone now resolves via GetSiteSettings host-side.
- Vendor the small CMS-internal helpers the plugin used into internal/helpers
  (GetRealIP, StartCleanupLoop, MaskSecret, GetStringOr, the PluginSettingsQuerier
  settings helpers, PluginCrypto for tests) and internal/db (minimal Setting /
  UpsertSettingParams), each with a provenance header.
- Inline the captcha widget: vendor blocks.CaptchaWidget as a local
  CaptchaWidget templ (captcha_widget.templ) and regenerate templ; drop the
  block/cms/blocks import from booking.templ.
- blockConfig (server-authoritative captcha requirement via a
  page_block_snapshots scan) has no wasm-ABI capability, so it is left nil:
  honeypot + per-IP rate limit still apply. Documented as a follow-up.
- web: @block-ninja/ui workspace:* -> ^0.1.0 registry version; eslint.config.js
  repointed at ../../../cms/web/eslint.config.js; rebuild web/dist.
- Rewrite CLAUDE.md for the standalone wasm reality; add .gitignore.

Full test suite preserved and passing; builds to calcomblock-2.0.0.bnp;
check-safety passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:04:18 +08:00

542 lines
21 KiB
Go

package main
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// Cal.com field-taxonomy regressions
//
// Cal.com event types return a mix of SYSTEM booking fields (name, email,
// attendeePhoneNumber, location, notes, guests, title, rescheduleReason) and
// CUSTOM fields. Each system field has a fixed slug + type + `editable` value,
// and reschedule-only fields carry views:[{id:"reschedule"}] (see Cal.com
// getBookingFields.ts). The widget must:
// - NOT render reschedule-only fields on a NEW booking (rescheduleReason)
// - route each field to its correct slot in POST /v2/bookings:
// phone-typed -> attendee.phoneNumber (E.164-normalized)
// guests -> top-level guests[] array
// custom -> bookingFieldsResponses (custom-only per the v2 schema)
// - never emit empty-string responses (Cal.com rejects empty/mistyped values)
// These tests pin that contract.
// ---------------------------------------------------------------------------
// TestHandleGetForm_DropsRescheduleOnlyFields asserts the booking form does not
// render fields whose Cal.com `views` restricts them to the reschedule flow
// (rescheduleReason). Genuine custom fields (birthday) must still render.
func TestHandleGetForm_DropsRescheduleOnlyFields(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{
"status":"success",
"data":[{
"id":7,"slug":"intake","title":"Intake","lengthInMinutes":15,
"bookingFields":[
{"slug":"name","type":"name","editable":"system","required":true},
{"slug":"email","type":"email","editable":"system-but-optional","required":true},
{"slug":"rescheduleReason","type":"textarea","editable":"system-but-optional","required":false,"views":[{"id":"reschedule","label":"Reschedule"}]},
{"slug":"birthday","type":"text","label":"Birthday","editable":"user","required":false}
]
}]
}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
req := httptest.NewRequest(http.MethodGet,
"/form?blockId=b1&start=2026-06-01T10:00:00Z&date=2026-06-01&username=alice&eventType=intake&timezone=UTC", nil)
rec := httptest.NewRecorder()
h.HandleGetForm(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// rescheduleReason is reschedule-only — must NOT appear on a new booking.
if strings.Contains(body, `name="rescheduleReason"`) {
t.Errorf("reschedule-only field rescheduleReason rendered on a new booking form; body: %q", body)
}
if strings.Contains(body, "Reason for reschedule") {
t.Errorf("reschedule-only label 'Reason for reschedule' rendered on a new booking form; body: %q", body)
}
// Genuine custom field must still render.
if !strings.Contains(body, `name="birthday"`) {
t.Errorf("expected custom field name=\"birthday\" in form, got: %q", body)
}
}
// TestHandleGetForm_RendersPhoneCountryCombobox asserts a Cal.com phone field
// renders the searchable country combobox with the detected country (AU, from
// the Australia/Perth timezone) pre-selected, alongside the tel number input.
func TestHandleGetForm_RendersPhoneCountryCombobox(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{
"status":"success",
"data":[{
"id":7,"slug":"intake","title":"Intake","lengthInMinutes":15,
"bookingFields":[
{"slug":"name","type":"name","required":true},
{"slug":"attendeePhoneNumber","type":"phone","required":true}
]
}]
}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
req := httptest.NewRequest(http.MethodGet,
"/form?blockId=b1&start=2026-06-01T10:00:00Z&date=2026-06-01&username=alice&eventType=intake&timezone=Australia/Perth&locale=en-AU", nil)
rec := httptest.NewRecorder()
h.HandleGetForm(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{
"data-cc-combobox",
`name="phoneCountry"`,
`value="AU"`, // detected default pre-selected in the hidden input
"🇦🇺 Australia (+61)", // default search label / AU option
`data-code="US"`, // other countries present in the list
`type="tel"`, // the national-number input
`name="attendeePhoneNumber"`,
} {
if !strings.Contains(body, want) {
t.Errorf("phone combobox missing %q in rendered form", want)
}
}
}
// TestHandleGetForm_DropsSystemFieldsBySlug asserts system fields with no valid
// new-booking home — location (radioInput) and rescheduleReason even WITHOUT a
// views flag — are not rendered (their submitted values would be discarded).
// Genuine custom fields still render.
func TestHandleGetForm_DropsSystemFieldsBySlug(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{
"status":"success",
"data":[{
"id":7,"slug":"intake","title":"Intake","lengthInMinutes":15,
"bookingFields":[
{"slug":"name","type":"name","required":true},
{"slug":"location","type":"radioInput","editable":"system","required":false},
{"slug":"rescheduleReason","type":"textarea","editable":"system-but-optional","required":false},
{"slug":"birthday","type":"text","label":"Birthday","editable":"user","required":false}
]
}]
}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
req := httptest.NewRequest(http.MethodGet,
"/form?blockId=b1&start=2026-06-01T10:00:00Z&date=2026-06-01&username=alice&eventType=intake&timezone=UTC", nil)
rec := httptest.NewRecorder()
h.HandleGetForm(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if strings.Contains(body, `name="location"`) {
t.Errorf("location field must not render (its value is dropped on submit); body: %q", body)
}
if strings.Contains(body, `name="rescheduleReason"`) {
t.Errorf("rescheduleReason must not render even without a views flag; body: %q", body)
}
if !strings.Contains(body, `name="birthday"`) {
t.Errorf("expected custom field name=\"birthday\" to still render")
}
}
// TestHandleCreateBooking_EmptySystemFieldsNotSent asserts the real-world
// failure case: the visitor fills name/email but leaves the optional system
// fields (location, notes, guests, rescheduleReason, title) empty. None of
// those empties may reach Cal.com — Cal.com rejects empty/mistyped system
// responses with a 400, which is the production booking failure.
func TestHandleCreateBooking_EmptySystemFieldsNotSent(t *testing.T) {
cap := &capturedBooking{}
fake := httptest.NewServer(cap.handler(t))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-06-01T10:00:00Z"},
"name": {"Bob"},
"email": {"bob@example.com"},
"timezone": {"America/Toronto"},
"attendeePhoneNumber": {""},
"location": {""},
"notes": {""},
"guests": {""},
"rescheduleReason": {""},
"title": {""},
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
cap.mu.Lock()
defer cap.mu.Unlock()
if cap.body == nil {
t.Fatalf("Cal.com received no parseable body")
}
// bookingFieldsResponses must be omitted entirely when there is nothing
// custom and non-empty to send.
if resps, present := cap.body["bookingFieldsResponses"]; present {
m, _ := resps.(map[string]any)
if len(m) != 0 {
t.Errorf("bookingFieldsResponses must be empty/omitted when all optional fields are blank, got: %v", resps)
}
// No empty-string values may ever be sent.
for k, v := range m {
if s, ok := v.(string); ok && strings.TrimSpace(s) == "" {
t.Errorf("bookingFieldsResponses[%q] is an empty string — Cal.com rejects empty responses", k)
}
}
// System/reschedule keys must never appear as custom responses.
for _, banned := range []string{"location", "notes", "guests", "rescheduleReason", "title", "attendeePhoneNumber"} {
if _, bad := m[banned]; bad {
t.Errorf("bookingFieldsResponses must not contain system key %q", banned)
}
}
}
// guests is a top-level field — empty guests must be omitted, not sent as "".
if g, present := cap.body["guests"]; present {
t.Errorf("empty guests must be omitted from the top-level payload, got: %v", g)
}
}
// TestHandleCreateBooking_GuestsRoutedToTopLevelArray asserts that the `guests`
// system field (Cal.com type multiemail) is split and sent as the top-level
// `guests` array of emails — NOT as a comma-joined string inside
// bookingFieldsResponses.
func TestHandleCreateBooking_GuestsRoutedToTopLevelArray(t *testing.T) {
cap := &capturedBooking{}
fake := httptest.NewServer(cap.handler(t))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-06-01T10:00:00Z"},
"name": {"Bob"},
"email": {"bob@example.com"},
"guests": {"a@x.com, b@y.com"},
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
cap.mu.Lock()
defer cap.mu.Unlock()
guests, ok := cap.body["guests"].([]any)
if !ok {
t.Fatalf("guests must be a top-level JSON array, got %T: %v", cap.body["guests"], cap.body["guests"])
}
if len(guests) != 2 || guests[0] != "a@x.com" || guests[1] != "b@y.com" {
t.Errorf("guests array wrong: %v", guests)
}
if resps, ok := cap.body["bookingFieldsResponses"].(map[string]any); ok {
if _, bad := resps["guests"]; bad {
t.Errorf("guests must not appear in bookingFieldsResponses")
}
}
}
// TestHandleCreateBooking_PhoneSlugRoutedToAttendee asserts that a phone value
// submitted under Cal.com's system slug `attendeePhoneNumber` (not the literal
// "phone") lands in attendee.phoneNumber, E.164-normalized (separators
// stripped) — not in bookingFieldsResponses.
func TestHandleCreateBooking_PhoneSlugRoutedToAttendee(t *testing.T) {
cap := &capturedBooking{}
fake := httptest.NewServer(cap.handler(t))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-06-01T10:00:00Z"},
"name": {"Bob"},
"email": {"bob@example.com"},
"attendeePhoneNumber": {"+61 432 777 227"},
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
cap.mu.Lock()
defer cap.mu.Unlock()
att, ok := cap.body["attendee"].(map[string]any)
if !ok {
t.Fatalf("attendee missing/wrong type: %v", cap.body)
}
if att["phoneNumber"] != "+61432777227" {
t.Errorf("attendee.phoneNumber: got %v, want +61432777227 (separators stripped)", att["phoneNumber"])
}
if resps, ok := cap.body["bookingFieldsResponses"].(map[string]any); ok {
if _, bad := resps["attendeePhoneNumber"]; bad {
t.Errorf("attendeePhoneNumber must route to attendee.phoneNumber, not bookingFieldsResponses")
}
}
}
// TestNormalizePhone_AppliesDialCode pins the E.164 normalization: Cal.com
// rejects national-format numbers (`{attendeePhoneNumber}invalid_number`), so a
// configured default dial code converts trunk-0 / bare-national numbers to
// international form. Numbers already in +/00 international form are left intact.
func TestNormalizePhone(t *testing.T) {
// libphonenumber applies each country's trunk rules. The IN/IT/RU cases failed
// under the old hand-rolled heuristic — the reviewer's bug class.
cases := []struct{ raw, region, want string }{
{"0432777227", "AU", "+61432777227"}, // AU mobile, trunk-0 dropped
{"0412 345 678", "AU", "+61412345678"}, // separators + trunk-0
{"07911123456", "GB", "+447911123456"}, // UK mobile, trunk-0 dropped
{"+44 7911 123456", "", "+447911123456"}, // already international, region ignored
{"9876543210", "IN", "+919876543210"}, // India mobile
{"0236618300", "IT", "+390236618300"}, // Italy landline KEEPS the leading 0
{"89161234567", "RU", "+79161234567"}, // Russia trunk prefix is 8
{"", "AU", ""}, // empty stays empty
{"NOPE", "AU", "NOPE"}, // unparseable -> best-effort fallback
{"555-1212", "", "5551212"}, // no region, not international -> fallback strip
}
for _, c := range cases {
if got := normalizePhone(c.raw, c.region); got != c.want {
t.Errorf("normalizePhone(%q, %q) = %q, want %q", c.raw, c.region, got, c.want)
}
}
}
// TestRegionFromLocale pins extraction of the region subtag from navigator.language.
func TestRegionFromLocale(t *testing.T) {
cases := map[string]string{
"en-AU": "AU",
"en_AU": "AU",
"AU": "AU",
"fr-FR": "FR",
"zh-Hans-CN": "CN",
"de-DE-1996": "DE",
"en": "", // no region subtag
"": "",
}
for in, want := range cases {
if got := regionFromLocale(in); got != want {
t.Errorf("regionFromLocale(%q) = %q, want %q", in, got, want)
}
}
}
// TestResolveRegion pins the country pre-selected in the phone combobox:
// timezone region first (strongest locator), then browser locale region.
func TestResolveRegion(t *testing.T) {
cases := []struct{ locale, tz, want string }{
{"en-AU", "", "AU"},
{"en-US", "Australia/Sydney", "AU"}, // timezone beats mismatched locale
{"fr-FR", "", "FR"},
{"", "Pacific/Auckland", "NZ"},
{"en", "", ""}, // no region
{"", "UTC", ""}, // unmapped tz, no locale
{"", "", ""},
}
for _, c := range cases {
if got := resolveRegion(c.locale, c.tz); got != c.want {
t.Errorf("resolveRegion(%q, %q) = %q, want %q", c.locale, c.tz, got, c.want)
}
}
}
// TestCountryFlag verifies ISO code → regional-indicator emoji.
func TestCountryFlag(t *testing.T) {
if got := countryFlag("AU"); got != "🇦🇺" {
t.Errorf("countryFlag(AU) = %q, want 🇦🇺", got)
}
// Guard rejects malformed input (wrong length / non-letters), not merely
// unknown-but-well-formed codes.
if got := countryFlag("X"); got != "" {
t.Errorf("countryFlag(too short) = %q, want empty", got)
}
if got := countryFlag("1A"); got != "" {
t.Errorf("countryFlag(non-letters) = %q, want empty", got)
}
if got := countryLabel("AU"); got != "🇦🇺 Australia (+61)" {
t.Errorf("countryLabel(AU) = %q, want '🇦🇺 Australia (+61)'", got)
}
}
// TestHandleCreateBooking_PhoneCountrySelectionWins asserts the visitor's
// explicit country-combobox choice drives the E.164 conversion and overrides
// browser detection.
func TestHandleCreateBooking_PhoneCountrySelectionWins(t *testing.T) {
cap := &capturedBooking{}
fake := httptest.NewServer(cap.handler(t))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{
"blockId": {"b1"},
"username": {"bidbuddy"},
"eventType": {"30min"},
"start": {"2026-06-01T09:30:00Z"},
"name": {"Alex"},
"email": {"alex@example.com"},
"attendeePhoneNumber": {"0432777227"},
"phoneCountry": {"AU"}, // explicit selection
"locale": {"en-US"}, // mismatched locale that must NOT win
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
cap.mu.Lock()
defer cap.mu.Unlock()
att, _ := cap.body["attendee"].(map[string]any)
if att["phoneNumber"] != "+61432777227" {
t.Errorf("attendee.phoneNumber: got %v, want +61432777227 (explicit AU selection)", att["phoneNumber"])
}
// phoneCountry is a control field — must not leak into responses.
if resps, ok := cap.body["bookingFieldsResponses"].(map[string]any); ok {
if _, bad := resps["phoneCountry"]; bad {
t.Errorf("phoneCountry must not appear in bookingFieldsResponses")
}
}
}
// TestHandleCreateBooking_InfersDialCodeFromLocale is the zero-config regression:
// the bidbuddy visitor submits the AU national number 0432777227 with NO admin
// dial code configured; the browser locale (en-AU) must drive the E.164
// conversion so Cal.com accepts attendee.phoneNumber.
func TestHandleCreateBooking_InfersDialCodeFromLocale(t *testing.T) {
cap := &capturedBooking{}
fake := httptest.NewServer(cap.handler(t))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
// Deliberately NO SetDefaultDialCode — detection must come from the locale.
form := url.Values{
"blockId": {"b1"},
"username": {"bidbuddy"},
"eventType": {"30min"},
"start": {"2026-06-01T09:30:00Z"},
"name": {"Alex Dunmow"},
"email": {"alex@example.com"},
"attendeePhoneNumber": {"0432777227"},
"locale": {"en-AU"},
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
cap.mu.Lock()
defer cap.mu.Unlock()
att, ok := cap.body["attendee"].(map[string]any)
if !ok {
t.Fatalf("attendee missing/wrong type: %v", cap.body)
}
if att["phoneNumber"] != "+61432777227" {
t.Errorf("attendee.phoneNumber: got %v, want +61432777227 (E.164 inferred from en-AU locale)", att["phoneNumber"])
}
// locale is a control field — it must never leak into bookingFieldsResponses.
if resps, ok := cap.body["bookingFieldsResponses"].(map[string]any); ok {
if _, bad := resps["locale"]; bad {
t.Errorf("locale must not appear in bookingFieldsResponses")
}
}
}
// TestStatusErrorWithBody_ParsesNestedCalcomError guards the diagnostic gap:
// Cal.com v2 returns errors as a NESTED object {"error":{"code","message"}},
// not a flat {"message"}. statusErrorWithBody must surface that message in the
// (server-side) error so booking failures are diagnosable instead of a bare
// "status 400".
func TestStatusErrorWithBody_ParsesNestedCalcomError(t *testing.T) {
body := []byte(`{"status":"error","timestamp":"2026-05-30T00:00:00Z","path":"/v2/bookings","error":{"code":"BAD_REQUEST","message":"Invalid responses - guests: Expected array, received string"}}`)
err := statusErrorWithBody("booking", http.StatusBadRequest, body)
if err == nil {
t.Fatal("expected an error")
}
msg := err.Error()
if !strings.Contains(msg, "400") {
t.Errorf("error must include the status code, got: %q", msg)
}
if !strings.Contains(msg, "guests: Expected array") {
t.Errorf("error must surface Cal.com's nested error.message, got: %q", msg)
}
}