Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 59 additions & 18 deletions internal/handler/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,59 @@ func authReq(method, path, body, apiKey string) *http.Request {
return r
}

// ---------------------------------------------------------------------------
// Response helpers
//
// Reading a body before checking the status hides the one thing that explains a
// failure, and `id := body["id"].(string)` on a body that has no id PANICS — which
// aborts the whole package, so a single bad request reports a nil-interface stack
// trace and no other handler test result at all.
//
// That is not hypothetical: a webhook create returning
// `400 webhook URL must not resolve to a private or loopback address` presented as an
// unrelated-looking panic, with the status and message nowhere on screen.
//
// These four keep a failure local and self-explaining. Each takes a `what` naming the
// call, so a test that makes several requests still says which one failed.
// ---------------------------------------------------------------------------

// mustStatus fails the test unless rec carries the wanted status, quoting the body.
func mustStatus(t *testing.T, rec *httptest.ResponseRecorder, want int, what string) {
t.Helper()
if rec.Code != want {
t.Fatalf("%s: status = %d; want %d — %s", what, rec.Code, want, rec.Body.String())
}
}

// mustJSON asserts the status first, then decodes the JSON body into a map.
func mustJSON(t *testing.T, rec *httptest.ResponseRecorder, want int, what string) map[string]any {
t.Helper()
mustStatus(t, rec, want, what)
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("%s: decode body: %v — %s", what, err, rec.Body.String())
}
return body
}

// mustCreated is mustJSON for 201, which is what most of these calls return.
func mustCreated(t *testing.T, rec *httptest.ResponseRecorder, what string) map[string]any {
t.Helper()
return mustJSON(t, rec, http.StatusCreated, what)
}

// mustString reads a string field, failing with the whole body when it is missing or of
// another type. The two-value assertion alone yields "" and defers the failure to
// something downstream — a 404 on an empty id — that cannot explain itself.
func mustString(t *testing.T, body map[string]any, key, what string) string {
t.Helper()
v, ok := body[key].(string)
if !ok {
t.Fatalf("%s: response has no string %q — %v", what, key, body)
}
return v
}

