//go:build test // +build test package admin // Test coverage for UpdateBookingServicesHandler: PUT /api/admin/bookings/{id} // // Tests cover: // - Successful service replacement, addition, removal // - Price and duration overrides // - Notes update // - Validation: invalid IDs, empty services, missing booking, invalid overrides // - Status rejection: completed, cancelled, no_show // - Overlap detection with next booking // - Response shape verification import ( "context" "net/http" "testing" "time" "crussell/db" "crussell/handlers/bookings" "crussell/testutils/fixtures" ) // ============================================================================= // Helpers // ============================================================================= // createBookingWithStartTime creates a booking at a specific start time with the given service func createBookingWithStartTime(t *testing.T, userID, serviceID string, startTime time.Time, status string) string { t.Helper() ctx := context.Background() var bookingID string err := db.DB.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, notes) VALUES ($1, $2, $3, $4) RETURNING id `, userID, startTime, status, "").Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = db.DB.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) if err != nil { t.Fatalf("failed to link service to booking: %v", err) } return bookingID } // createSecondService creates an additional test service with the given duration func createSecondService(t *testing.T, name string, durationMinutes int, price float64) string { t.Helper() ctx := context.Background() var serviceID string err := db.DB.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active) VALUES ($1, $2, $3, $4, true) RETURNING id `, name, "Test service: "+name, price, durationMinutes).Scan(&serviceID) if err != nil { t.Fatalf("failed to create service %s: %v", name, err) } return serviceID } // ============================================================================= // Success Cases // ============================================================================= // TestAdminBookings_UpdateServices_ReplaceServices verifies that an admin can // replace all services on a booking with a new set of services. func TestAdminBookings_UpdateServices_ReplaceServices(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service 1: %v", err) } defer fixtures.DeleteService(db.DB, service1) service2 := createSecondService(t, "Service Two", 45, 55.00) defer fixtures.DeleteService(db.DB, service2) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service2}, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp bookings.Booking if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Services) != 1 { t.Errorf("expected 1 service, got %d", len(resp.Services)) } if resp.Services[0].ServiceID != service2 { t.Errorf("expected service %s, got %s", service2, resp.Services[0].ServiceID) } } // TestAdminBookings_UpdateServices_AddService verifies that an admin can add // additional services to an existing booking. func TestAdminBookings_UpdateServices_AddService(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service 1: %v", err) } defer fixtures.DeleteService(db.DB, service1) service2 := createSecondService(t, "Service Two", 30, 40.00) defer fixtures.DeleteService(db.DB, service2) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1, service2}, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp bookings.Booking if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Services) != 2 { t.Errorf("expected 2 services, got %d", len(resp.Services)) } } // TestAdminBookings_UpdateServices_RemoveService verifies that an admin can // remove services from a booking by providing fewer service IDs. func TestAdminBookings_UpdateServices_RemoveService(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service 1: %v", err) } defer fixtures.DeleteService(db.DB, service1) service2 := createSecondService(t, "Service Two", 30, 40.00) defer fixtures.DeleteService(db.DB, service2) // Create booking with service1 bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID) // Manually add service2 to the booking ctx := context.Background() _, err = db.DB.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, service2) if err != nil { t.Fatalf("failed to add second service: %v", err) } // Update to only service1 (removing service2) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp bookings.Booking if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Services) != 1 { t.Errorf("expected 1 service after removal, got %d", len(resp.Services)) } } // TestAdminBookings_UpdateServices_WithPriceOverride verifies that an admin can // apply a price override to a service. func TestAdminBookings_UpdateServices_WithPriceOverride(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, "service_overrides": []map[string]interface{}{ { "service_id": service1, "override_price": 25.00, }, }, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp bookings.Booking if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp.Services[0].OverridePrice == nil { t.Fatal("expected override_price to be set") } if *resp.Services[0].OverridePrice != 25.00 { t.Errorf("expected override_price 25.00, got %f", *resp.Services[0].OverridePrice) } } // TestAdminBookings_UpdateServices_WithDurationOverride verifies that an admin can // apply a duration override to a service. func TestAdminBookings_UpdateServices_WithDurationOverride(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, "service_overrides": []map[string]interface{}{ { "service_id": service1, "override_duration_minutes": 90, }, }, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp bookings.Booking if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp.Services[0].OverrideDurationMinutes == nil { t.Fatal("expected override_duration_minutes to be set") } if *resp.Services[0].OverrideDurationMinutes != 90 { t.Errorf("expected override_duration_minutes 90, got %d", *resp.Services[0].OverrideDurationMinutes) } } // TestAdminBookings_UpdateServices_WithBothOverrides verifies that an admin can // apply both price and duration overrides simultaneously. func TestAdminBookings_UpdateServices_WithBothOverrides(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, "service_overrides": []map[string]interface{}{ { "service_id": service1, "override_price": 30.00, "override_duration_minutes": 75, }, }, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp bookings.Booking if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp.Services[0].OverridePrice == nil || *resp.Services[0].OverridePrice != 30.00 { t.Errorf("expected override_price 30.00, got %v", resp.Services[0].OverridePrice) } if resp.Services[0].OverrideDurationMinutes == nil || *resp.Services[0].OverrideDurationMinutes != 75 { t.Errorf("expected override_duration_minutes 75, got %v", resp.Services[0].OverrideDurationMinutes) } } // TestAdminBookings_UpdateServices_UpdateNotes verifies that an admin can update // the booking notes along with services. func TestAdminBookings_UpdateServices_UpdateNotes(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, "notes": "Updated notes for this booking", } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp bookings.Booking if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp.Notes == nil || *resp.Notes != "Updated notes for this booking" { t.Errorf("expected notes 'Updated notes for this booking', got %v", resp.Notes) } } // TestAdminBookings_UpdateServices_MultipleOverrides verifies that an admin can // apply overrides to multiple services in a single request. func TestAdminBookings_UpdateServices_MultipleOverrides(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service 1: %v", err) } defer fixtures.DeleteService(db.DB, service1) service2 := createSecondService(t, "Service Two", 45, 55.00) defer fixtures.DeleteService(db.DB, service2) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1, service2}, "service_overrides": []map[string]interface{}{ { "service_id": service1, "override_price": 20.00, }, { "service_id": service2, "override_duration_minutes": 60, }, }, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp bookings.Booking if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Services) != 2 { t.Fatalf("expected 2 services, got %d", len(resp.Services)) } // Find each service and verify its override for _, svc := range resp.Services { if svc.ServiceID == service1 { if svc.OverridePrice == nil || *svc.OverridePrice != 20.00 { t.Errorf("service1: expected override_price 20.00, got %v", svc.OverridePrice) } } if svc.ServiceID == service2 { if svc.OverrideDurationMinutes == nil || *svc.OverrideDurationMinutes != 60 { t.Errorf("service2: expected override_duration_minutes 60, got %v", svc.OverrideDurationMinutes) } } } } // ============================================================================= // Validation Error Cases // ============================================================================= // TestAdminBookings_UpdateServices_InvalidBookingID verifies that an invalid // booking ID returns 404. func TestAdminBookings_UpdateServices_InvalidBookingID(t *testing.T) { resetTestData(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/invalid-id", reqBody) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminBookings_UpdateServices_BookingNotFound verifies that a valid-format // but non-existent booking ID returns 404. func TestAdminBookings_UpdateServices_BookingNotFound(t *testing.T) { resetTestData(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/abc123def456", reqBody) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminBookings_UpdateServices_EmptyServiceIDs verifies that an empty // service_ids array returns 400. func TestAdminBookings_UpdateServices_EmptyServiceIDs(t *testing.T) { resetTestData(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{}, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminBookings_UpdateServices_InvalidServiceID verifies that an invalid // service ID in the list returns 400. func TestAdminBookings_UpdateServices_InvalidServiceID(t *testing.T) { resetTestData(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{"invalid-service-id"}, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminBookings_UpdateServices_ServiceNotFound verifies that a valid-format // but non-existent service ID returns 400. func TestAdminBookings_UpdateServices_ServiceNotFound(t *testing.T) { resetTestData(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{"abc123def456"}, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminBookings_UpdateServices_NegativePriceOverride verifies that a negative // price override returns 400. func TestAdminBookings_UpdateServices_NegativePriceOverride(t *testing.T) { resetTestData(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, "service_overrides": []map[string]interface{}{ { "service_id": service1, "override_price": -10.00, }, }, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminBookings_UpdateServices_ZeroDurationOverride verifies that a zero or // negative duration override returns 400. func TestAdminBookings_UpdateServices_ZeroDurationOverride(t *testing.T) { resetTestData(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, "service_overrides": []map[string]interface{}{ { "service_id": service1, "override_duration_minutes": 0, }, }, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // Status Rejection Cases // ============================================================================= // TestAdminBookings_UpdateServices_CompletedBookingRejected verifies that // updating services on a completed booking returns 403. func TestAdminBookings_UpdateServices_CompletedBookingRejected(t *testing.T) { resetTestData(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(-1*time.Hour), "completed") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminBookings_UpdateServices_CancelledBookingRejected verifies that // updating services on a cancelled booking returns 403. func TestAdminBookings_UpdateServices_CancelledBookingRejected(t *testing.T) { resetTestData(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "client_cancelled") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminBookings_UpdateServices_NoShowBookingRejected verifies that // updating services on a no-show booking returns 403. func TestAdminBookings_UpdateServices_NoShowBookingRejected(t *testing.T) { resetTestData(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "no_show") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminBookings_UpdateServices_WeCancelledBookingRejected verifies that // updating services on a we_cancelled booking returns 403. func TestAdminBookings_UpdateServices_WeCancelledBookingRejected(t *testing.T) { resetTestData(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "we_cancelled") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // Overlap Detection Cases // ============================================================================= // TestAdminBookings_UpdateServices_OverlapWithNextBooking verifies that extending // a booking's duration to overlap with the next booking returns 409. func TestAdminBookings_UpdateServices_OverlapWithNextBooking(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) // Create a long-duration service for the overlap test longService := createSecondService(t, "Long Service", 300, 100.00) // 5 hours defer fixtures.DeleteService(db.DB, longService) now := time.Now() // Booking 1 at 10:00 tomorrow booking1Start := now.Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) bookingID1 := createBookingWithStartTime(t, userID, service1, booking1Start, "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID1) // Booking 2 at 11:00 tomorrow (1 hour after booking 1) booking2Start := booking1Start.Add(1 * time.Hour) bookingID2 := createBookingWithStartTime(t, userID, service1, booking2Start, "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID2) // Try to update booking 1 to use the 5-hour service (would overlap booking 2) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{longService}, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID1, reqBody) if w.Code != http.StatusConflict { t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminBookings_UpdateServices_NoOverlapSucceeds verifies that a service update // that does not overlap with the next booking succeeds. func TestAdminBookings_UpdateServices_NoOverlapSucceeds(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) service2 := createSecondService(t, "Service Two", 30, 40.00) defer fixtures.DeleteService(db.DB, service2) now := time.Now() // Booking 1 at 10:00 tomorrow booking1Start := now.Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) bookingID1 := createBookingWithStartTime(t, userID, service1, booking1Start, "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID1) // Booking 2 at 14:00 tomorrow (4 hours after booking 1 starts) booking2Start := booking1Start.Add(4 * time.Hour) bookingID2 := createBookingWithStartTime(t, userID, service1, booking2Start, "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID2) // Update booking 1 to have both services (total ~60 min, well within the 4-hour gap) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1, service2}, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID1, reqBody) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminBookings_UpdateServices_NoNextBookingSucceeds verifies that a service // update succeeds when there is no next booking (no overlap possible). func TestAdminBookings_UpdateServices_NoNextBookingSucceeds(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) longService := createSecondService(t, "Long Service", 300, 100.00) defer fixtures.DeleteService(db.DB, longService) // Only booking for the day — no next booking to conflict with bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{longService}, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // Response Shape Verification // ============================================================================= // TestAdminBookings_UpdateServices_ResponseShape verifies that the response // contains all expected fields after a successful update. func TestAdminBookings_UpdateServices_ResponseShape(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, "notes": "Test notes for response shape verification", } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp bookings.Booking if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } // Verify all top-level fields are present if resp.ID == "" { t.Error("expected non-empty ID") } if resp.StartTime.IsZero() { t.Error("expected non-zero StartTime") } if resp.Status == "" { t.Error("expected non-empty Status") } if resp.Notes == nil { t.Error("expected non-nil Notes") } if resp.User == nil { t.Error("expected non-nil User") } if resp.User.FullName == "" { t.Error("expected non-empty User.FullName") } if len(resp.Services) == 0 { t.Error("expected at least one service in response") } } // ============================================================================= // Edge Cases // ============================================================================= // TestAdminBookings_UpdateServices_PendingBooking verifies that services can be // updated on a pending booking. func TestAdminBookings_UpdateServices_PendingBooking(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "pending") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminBookings_UpdateServices_InProgressBooking verifies that services can be // updated on an in_progress booking. func TestAdminBookings_UpdateServices_InProgressBooking(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(-30*time.Minute), "in_progress") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminBookings_UpdateServices_ClearNotes verifies that setting notes to an // empty string updates the booking notes accordingly. func TestAdminBookings_UpdateServices_ClearNotes(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin user: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) service1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, service1) bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") defer fixtures.DeleteBooking(db.DB, bookingID) handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) reqBody := map[string]interface{}{ "service_ids": []string{service1}, "notes": "", } w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } }