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