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) } }