calcomblock/client_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

652 lines
24 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// newFakeCalcomServer returns a httptest server whose handler is fn. The
// returned client is preconfigured to talk to it.
func newFakeCalcomServer(t *testing.T, fn http.HandlerFunc) (*httptest.Server, *CalcomClient) {
t.Helper()
srv := httptest.NewServer(fn)
t.Cleanup(srv.Close)
return srv, &CalcomClient{
http: srv.Client(),
apiKey: "test-key",
baseURL: srv.URL,
}
}
// TestGetSlots_URLAndHeaders verifies the request shape sent by GetSlots:
// path /v2/slots (or just /slots against our fake), correct query string, and
// the per-endpoint cal-api-version header.
func TestGetSlots_URLAndHeaders(t *testing.T) {
var gotPath string
var gotQuery string
var gotHeaders http.Header
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotQuery = r.URL.RawQuery
gotHeaders = r.Header
_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
})
_ = srv
_, err := c.GetSlots(context.Background(),
"alice", "30min",
"2026-05-27T00:00:00Z", "2026-05-28T00:00:00Z",
"America/Toronto")
if err != nil {
t.Fatalf("GetSlots returned error: %v", err)
}
if gotPath != "/slots" {
t.Errorf("path: got %q, want /slots", gotPath)
}
for _, want := range []string{
"username=alice",
"eventTypeSlug=30min",
"start=2026-05-27T00%3A00%3A00Z",
"end=2026-05-28T00%3A00%3A00Z",
"timeZone=America%2FToronto",
} {
if !strings.Contains(gotQuery, want) {
t.Errorf("query missing %q (got: %q)", want, gotQuery)
}
}
if got := gotHeaders.Get("cal-api-version"); got != versionSlots {
t.Errorf("cal-api-version: got %q, want %q", got, versionSlots)
}
if got := gotHeaders.Get("Authorization"); got != "Bearer test-key" {
t.Errorf("Authorization: got %q, want Bearer test-key", got)
}
}
// TestGetSlots_ParsesResponse asserts the Cal.com nested-by-date shape is
// decoded into SlotsResponse.Data correctly.
func TestGetSlots_ParsesResponse(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
// cal-api-version 2024-09-04 shape: each slot carries `start` (not the
// legacy `time` field). Millisecond precision mirrors the real wire.
_, _ = io.WriteString(w, `{
"status":"success",
"data":{
"2026-05-27":[{"start":"2026-05-27T13:00:00.000Z"},{"start":"2026-05-27T13:30:00.000Z"}],
"2026-05-28":[{"start":"2026-05-28T10:00:00.000Z"}]
}
}`)
})
_ = srv
got, err := c.GetSlots(context.Background(), "alice", "30min", "2026-05-27T00:00:00Z", "2026-05-29T00:00:00Z", "UTC")
if err != nil {
t.Fatalf("GetSlots: %v", err)
}
if len(got.Data["2026-05-27"]) != 2 {
t.Errorf("expected 2 slots for 2026-05-27, got %d", len(got.Data["2026-05-27"]))
}
if got.Data["2026-05-27"][0].When() != "2026-05-27T13:00:00.000Z" {
t.Errorf("first slot start mismatch: %q", got.Data["2026-05-27"][0].When())
}
if len(got.Data["2026-05-28"]) != 1 {
t.Errorf("expected 1 slot for 2026-05-28, got %d", len(got.Data["2026-05-28"]))
}
}
// TestGetSlots_LegacyTimeFieldFallback asserts that if Cal.com (or a rolled-back
// cal-api-version) replies with the older {"time":...} slot shape, SlotEntry.When()
// still resolves the timestamp. Guards the dual-shape decode that protects the
// widget from Cal.com's documented response-shape drift across API versions.
func TestGetSlots_LegacyTimeFieldFallback(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"status":"success","data":{"2026-05-27":[{"time":"2026-05-27T13:00:00Z"}]}}`)
})
_ = srv
got, err := c.GetSlots(context.Background(), "alice", "30min", "2026-05-27T00:00:00Z", "2026-05-28T00:00:00Z", "UTC")
if err != nil {
t.Fatalf("GetSlots: %v", err)
}
if len(got.Data["2026-05-27"]) != 1 {
t.Fatalf("expected 1 slot, got %d", len(got.Data["2026-05-27"]))
}
if got.Data["2026-05-27"][0].When() != "2026-05-27T13:00:00Z" {
t.Errorf("legacy time field not resolved by When(): %q", got.Data["2026-05-27"][0].When())
}
}
// TestListEventTypes_HeaderAndParsing asserts the event-types header and the
// EventTypeSummary parsing including lengthInMinutes.
func TestListEventTypes_HeaderAndParsing(t *testing.T) {
var gotHeaders http.Header
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, r *http.Request) {
gotHeaders = r.Header
_, _ = io.WriteString(w, `{
"status":"success",
"data":[
{"id":1,"slug":"30min","title":"30 Minute Meeting","lengthInMinutes":30},
{"id":2,"slug":"60min","title":"60 Minute Meeting","lengthInMinutes":60}
]
}`)
})
_ = srv
ets, err := c.ListEventTypes(context.Background(), "alice")
if err != nil {
t.Fatalf("ListEventTypes: %v", err)
}
if got := gotHeaders.Get("cal-api-version"); got != versionEventTypes {
t.Errorf("cal-api-version: got %q, want %q", got, versionEventTypes)
}
if len(ets) != 2 {
t.Fatalf("expected 2 event types, got %d", len(ets))
}
if ets[0].LengthInMinutes != 30 {
t.Errorf("lengthInMinutes not parsed: %+v", ets[0])
}
if ets[1].Slug != "60min" {
t.Errorf("second slug wrong: %+v", ets[1])
}
}
// TestGetEventType_BookingFieldsParsing verifies the full EventType shape
// including custom bookingFields with options is parsed correctly.
func TestGetEventType_BookingFieldsParsing(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{
"status":"success",
"data":[{
"id":42,
"slug":"intake",
"title":"Intake Call",
"lengthInMinutes":15,
"bookingFields":[
{"slug":"name","label":"Your name","type":"name","required":true},
{"slug":"company_size","label":"Company size","type":"select","required":true,
"options":[{"label":"1-10","value":"small"},{"label":"11-50","value":"medium"}]}
]
}]
}`)
})
_ = srv
et, err := c.GetEventType(context.Background(), "alice", "intake")
if err != nil {
t.Fatalf("GetEventType: %v", err)
}
if et.ID != 42 {
t.Errorf("ID: got %d, want 42", et.ID)
}
if len(et.BookingFields) != 2 {
t.Fatalf("expected 2 booking fields, got %d", len(et.BookingFields))
}
if et.BookingFields[1].Type != "select" || len(et.BookingFields[1].Options) != 2 {
t.Errorf("select field options not parsed: %+v", et.BookingFields[1])
}
if et.BookingFields[1].Options[0].Value != "small" {
t.Errorf("first option value wrong: %+v", et.BookingFields[1].Options[0])
}
}
// TestGetEventType_RealV2ShapeNormalizesFields pins the ACTUAL Cal.com v2
// /event-types bookingFields shape (cal-api-version 2024-06-14), which differs
// from what the plugin originally assumed:
//
// - the field identifier is "slug", NOT "name"
// - default/system fields return an empty "label" (Cal.com's own booker
// derives the visible label client-side from i18n keyed on the field type)
// - some system fields are "hidden":true and must not be rendered
//
// Decoding this with the old struct produced inputs with name="" and blank
// labels — invisible AND unsubmittable. GetEventType must normalize:
// slug→Name, a resolved label for system fields, and drop hidden fields.
func TestGetEventType_RealV2ShapeNormalizesFields(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{
"status":"success",
"data":[{
"id":7,
"slug":"30min",
"title":"30 Min Meeting",
"lengthInMinutes":30,
"bookingFields":[
{"type":"name","slug":"name","required":true,"label":"","isDefault":true},
{"type":"email","slug":"email","required":true,"label":"","isDefault":true},
{"type":"phone","slug":"attendeePhoneNumber","required":true,"label":"","placeholder":"","isDefault":true},
{"type":"textarea","slug":"notes","required":false,"label":"","isDefault":true},
{"type":"text","slug":"company","required":false,"label":"Company name","isDefault":false},
{"type":"text","slug":"title","required":false,"label":"","hidden":true,"isDefault":true}
]
}]
}`)
})
_ = srv
et, err := c.GetEventType(context.Background(), "bidbuddy", "30min")
if err != nil {
t.Fatalf("GetEventType: %v", err)
}
// The hidden "title" system field must be dropped.
if len(et.BookingFields) != 5 {
t.Fatalf("expected 5 visible booking fields (hidden dropped), got %d: %+v", len(et.BookingFields), et.BookingFields)
}
want := []struct {
name string
label string
}{
{"name", "Name"},
{"email", "Email"},
{"attendeePhoneNumber", "Phone number"},
{"notes", "Additional notes"},
{"company", "Company name"}, // custom field: explicit label preserved
}
for i, w := range want {
got := et.BookingFields[i]
if got.Name != w.name {
t.Errorf("field %d Name: got %q, want %q (slug must populate Name)", i, got.Name, w.name)
}
if got.Label != w.label {
t.Errorf("field %d Label: got %q, want %q", i, got.Label, w.label)
}
}
}
// TestGetEventType_NotFound asserts a descriptive error is returned when the
// event type isn't found in the response.
func TestGetEventType_NotFound(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"status":"success","data":[]}`)
})
_ = srv
_, err := c.GetEventType(context.Background(), "alice", "nope")
if err == nil {
t.Fatalf("expected error for missing event type")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("expected 'not found' in error, got: %v", err)
}
}
// TestCreateBooking_RequestShape verifies the WO-008 body contract:
// uses eventTypeSlug + username (no eventTypeId); attendee has timeZone;
// bookingFieldsResponses carries custom fields; cal-api-version header is the
// bookings-specific 2026-02-25.
func TestCreateBooking_RequestShape(t *testing.T) {
var gotBody []byte
var gotHeaders http.Header
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, r *http.Request) {
gotBody, _ = io.ReadAll(r.Body)
gotHeaders = r.Header
_, _ = io.WriteString(w, `{"status":"success","data":{"uid":"abc","rescheduleUrl":"https://cal.com/reschedule/abc","cancelUrl":"https://cal.com/booking/abc?cancel=true"}}`)
})
_ = srv
_, err := c.CreateBooking(context.Background(), BookingRequest{
EventTypeSlug: "30min",
Username: "alice",
Start: "2026-05-27T10:00:00Z",
Attendee: BookingAttendee{
Name: "Bob", Email: "bob@example.com", TimeZone: "America/Toronto",
},
BookingFieldsResponses: map[string]any{"q1": "yes"},
})
if err != nil {
t.Fatalf("CreateBooking: %v", err)
}
if got := gotHeaders.Get("cal-api-version"); got != versionBookings {
t.Errorf("cal-api-version: got %q, want %q", got, versionBookings)
}
var parsed map[string]any
if err := json.Unmarshal(gotBody, &parsed); err != nil {
t.Fatalf("decode body: %v", err)
}
if parsed["eventTypeSlug"] != "30min" {
t.Errorf("eventTypeSlug missing/wrong: %v", parsed["eventTypeSlug"])
}
if parsed["username"] != "alice" {
t.Errorf("username missing/wrong: %v", parsed["username"])
}
if _, present := parsed["eventTypeId"]; present {
t.Errorf("body must NOT contain eventTypeId (old shape); body=%s", string(gotBody))
}
att, _ := parsed["attendee"].(map[string]any)
if att["timeZone"] != "America/Toronto" {
t.Errorf("attendee.timeZone wrong: %v", att["timeZone"])
}
resps, _ := parsed["bookingFieldsResponses"].(map[string]any)
if resps["q1"] != "yes" {
t.Errorf("bookingFieldsResponses.q1 wrong: %v", resps)
}
}
// TestCreateBooking_ParsesLocationAndUID verifies the 2026-02-25 response: the
// booking UID and the canonical `location` meeting URL parse, and MeetingLink()
// resolves to `location`. The response carries no reschedule/cancel URLs, so
// those fields were intentionally removed from BookingResponseData.
func TestCreateBooking_ParsesLocationAndUID(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"status":"success","data":{
"id":123,
"uid":"xyz",
"start":"2026-05-27T10:00:00Z",
"end":"2026-05-27T10:30:00Z",
"location":"https://meet.example.com/xyz"
}}`)
})
_ = srv
resp, err := c.CreateBooking(context.Background(), BookingRequest{
EventTypeSlug: "30min", Username: "alice", Start: "2026-05-27T10:00:00Z",
Attendee: BookingAttendee{Name: "Bob", Email: "b@x.com", TimeZone: "UTC"},
})
if err != nil {
t.Fatalf("CreateBooking: %v", err)
}
if resp.Data.UID != "xyz" {
t.Errorf("UID: got %q, want xyz", resp.Data.UID)
}
if resp.Data.MeetingLink() != "https://meet.example.com/xyz" {
t.Errorf("MeetingLink: got %q, want the `location` URL", resp.Data.MeetingLink())
}
}
// TestBookingMeetingLink_Resolution covers MeetingLink()'s field precedence and
// the URL guard that prevents a physical-address/phone `location` (used by
// non-video event types) from being surfaced as a clickable link.
func TestBookingMeetingLink_Resolution(t *testing.T) {
cases := []struct {
name string
data BookingResponseData
want string
}{
{"location url preferred", BookingResponseData{Location: "https://a/x", MeetingURL: "https://b/y"}, "https://a/x"},
{"falls back to meetingUrl", BookingResponseData{Location: "", MeetingURL: "https://b/y"}, "https://b/y"},
{"non-url location is not a link", BookingResponseData{Location: "123 Main St, Perth", MeetingURL: ""}, ""},
{"non-url location falls back to meetingUrl", BookingResponseData{Location: "+61 8 1234 5678", MeetingURL: "https://b/y"}, "https://b/y"},
{"nothing", BookingResponseData{}, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.data.MeetingLink(); got != tc.want {
t.Errorf("MeetingLink() = %q, want %q", got, tc.want)
}
})
}
}
// TestCreateBooking_RecurringArrayData asserts that a recurring event type —
// whose response `data` is an ARRAY of occurrences — no longer hard-fails the
// booking. The first occurrence is surfaced (its UID + location).
func TestCreateBooking_RecurringArrayData(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"status":"success","data":[
{"uid":"occ-1","start":"2026-05-27T10:00:00Z","location":"https://meet.example.com/occ-1"},
{"uid":"occ-2","start":"2026-06-03T10:00:00Z","location":"https://meet.example.com/occ-2"}
]}`)
})
_ = srv
resp, err := c.CreateBooking(context.Background(), BookingRequest{
EventTypeSlug: "weekly", Username: "alice", Start: "2026-05-27T10:00:00Z",
Attendee: BookingAttendee{Name: "Bob", Email: "b@x.com", TimeZone: "UTC"},
})
if err != nil {
t.Fatalf("CreateBooking (recurring): %v", err)
}
if resp.Data.UID != "occ-1" {
t.Errorf("recurring booking should surface first occurrence UID; got %q", resp.Data.UID)
}
if resp.Data.MeetingLink() != "https://meet.example.com/occ-1" {
t.Errorf("recurring MeetingLink: got %q", resp.Data.MeetingLink())
}
}
// TestCancelBooking_RequestShape verifies CancelBooking sends the confirmed v2
// shape: POST /bookings/{uid}/cancel with the bookings cal-api-version, the
// Bearer token, and a {"cancellationReason": ...} body. The uid must be
// URL-path-escaped so a path-significant character can't break out of the route.
func TestCancelBooking_RequestShape(t *testing.T) {
var gotMethod, gotEscapedPath string
var gotHeaders http.Header
var gotBody []byte
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
// EscapedPath() preserves the on-the-wire escaping (r.URL.Path is decoded
// by the server, hiding whether the uid was escaped). This is what proves
// the uid was path-escaped and couldn't break out of the route.
gotEscapedPath = r.URL.EscapedPath()
gotHeaders = r.Header
gotBody, _ = io.ReadAll(r.Body)
_, _ = io.WriteString(w, `{"status":"success","data":{"id":1,"uid":"abc","status":"cancelled"}}`)
})
_ = srv
// A uid with a path-significant character (slash) must be escaped into a
// single path segment, not split the route.
if err := c.CancelBooking(context.Background(), "uid/with space", "Cancelled by attendee"); err != nil {
t.Fatalf("CancelBooking: %v", err)
}
if gotMethod != http.MethodPost {
t.Errorf("method: got %q, want POST", gotMethod)
}
if gotEscapedPath != "/bookings/uid%2Fwith%20space/cancel" {
t.Errorf("escaped path: got %q, want /bookings/uid%%2Fwith%%20space/cancel (uid path-escaped)", gotEscapedPath)
}
if got := gotHeaders.Get("cal-api-version"); got != versionBookings {
t.Errorf("cal-api-version: got %q, want %q", got, versionBookings)
}
if got := gotHeaders.Get("Authorization"); got != "Bearer test-key" {
t.Errorf("Authorization: got %q, want Bearer test-key", got)
}
var parsed map[string]any
if err := json.Unmarshal(gotBody, &parsed); err != nil {
t.Fatalf("decode body: %v", err)
}
if parsed["cancellationReason"] != "Cancelled by attendee" {
t.Errorf("cancellationReason: got %v, want %q", parsed["cancellationReason"], "Cancelled by attendee")
}
}
// TestCancelBooking_NonOKReturnsAPIError confirms a non-2xx cancel response
// surfaces as a *CalcomAPIError carrying the status (so the handler can classify
// it / log it) and is never silently swallowed.
func TestCancelBooking_NonOKReturnsAPIError(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, `{"error":{"code":"NotFoundException","message":"Booking not found"}}`)
})
_ = srv
err := c.CancelBooking(context.Background(), "missing-uid", "reason")
if err == nil {
t.Fatalf("expected error from 404 cancel response")
}
var apiErr *CalcomAPIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *CalcomAPIError, got %T: %v", err, err)
}
if apiErr.Status != http.StatusNotFound {
t.Errorf("status: got %d, want 404", apiErr.Status)
}
if !strings.Contains(err.Error(), "404") {
t.Errorf("error should mention status code, got: %v", err)
}
}
// TestCancelBooking_OmitsReasonWhenEmpty confirms an empty reason still sends a
// well-formed body (cancellationReason is optional per the v2 docs; we always
// send the field, empty when no reason is supplied).
func TestCancelBooking_AcceptsEmptyReason(t *testing.T) {
var gotBody []byte
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, r *http.Request) {
gotBody, _ = io.ReadAll(r.Body)
_, _ = io.WriteString(w, `{"status":"success","data":{"status":"cancelled"}}`)
})
_ = srv
if err := c.CancelBooking(context.Background(), "abc", ""); err != nil {
t.Fatalf("CancelBooking with empty reason: %v", err)
}
if !strings.Contains(string(gotBody), `"cancellationReason":""`) {
t.Errorf("body should carry an (empty) cancellationReason field; got %s", string(gotBody))
}
}
// TestClient_ErrorResponseSanitization confirms 4xx responses surface as
// errors (not panics) and that the error string includes the status code so
// callers can branch on it.
func TestClient_ErrorResponseSanitization(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, `{"error":"unauthorized","message":"Invalid API key"}`)
})
_ = srv
_, err := c.GetSlots(context.Background(), "alice", "30min", "2026-05-27T00:00:00Z", "2026-05-28T00:00:00Z", "UTC")
if err == nil {
t.Fatalf("expected error from 401 response")
}
if !strings.Contains(err.Error(), "401") {
t.Errorf("error should mention status code, got: %v", err)
}
}
// TestClient_HandlesNetworkFailure ensures a connection-level error surfaces
// without panicking.
func TestClient_HandlesNetworkFailure(t *testing.T) {
c := &CalcomClient{http: &http.Client{}, apiKey: "k", baseURL: "http://127.0.0.1:1"}
_, err := c.GetSlots(context.Background(), "alice", "30min", "x", "y", "UTC")
if err == nil {
t.Fatalf("expected error from unreachable server")
}
}
// Compile-time guard: ensure SlotEntry retains BOTH timestamp fields. Catches
// accidental renames during refactors — the dual shape (2024-09-04 `start` plus
// legacy `time`) is what keeps the widget resilient to Cal.com version drift.
var _ = SlotEntry{Start: "", Time: ""}
// Compile-time guard for the legacy fmt import in test diagnostics.
var _ = ""
// TestGetEventType_SendsEventSlugQuery verifies that GetEventType sends BOTH
// `username=` and `eventSlug=` as query params. The existing booking-fields
// parsing test (TestGetEventType_BookingFieldsParsing) does not capture the
// outbound URL — without this assertion the client could silently regress to
// omitting one or both params and the broken request would still parse the
// stub response in tests.
func TestGetEventType_SendsEventSlugQuery(t *testing.T) {
var gotQuery string
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
_, _ = io.WriteString(w, `{"status":"success","data":[{"id":1,"slug":"intake","title":"x","lengthInMinutes":15,"bookingFields":[]}]}`)
})
_ = srv
if _, err := c.GetEventType(context.Background(), "alice", "intake"); err != nil {
t.Fatalf("GetEventType: %v", err)
}
if !strings.Contains(gotQuery, "eventSlug=intake") {
t.Errorf("outbound query missing eventSlug=intake (got: %q)", gotQuery)
}
if !strings.Contains(gotQuery, "username=alice") {
t.Errorf("outbound query missing username=alice (got: %q)", gotQuery)
}
}
// TestListEventTypes_SendsUsernameQuery verifies that ListEventTypes propagates
// the username argument to Cal.com as a query param. Without this, the editor
// dropdown would silently fetch the API-key owner's own event types instead of
// the user the admin selected.
func TestListEventTypes_SendsUsernameQuery(t *testing.T) {
var gotQuery string
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
_, _ = io.WriteString(w, `{"status":"success","data":[]}`)
})
_ = srv
if _, err := c.ListEventTypes(context.Background(), "alice"); err != nil {
t.Fatalf("ListEventTypes: %v", err)
}
if !strings.Contains(gotQuery, "username=alice") {
t.Errorf("outbound query missing username=alice (got: %q)", gotQuery)
}
}
// TestGetMe_URLAndHeaders verifies the request shape sent by GetMe:
// path /me, no query string, and the cal-api-version header for the /me endpoint.
func TestGetMe_URLAndHeaders(t *testing.T) {
var gotPath string
var gotHeaders http.Header
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotHeaders = r.Header
_, _ = io.WriteString(w, `{"status":"success","data":{"username":"alice","email":"alice@example.com"}}`)
})
_ = srv
_, err := c.GetMe(context.Background())
if err != nil {
t.Fatalf("GetMe returned error: %v", err)
}
if gotPath != "/me" {
t.Errorf("path: got %q, want /me", gotPath)
}
if got := gotHeaders.Get("cal-api-version"); got != versionMe {
t.Errorf("cal-api-version: got %q, want %q", got, versionMe)
}
if got := gotHeaders.Get("Authorization"); got != "Bearer test-key" {
t.Errorf("Authorization: got %q, want Bearer test-key", got)
}
}
// TestGetMe_ParsesResponse confirms the {status, data:{username, email}} envelope
// is decoded into MeResponse correctly.
func TestGetMe_ParsesResponse(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"status":"success","data":{"username":"alice","email":"alice@example.com"}}`)
})
_ = srv
me, err := c.GetMe(context.Background())
if err != nil {
t.Fatalf("GetMe: %v", err)
}
if me.Username != "alice" {
t.Errorf("Username: got %q, want alice", me.Username)
}
if me.Email != "alice@example.com" {
t.Errorf("Email: got %q, want alice@example.com", me.Email)
}
}
// TestGetMe_BubblesUpstreamError ensures a non-2xx Cal.com response surfaces
// as an error from GetMe rather than a zero-value MeResponse.
func TestGetMe_BubblesUpstreamError(t *testing.T) {
srv, c := newFakeCalcomServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, `{"message":"invalid api key"}`)
})
_ = srv
_, err := c.GetMe(context.Background())
if err == nil {
t.Fatal("expected error on 401, got nil")
}
if !strings.Contains(err.Error(), "401") {
t.Errorf("error should mention 401, got: %v", err)
}
}