diff --git a/internal/handler/api_test.go b/internal/handler/api_test.go index 047c9c4..854623a 100644 --- a/internal/handler/api_test.go +++ b/internal/handler/api_test.go @@ -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() @@ -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") } @@ -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) @@ -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", diff --git a/internal/handler/availability_rule_test.go b/internal/handler/availability_rule_test.go index 60cf01f..329d370 100644 --- a/internal/handler/availability_rule_test.go +++ b/internal/handler/availability_rule_test.go @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) @@ -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, diff --git a/internal/handler/invites_test.go b/internal/handler/invites_test.go index 4e073dd..ff3c27f 100644 --- a/internal/handler/invites_test.go +++ b/internal/handler/invites_test.go @@ -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] @@ -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. @@ -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"}` @@ -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) @@ -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) diff --git a/internal/handler/mcp_oauth_test.go b/internal/handler/mcp_oauth_test.go index a09164c..59e5042 100644 --- a/internal/handler/mcp_oauth_test.go +++ b/internal/handler/mcp_oauth_test.go @@ -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) } @@ -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) } diff --git a/internal/handler/override_test.go b/internal/handler/override_test.go index 43cafbc..9dd49f1 100644 --- a/internal/handler/override_test.go +++ b/internal/handler/override_test.go @@ -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") } @@ -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 @@ -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") } @@ -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") } @@ -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") } @@ -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") } @@ -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") } @@ -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") } diff --git a/internal/handler/webhook_test.go b/internal/handler/webhook_test.go index b541d49..9e1f3ce 100644 --- a/internal/handler/webhook_test.go +++ b/internal/handler/webhook_test.go @@ -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)) } @@ -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) @@ -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)