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>
542 lines
21 KiB
Go
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)
|
|
}
|
|
}
|