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>
652 lines
24 KiB
Go
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)
|
|
}
|
|
}
|