package main import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" ) // These tests pin the timezone contract for the public booking flow: every // surface (slot list, form recap, confirmation, attendee record) renders in // the MEETING's timezone — the event's Cal.com availability schedule — never // the visitor's. Ladder: event schedule timezone → site Localization setting // → UTC. // // Written against the bidbuddy.com.au incidents (2026-06-10). First: the // handler windowed and rendered slots in UTC (Perth visitors saw "12:00 AM"), // leaked the next day (Cal.com's end bound is date-granular), and assembled // slots from an unsorted map range. Second: display depended on client-side // timezone detection, which shipped a never-updated "UTC" sentinel on cached // pages. The meeting timezone is a server-side fact; visitor state is out. // seedSiteTimezone writes a site settings blob with the given localization // timezone into the fake querier, mirroring Site Settings → Localization. func seedSiteTimezone(q *fakeQuerier, tz string) { q.mu.Lock() defer q.mu.Unlock() q.data["site"] = []byte(`{"localization":{"timezone":"` + tz + `"}}`) } // fakeCalcom builds a Cal.com test double serving the event-timezone // resolution pair — /event-types (scheduleId: null → default schedule) and // /schedules (the schedule carrying scheduleTZ) — and delegates every other // path (slots, bookings) to rest. scheduleTZ "" makes /schedules fail with a // 500, simulating Cal.com being unreachable for the timezone lookup. The // double is installed as the package base URL for the duration of the test. func fakeCalcom(t *testing.T, scheduleTZ string, rest http.HandlerFunc) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasPrefix(r.URL.Path, "/event-types"): _, _ = io.WriteString(w, `{"status":"success","data":[{"id":1,"slug":"30min","scheduleId":null,"bookingFields":[]}]}`) case strings.HasPrefix(r.URL.Path, "/schedules"): if scheduleTZ == "" { w.WriteHeader(http.StatusInternalServerError) return } _, _ = io.WriteString(w, `{"status":"success","data":[{"id":9,"name":"Availability","timeZone":"`+scheduleTZ+`","isDefault":true}]}`) default: if rest != nil { rest(w, r) return } _, _ = io.WriteString(w, `{"status":"success","data":{}}`) } })) t.Cleanup(srv.Close) withCalcomBaseURL(t, srv.URL) return srv } // newKeyedHandler returns a handler whose settings carry an API key — the // precondition for the event-timezone fetch. func newKeyedHandler(t *testing.T) (*CalcomHandler, *fakeQuerier) { t.Helper() h, q := newTestHandler() if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil { t.Fatalf("SetAPIKey: %v", err) } return h, q } // TestHandleGetSlots_WindowBuiltInEventTimezone asserts the [start, end) // window sent to Cal.com covers the selected date in the MEETING's timezone — // resolved from the event's availability schedule, with no timezone hint on // the request at all. For date=2026-06-11 and a Perth schedule the window // must be 2026-06-11T00:00:00+08:00 .. 2026-06-12T00:00:00+08:00. func TestHandleGetSlots_WindowBuiltInEventTimezone(t *testing.T) { var gotQuery url.Values fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) { gotQuery = r.URL.Query() _, _ = io.WriteString(w, `{"status":"success","data":{}}`) }) h, _ := newKeyedHandler(t) req := httptest.NewRequest(http.MethodGet, "/slots?username=alice&eventType=30min&date=2026-06-11&blockId=b1", nil) rec := httptest.NewRecorder() h.HandleGetSlots(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String()) } if got := gotQuery.Get("start"); got != "2026-06-11T00:00:00+08:00" { t.Errorf("outbound start: got %q, want 2026-06-11T00:00:00+08:00", got) } if got := gotQuery.Get("end"); got != "2026-06-12T00:00:00+08:00" { t.Errorf("outbound end: got %q, want 2026-06-12T00:00:00+08:00", got) } if got := gotQuery.Get("timeZone"); got != "Australia/Perth" { t.Errorf("outbound timeZone: got %q, want Australia/Perth", got) } } // TestHandleGetSlots_VisitorTimezoneParamIgnored pins the design decision: a // timezone query param — whether a visitor's real browser zone or a stale // cached page's sentinel — must NOT move the display away from the meeting's // timezone. Availability is a fact about the host's calendar. func TestHandleGetSlots_VisitorTimezoneParamIgnored(t *testing.T) { var gotQuery url.Values fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) { gotQuery = r.URL.Query() _, _ = io.WriteString(w, `{"status":"success","data":{}}`) }) h, _ := newKeyedHandler(t) req := httptest.NewRequest(http.MethodGet, "/slots?username=alice&eventType=30min&date=2026-06-11&blockId=b1&timezone=America/New_York", nil) rec := httptest.NewRecorder() h.HandleGetSlots(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String()) } if got := gotQuery.Get("timeZone"); got != "Australia/Perth" { t.Errorf("outbound timeZone: got %q, want Australia/Perth (visitor param must be ignored)", got) } if got := gotQuery.Get("start"); got != "2026-06-11T00:00:00+08:00" { t.Errorf("outbound start: got %q, want Perth-midnight window despite visitor param", got) } } // TestHandleGetSlots_FiltersSlotsOutsideEventDay reproduces the production // leak: Cal.com treats the end bound date-granularly and returns the entire // next day too. Slots not falling on the requested date in the meeting's // timezone must be dropped, not rendered under the wrong heading (where // same-wall-clock times read as duplicates). func TestHandleGetSlots_FiltersSlotsOutsideEventDay(t *testing.T) { fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) { // Perth-local response shape with a leaked next-day group, as observed // live on 2026-06-10 against api.cal.com. _, _ = io.WriteString(w, `{ "status":"success", "data":{ "2026-06-11":[{"start":"2026-06-11T08:00:00.000+08:00"},{"start":"2026-06-11T17:30:00.000+08:00"}], "2026-06-12":[{"start":"2026-06-12T13:30:00.000+08:00"}] } }`) }) h, _ := newKeyedHandler(t) req := httptest.NewRequest(http.MethodGet, "/slots?username=alice&eventType=30min&date=2026-06-11&blockId=b1", nil) rec := httptest.NewRecorder() h.HandleGetSlots(rec, req) body := rec.Body.String() if !strings.Contains(body, "8:00 AM") { t.Errorf("expected '8:00 AM' (Perth-local slot) in body, got: %q", body) } if !strings.Contains(body, "5:30 PM") { t.Errorf("expected '5:30 PM' (Perth-local slot) in body, got: %q", body) } if strings.Contains(body, "1:30 PM") { t.Errorf("next-day slot (2026-06-12 13:30) leaked into the 2026-06-11 view: %q", body) } } // TestHandleGetSlots_ConvertsUTCSlotsToEventTimezone asserts display times are // converted into the meeting's timezone even when Cal.com returns UTC (Z) // timestamps — display must never depend on the offset Cal.com happens to // format with. func TestHandleGetSlots_ConvertsUTCSlotsToEventTimezone(t *testing.T) { fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) { // 2026-06-11 00:00Z == 8:00 AM Perth; 09:30Z == 5:30 PM Perth. _, _ = io.WriteString(w, `{ "status":"success", "data":{"2026-06-11":[{"start":"2026-06-11T00:00:00.000Z"},{"start":"2026-06-11T09:30:00.000Z"}]} }`) }) h, _ := newKeyedHandler(t) req := httptest.NewRequest(http.MethodGet, "/slots?username=alice&eventType=30min&date=2026-06-11&blockId=b1", nil) rec := httptest.NewRecorder() h.HandleGetSlots(rec, req) body := rec.Body.String() if !strings.Contains(body, "8:00 AM") { t.Errorf("expected UTC slot rendered as '8:00 AM' Perth time, got: %q", body) } if !strings.Contains(body, "5:30 PM") { t.Errorf("expected UTC slot rendered as '5:30 PM' Perth time, got: %q", body) } if strings.Contains(body, "12:00 AM") { t.Errorf("slot rendered as raw UTC wall time ('12:00 AM') instead of Perth time: %q", body) } } // TestHandleGetSlots_SortsSlotsChronologically guards against Go map iteration // order: a Perth-local day can span two UTC date keys, so slots assembled // across keys must be explicitly sorted. (2026-06-10T23:00Z == 7:00 AM Perth // on June 11; 2026-06-11T01:00:00Z == 9:00 AM Perth on June 11.) func TestHandleGetSlots_SortsSlotsChronologically(t *testing.T) { fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, `{ "status":"success", "data":{ "2026-06-10":[{"start":"2026-06-10T23:00:00.000Z"}], "2026-06-11":[{"start":"2026-06-11T01:00:00.000Z"}] } }`) }) h, _ := newKeyedHandler(t) req := httptest.NewRequest(http.MethodGet, "/slots?username=alice&eventType=30min&date=2026-06-11&blockId=b1", nil) rec := httptest.NewRecorder() h.HandleGetSlots(rec, req) body := rec.Body.String() early := strings.Index(body, "7:00 AM") late := strings.Index(body, "9:00 AM") if early == -1 || late == -1 { t.Fatalf("expected both '7:00 AM' and '9:00 AM' in body, got: %q", body) } if early > late { t.Errorf("slots out of chronological order: '7:00 AM' rendered after '9:00 AM'") } } // TestHandleGetSlots_FallsBackToSiteTimezone asserts that when the event // timezone can't be fetched (Cal.com schedules endpoint down), the site-wide // Localization timezone is used instead of UTC. func TestHandleGetSlots_FallsBackToSiteTimezone(t *testing.T) { var gotQuery url.Values fakeCalcom(t, "" /* schedules endpoint fails */, func(w http.ResponseWriter, r *http.Request) { gotQuery = r.URL.Query() _, _ = io.WriteString(w, `{"status":"success","data":{}}`) }) h, q := newKeyedHandler(t) seedSiteTimezone(q, "Australia/Perth") req := httptest.NewRequest(http.MethodGet, "/slots?username=alice&eventType=30min&date=2026-06-11&blockId=b1", nil) rec := httptest.NewRecorder() h.HandleGetSlots(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String()) } if got := gotQuery.Get("timeZone"); got != "Australia/Perth" { t.Errorf("outbound timeZone: got %q, want Australia/Perth (site fallback)", got) } if got := gotQuery.Get("start"); got != "2026-06-11T00:00:00+08:00" { t.Errorf("outbound start: got %q, want Perth-midnight window", got) } } // TestHandleGetSlots_InvalidEventTimezoneFallsBack asserts a garbage timezone // on the Cal.com schedule degrades to the site timezone rather than erroring // or silently using the bogus value. func TestHandleGetSlots_InvalidEventTimezoneFallsBack(t *testing.T) { var gotQuery url.Values fakeCalcom(t, "Not/AZone", func(w http.ResponseWriter, r *http.Request) { gotQuery = r.URL.Query() _, _ = io.WriteString(w, `{"status":"success","data":{}}`) }) h, q := newKeyedHandler(t) seedSiteTimezone(q, "Australia/Perth") req := httptest.NewRequest(http.MethodGet, "/slots?username=alice&eventType=30min&date=2026-06-11&blockId=b1", nil) rec := httptest.NewRecorder() h.HandleGetSlots(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String()) } if got := gotQuery.Get("timeZone"); got != "Australia/Perth" { t.Errorf("outbound timeZone: got %q, want Australia/Perth (invalid event tz must fall back)", got) } } // TestHandleGetForm_DisplayTimeInEventTimezone asserts the form's "selected // time" recap renders in the meeting's timezone when the slot start arrives // as a UTC timestamp. func TestHandleGetForm_DisplayTimeInEventTimezone(t *testing.T) { fakeCalcom(t, "Australia/Perth", nil) h, _ := newKeyedHandler(t) // 2026-06-11T01:00:00Z == 9:00 AM Australia/Perth. req := httptest.NewRequest(http.MethodGet, "/form?blockId=b1&start=2026-06-11T01:00:00Z&date=2026-06-11&username=alice&eventType=30min", nil) rec := httptest.NewRecorder() h.HandleGetForm(rec, req) body := rec.Body.String() if !strings.Contains(body, "9:00 AM") { t.Errorf("expected form recap '9:00 AM' (Perth), got: %q", body) } if strings.Contains(body, "1:00 AM") { t.Errorf("form recap rendered raw UTC wall time ('1:00 AM'): %q", body) } } // TestHandleCreateBooking_ConfirmationInEventTimezone asserts the booking // confirmation panel renders the meeting time in the meeting's timezone, and // that the attendee record sent to Cal.com carries the meeting timezone too — // even when a stale cached page still submits a visitor timezone field. func TestHandleCreateBooking_ConfirmationInEventTimezone(t *testing.T) { var attendeeTZ string fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/bookings") { var payload struct { Attendee struct { TimeZone string `json:"timeZone"` } `json:"attendee"` } _ = json.NewDecoder(r.Body).Decode(&payload) attendeeTZ = payload.Attendee.TimeZone _, _ = io.WriteString(w, `{ "status":"success", "data":{"uid":"uid-1","start":"2026-06-11T01:00:00Z","end":"2026-06-11T01:30:00Z","location":"https://meet.example.com/uid-1"} }`) return } _, _ = io.WriteString(w, `{"status":"success","data":{}}`) }) h, _ := newKeyedHandler(t) form := url.Values{ "blockId": {"b1"}, "username": {"alice"}, "eventType": {"30min"}, "start": {"2026-06-11T01:00:00Z"}, "name": {"Sue Findlay"}, "email": {"sue@example.test"}, // A page cached before the rework still posts this — must be ignored. "timezone": {"America/New_York"}, } 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) body := rec.Body.String() // 01:00Z on June 11 is 9:00 AM Thursday June 11 in Perth. if !strings.Contains(body, "9:00 AM") { t.Errorf("expected confirmation in Perth time ('9:00 AM'), got: %q", body) } if strings.Contains(body, "1:00 AM") { t.Errorf("confirmation rendered the UTC instant ('1:00 AM') instead of meeting time: %q", body) } if attendeeTZ != "Australia/Perth" { t.Errorf("attendee timeZone sent to Cal.com: got %q, want Australia/Perth", attendeeTZ) } } // TestCalcomBookingWidget_NoClientTimezonePlumbing pins the removal of // client-side timezone detection — the mechanism behind the original bidbuddy // incident (a hidden input's "UTC" default that never updated on cached // pages). The widget must carry no timezone input, attribute, or detection // call; the locale capture must still defer to DOMContentLoaded (the input it // fills sits after the script in parse order); and the visible label must be // the server-resolved zone. func TestCalcomBookingWidget_NoClientTimezonePlumbing(t *testing.T) { var buf strings.Builder cfg := BookingConfig{ BlockID: "b1", Username: "alice", EventTypeSlug: "30min", WeeksToShow: 2, WeekStart: "2026-06-11", Today: "2026-06-11", TimeZone: "Australia/Perth", Dates: nil, ShowTimezone: true, } if err := CalcomBookingWidget(cfg).Render(context.Background(), &buf); err != nil { t.Fatalf("render widget: %v", err) } html := buf.String() for _, leftover := range []string{`name="timezone"`, "data-timezone", "Intl.DateTimeFormat"} { if strings.Contains(html, leftover) { t.Errorf("widget still ships client timezone plumbing (%s)", leftover) } } if !strings.Contains(html, "DOMContentLoaded") { t.Errorf("widget init script must defer until DOMContentLoaded — running during parse leaves the locale input at its empty default") } if !strings.Contains(html, "Australia/Perth") { t.Errorf("ShowTimezone label must render the server-resolved zone, got: %q", html) } }