// seedEventType creates an event type via the HTTP handler.
func seedEventTypeHTTP(t *testing.T, h *handler.Handler, apiKey string) (slug, id string) {
t.Helper()
Expand Down Expand Up @@ -426,13 +479,9 @@ func TestCreateAndListAvailabilityRule(t *testing.T) {
req := authReq(http.MethodPost, "/v1/availability-rules", body, key)
rec := httptest.NewRecorder()
h.RequireAuth(h.CreateAvailabilityRule)(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create rule: %d — %s", rec.Code, rec.Body.String())
}

var created map[string]any
json.Unmarshal(rec.Body.Bytes(), &created)
ruleID, _ := created["id"].(string)
created := mustCreated(t, rec, "create rule")
ruleID := mustString(t, created, "id", "create rule")
if ruleID == "" {
t.Fatal("rule id is empty")
}
Expand Down Expand Up @@ -652,12 +701,8 @@ func TestGetBooking_public(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.CreateBooking(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create: %d", rec.Code)
}
var created map[string]any
json.Unmarshal(rec.Body.Bytes(), &created)
bookingID := created["id"].(string)
created := mustCreated(t, rec, "create booking")
bookingID := mustString(t, created, "id", "create booking")

// Get without auth key.
req2 := httptest.NewRequest(http.MethodGet, "/v1/bookings/"+bookingID, nil)
Expand Down Expand Up @@ -685,12 +730,8 @@ func TestCancelBooking(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.CreateBooking(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create: %d", rec.Code)
}
var created map[string]any
json.Unmarshal(rec.Body.Bytes(), &created)
bookingID := created["id"].(string)
created := mustCreated(t, rec, "create booking")
bookingID := mustString(t, created, "id", "create booking")

// Cancel.
cancelReq := authReq(http.MethodPost, "/v1/bookings/"+bookingID+"/cancel",
Expand Down
12 changes: 6 additions & 6 deletions internal/handler/availability_rule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func TestUpdateAvailabilityRule_updateTimes(t *testing.T) {
if code != http.StatusCreated {
t.Fatalf("create rule: %d — %v", code, created)
}
id, _ := created["id"].(string)
id := mustString(t, created, "id", "create rule")

req := authReq(http.MethodPatch, "/v1/availability-rules/"+id,
`{"start_time":"10:00","end_time":"18:00"}`, key)
Expand Down Expand Up @@ -64,7 +64,7 @@ func TestUpdateAvailabilityRule_updateDayOfWeek(t *testing.T) {
if code != http.StatusCreated {
t.Fatalf("create rule: %d — %v", code, created)
}
id, _ := created["id"].(string)
id := mustString(t, created, "id", "create rule")

req := authReq(http.MethodPatch, "/v1/availability-rules/"+id,
`{"day_of_week":2}`, key)
Expand Down Expand Up @@ -103,7 +103,7 @@ func TestUpdateAvailabilityRule_invalidDayOfWeek(t *testing.T) {
if code != http.StatusCreated {
t.Fatalf("create rule: %d — %v", code, created)
}
id, _ := created["id"].(string)
id := mustString(t, created, "id", "create rule")

req := authReq(http.MethodPatch, "/v1/availability-rules/"+id,
`{"day_of_week":7}`, key)
Expand All @@ -123,7 +123,7 @@ func TestUpdateAvailabilityRule_invalidHHMM(t *testing.T) {
if code != http.StatusCreated {
t.Fatalf("create rule: %d — %v", code, created)
}
id, _ := created["id"].(string)
id := mustString(t, created, "id", "create rule")

req := authReq(http.MethodPatch, "/v1/availability-rules/"+id,
`{"start_time":"9:00"}`, key)
Expand All @@ -143,7 +143,7 @@ func TestUpdateAvailabilityRule_endNotAfterStart(t *testing.T) {
if code != http.StatusCreated {
t.Fatalf("create rule: %d — %v", code, created)
}
id, _ := created["id"].(string)
id := mustString(t, created, "id", "create rule")

req := authReq(http.MethodPatch, "/v1/availability-rules/"+id,
`{"start_time":"17:00","end_time":"09:00"}`, key)
Expand Down Expand Up @@ -176,7 +176,7 @@ func TestUpdateAvailabilityRule_conflictWith409(t *testing.T) {
if code2 != http.StatusCreated {
t.Fatalf("create rule 2: %d — %v", code2, created2)
}
id2, _ := created2["id"].(string)
id2 := mustString(t, created2, "id", "create second rule")

// Patch rule 2 to have the same day_of_week as rule 1 → conflict.
req := authReq(http.MethodPatch, "/v1/availability-rules/"+id2,
Expand Down
27 changes: 11 additions & 16 deletions internal/handler/invites_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,8 @@ func TestGetInvite_valid(t *testing.T) {

rec := httptest.NewRecorder()
h.RequireAuth(h.CreateInvite)(rec, createInviteReq("bob@example.com", key))
var created map[string]any
json.Unmarshal(rec.Body.Bytes(), &created)
inviteURL := created["invite_url"].(string)
created := mustCreated(t, rec, "create invite")
inviteURL := mustString(t, created, "invite_url", "create invite")
// Extract token from URL (last path segment).
parts := strings.Split(inviteURL, "/")
token := parts[len(parts)-1]
Expand Down Expand Up @@ -137,9 +136,8 @@ func TestClaimInvite_success(t *testing.T) {
// Create invite.
rec := httptest.NewRecorder()
h.RequireAuth(h.CreateInvite)(rec, createInviteReq("bob@example.com", key))
var created map[string]any
json.Unmarshal(rec.Body.Bytes(), &created)
parts := strings.Split(created["invite_url"].(string), "/")
created := mustCreated(t, rec, "create invite")
parts := strings.Split(mustString(t, created, "invite_url", "create invite"), "/")
token := parts[len(parts)-1]

// Claim it.
Expand Down Expand Up @@ -170,9 +168,8 @@ func TestClaimInvite_cannotReuseToken(t *testing.T) {

rec := httptest.NewRecorder()
h.RequireAuth(h.CreateInvite)(rec, createInviteReq("bob@example.com", key))
var created map[string]any
json.Unmarshal(rec.Body.Bytes(), &created)
parts := strings.Split(created["invite_url"].(string), "/")
created := mustCreated(t, rec, "create invite")
parts := strings.Split(mustString(t, created, "invite_url", "create invite"), "/")
token := parts[len(parts)-1]

claimBody := `{"name":"Bob","password":"bobspassword1","timezone":"UTC"}`
Expand Down Expand Up @@ -219,9 +216,8 @@ func TestRevokeInvite_success(t *testing.T) {

rec := httptest.NewRecorder()
h.RequireAuth(h.CreateInvite)(rec, createInviteReq("bob@example.com", key))
var created map[string]any
json.Unmarshal(rec.Body.Bytes(), &created)
inviteID := created["id"].(string)
created := mustCreated(t, rec, "create invite")
inviteID := mustString(t, created, "id", "create invite")

req := authReq(http.MethodDelete, "/v1/invites/"+inviteID, "", key)
req.SetPathValue("id", inviteID)
Expand Down Expand Up @@ -253,10 +249,9 @@ func TestResendInvite_reissuesFreshLink(t *testing.T) {

rec := httptest.NewRecorder()
h.RequireAuth(h.CreateInvite)(rec, createInviteReq("bob@example.com", key))
var created map[string]any
json.Unmarshal(rec.Body.Bytes(), &created)
origID := created["id"].(string)
origURL := created["invite_url"].(string)
created := mustCreated(t, rec, "create invite")
origID := mustString(t, created, "id", "create invite")
origURL := mustString(t, created, "invite_url", "create invite")

req := authReq(http.MethodPost, "/v1/invites/"+origID+"/resend", "", key)
req.SetPathValue("id", origID)
Expand Down
6 changes: 3 additions & 3 deletions internal/handler/mcp_oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,8 @@ func TestMCP_OAuthFlow(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("token: %d — %s", rec.Code, rec.Body.String())
}
access, _ := body["access_token"].(string)
refresh, _ := body["refresh_token"].(string)
access := mustString(t, body, "access_token", "token exchange")
refresh := mustString(t, body, "refresh_token", "token exchange")
if access == "" || refresh == "" {
t.Fatalf("token: missing tokens: %v", body)
}
Expand All @@ -154,7 +154,7 @@ func TestMCP_OAuthFlow(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("refresh: %d — %s", rec.Code, rec.Body.String())
}
newAccess, _ := body["access_token"].(string)
newAccess := mustString(t, body, "access_token", "refresh grant")
if newAccess == "" || newAccess == access {
t.Fatalf("refresh: expected a new access token, got %q", newAccess)
}
Expand Down
16 changes: 8 additions & 8 deletions internal/handler/override_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ func TestDeleteAvailabilityOverride_success(t *testing.T) {
h, key, _ := setupWorkspace(t)

_, created := createOverride(t, h, key, `{"date":"2026-07-04","is_available":false}`)
id, _ := created["id"].(string)
id := mustString(t, created, "id", "create override")
if id == "" {
t.Fatal("created id is empty")
}
Expand Down Expand Up @@ -266,7 +266,7 @@ func TestDeleteAvailabilityOverride_cannotDeleteOtherUsersOverride(t *testing.T)
h, keyA, _ := setupWorkspace(t)

_, created := createOverride(t, h, keyA, `{"date":"2026-07-04","is_available":false}`)
id, _ := created["id"].(string)
id := mustString(t, created, "id", "create override")

// A second handler instance (same in-memory DB used via h) won't help here;
// in practice userB would be a different user. But since we only have one user
Expand Down Expand Up @@ -411,7 +411,7 @@ func TestUpdateAvailabilityOverride_updateTimes(t *testing.T) {

_, created := createOverride(t, h, key,
`{"date":"2026-08-01","is_available":true,"start_time":"10:00","end_time":"14:00"}`)
id, _ := created["id"].(string)
id := mustString(t, created, "id", "create override")
if id == "" {
t.Fatal("created id is empty")
}
Expand Down Expand Up @@ -443,7 +443,7 @@ func TestUpdateAvailabilityOverride_flipToBlocked(t *testing.T) {

_, created := createOverride(t, h, key,
`{"date":"2026-08-02","is_available":true,"start_time":"09:00","end_time":"17:00"}`)
id, _ := created["id"].(string)
id := mustString(t, created, "id", "create override")
if id == "" {
t.Fatal("created id is empty")
}
Expand Down Expand Up @@ -475,7 +475,7 @@ func TestUpdateAvailabilityOverride_flipToAvailable(t *testing.T) {

_, created := createOverride(t, h, key,
`{"date":"2026-08-03","is_available":false}`)
id, _ := created["id"].(string)
id := mustString(t, created, "id", "create override")
if id == "" {
t.Fatal("created id is empty")
}
Expand Down Expand Up @@ -521,7 +521,7 @@ func TestUpdateAvailabilityOverride_missingTimesWhenAvailable(t *testing.T) {

_, created := createOverride(t, h, key,
`{"date":"2026-08-04","is_available":false}`)
id, _ := created["id"].(string)
id := mustString(t, created, "id", "create override")
if id == "" {
t.Fatal("created id is empty")
}
Expand All @@ -542,7 +542,7 @@ func TestUpdateAvailabilityOverride_startNotBeforeEnd(t *testing.T) {

_, created := createOverride(t, h, key,
`{"date":"2026-08-05","is_available":true,"start_time":"09:00","end_time":"17:00"}`)
id, _ := created["id"].(string)
id := mustString(t, created, "id", "create override")
if id == "" {
t.Fatal("created id is empty")
}
Expand All @@ -563,7 +563,7 @@ func TestUpdateAvailabilityOverride_invalidHHMM(t *testing.T) {

_, created := createOverride(t, h, key,
`{"date":"2026-08-06","is_available":true,"start_time":"09:00","end_time":"17:00"}`)
id, _ := created["id"].(string)
id := mustString(t, created, "id", "create override")
if id == "" {
t.Fatal("created id is empty")
}
Expand Down
15 changes: 5 additions & 10 deletions internal/handler/webhook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func TestCreateWebhook_success(t *testing.T) {
if resp["id"] == "" {
t.Error("expected non-empty id")
}
secret, _ := resp["secret"].(string)
secret := mustString(t, resp, "secret", "create webhook")
if len(secret) != 64 {
t.Errorf("secret length = %d; want 64 hex chars", len(secret))
}
Expand Down Expand Up @@ -208,12 +208,8 @@ func TestDeleteWebhook_success(t *testing.T) {
`{"url":"https://example.com/hook","events":["booking.created"]}`, apiKey)
createRec := httptest.NewRecorder()
h.RequireAuth(h.CreateWebhook)(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create: %d", createRec.Code)
}
var created map[string]any
json.Unmarshal(createRec.Body.Bytes(), &created)
id := created["id"].(string)
created := mustCreated(t, createRec, "create webhook")
id := mustString(t, created, "id", "create webhook")

// Delete.
delReq := authReq(http.MethodDelete, "/v1/webhooks/"+id, "", apiKey)
Expand Down Expand Up @@ -263,9 +259,8 @@ func TestListWebhookDeliveries_emptyInitially(t *testing.T) {
`{"url":"https://example.com/hook","events":["booking.created"]}`, apiKey)
createRec := httptest.NewRecorder()
h.RequireAuth(h.CreateWebhook)(createRec, createReq)
var created map[string]any
json.Unmarshal(createRec.Body.Bytes(), &created)
id := created["id"].(string)
created := mustCreated(t, createRec, "create webhook")
id := mustString(t, created, "id", "create webhook")

req := authReq(http.MethodGet, "/v1/webhooks/"+id+"/deliveries", "", apiKey)
req.SetPathValue("id", id)
Expand Down
Loading