diff --git a/backend/handlers/admin/bookings_test.go b/backend/handlers/admin/bookings_test.go index b96c8a4..c6ee021 100644 --- a/backend/handlers/admin/bookings_test.go +++ b/backend/handlers/admin/bookings_test.go @@ -26,6 +26,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -3207,3 +3208,492 @@ func createCompletedBookingWithTimeForAdmin(t *testing.T, userID, serviceID stri return bookingID } + +// ============================================================================= +// Custom Service Tests +// ============================================================================= + +// TestAdminBookings_CreateWithCustomServices verifies that an admin can create +// a booking with only custom services via AdminCreateBookingForUserHandler. +// It checks that the booking_custom_services entry is created and the custom +// service usage_count is incremented. +func TestAdminBookings_CreateWithCustomServices(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) + + customServiceID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, customServiceID) + + futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) + req := bookings.AdminCreateBookingForUserRequest{ + UserID: userID, + StartTime: futureTime, + CustomServiceIDs: []string{customServiceID}, + } + + handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) + } + + var response map[string]interface{} + if err := parseResponseBody(w, &response); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + bookingData, ok := response["booking"].(map[string]interface{}) + if !ok { + t.Fatal("expected booking in response") + } + + bookingID, ok := bookingData["id"].(string) + if !ok || bookingID == "" { + t.Fatal("expected booking id in response") + } + defer fixtures.DeleteBooking(db.DB, bookingID) + + // Verify booking_custom_services has the custom service linked + var bcsCount int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM booking_custom_services WHERE booking_id = $1 AND custom_service_id = $2", + bookingID, customServiceID).Scan(&bcsCount) + if err != nil { + t.Fatalf("failed to query booking_custom_services: %v", err) + } + if bcsCount != 1 { + t.Errorf("expected 1 booking_custom_services entry, got %d", bcsCount) + } + + // Verify custom_services usage_count was incremented + var usageCount int + err = db.DB.QueryRow(context.Background(), + "SELECT usage_count FROM custom_services WHERE id = $1", customServiceID).Scan(&usageCount) + if err != nil { + t.Fatalf("failed to query custom_service usage_count: %v", err) + } + if usageCount < 1 { + t.Errorf("expected usage_count >= 1, got %d", usageCount) + } +} + +// TestAdminBookings_CreateWithCustomAndRegularServices verifies that an admin +// can create a booking with both regular and custom services simultaneously. +// It checks that entries are created in both booking_services and +// booking_custom_services. +func TestAdminBookings_CreateWithCustomAndRegularServices(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) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + customServiceID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, customServiceID) + + futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) + req := bookings.AdminCreateBookingForUserRequest{ + UserID: userID, + StartTime: futureTime, + ServiceIDs: []string{serviceID}, + CustomServiceIDs: []string{customServiceID}, + } + + handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) + } + + var response map[string]interface{} + if err := parseResponseBody(w, &response); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + bookingData, ok := response["booking"].(map[string]interface{}) + if !ok { + t.Fatal("expected booking in response") + } + + bookingID, ok := bookingData["id"].(string) + if !ok || bookingID == "" { + t.Fatal("expected booking id in response") + } + defer fixtures.DeleteBooking(db.DB, bookingID) + + // Verify booking_services has the regular service + var svcCount int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM booking_services WHERE booking_id = $1 AND service_id = $2", + bookingID, serviceID).Scan(&svcCount) + if err != nil { + t.Fatalf("failed to query booking_services: %v", err) + } + if svcCount != 1 { + t.Errorf("expected 1 booking_services entry, got %d", svcCount) + } + + // Verify booking_custom_services has the custom service + var csCount int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM booking_custom_services WHERE booking_id = $1 AND custom_service_id = $2", + bookingID, customServiceID).Scan(&csCount) + if err != nil { + t.Fatalf("failed to query booking_custom_services: %v", err) + } + if csCount != 1 { + t.Errorf("expected 1 booking_custom_services entry, got %d", csCount) + } +} + +// TestAdminBookings_Create_CustomServiceValidation verifies that the handler +// rejects requests with neither service_ids nor custom_service_ids, testing +// both nil and empty arrays. +func TestAdminBookings_Create_CustomServiceValidation(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) + + handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) + + // Test 1: both service_ids and custom_service_ids are nil + t.Run("missing both service and custom service IDs", func(t *testing.T) { + req := bookings.AdminCreateBookingForUserRequest{ + UserID: userID, + StartTime: time.Now().Add(72 * time.Hour), + } + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) + } + }) + + // Test 2: both are empty arrays + t.Run("empty service_ids and custom_service_ids arrays", func(t *testing.T) { + req := bookings.AdminCreateBookingForUserRequest{ + UserID: userID, + StartTime: time.Now().Add(72 * time.Hour), + ServiceIDs: []string{}, + CustomServiceIDs: []string{}, + } + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) + } + }) + + // Test 3: custom_service_ids is provided (should succeed — need working hours) + t.Run("provides custom_service_ids only", func(t *testing.T) { + seedDefaultWorkingHours(t) + + customServiceID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, customServiceID) + + futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) + req := bookings.AdminCreateBookingForUserRequest{ + UserID: userID, + StartTime: futureTime, + CustomServiceIDs: []string{customServiceID}, + } + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + if w.Code != http.StatusCreated { + t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) + } + }) +} + +// TestAdminBookings_Confirm_WithCustomOverrides verifies that an admin can +// confirm a booking with custom service overrides via ConfirmBookingHandler. +// It checks that the override_price and override_duration_minutes are stored +// in booking_custom_services. +func TestAdminBookings_Confirm_WithCustomOverrides(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) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + customServiceID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, customServiceID) + + // Create a pending booking with a regular service + bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + if err != nil { + t.Fatalf("failed to create test booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, bookingID) + + // Link a custom service to the booking + _, err = db.DB.Exec(context.Background(), + "INSERT INTO booking_custom_services (booking_id, custom_service_id) VALUES ($1, $2)", + bookingID, customServiceID) + if err != nil { + t.Fatalf("failed to link custom service to booking: %v", err) + } + + // Confirm with custom service overrides + overridePrice := 65.00 + overrideDuration := 30 + confirmReq := bookings.ConfirmBookingRequest{ + CustomServiceOverrides: []bookings.ServiceOverride{ + { + ServiceID: customServiceID, + OverridePrice: &overridePrice, + OverrideDurationMinutes: &overrideDuration, + }, + }, + } + + handler := http.HandlerFunc(bookings.ConfirmBookingHandler) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/confirm", confirmReq) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var booking bookings.Booking + if err := parseResponseBody(w, &booking); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if booking.Status != "confirmed" { + t.Errorf("expected status 'confirmed', got %s", booking.Status) + } + + // Verify override was applied to booking_custom_services + var actualPrice *float64 + var actualDuration *int + err = db.DB.QueryRow(context.Background(), + "SELECT override_price, override_duration_minutes FROM booking_custom_services WHERE booking_id = $1 AND custom_service_id = $2", + bookingID, customServiceID).Scan(&actualPrice, &actualDuration) + if err != nil { + t.Fatalf("failed to query booking_custom_services override: %v", err) + } + if actualPrice == nil || *actualPrice != overridePrice { + t.Errorf("expected override_price %.2f, got %v", overridePrice, actualPrice) + } + if actualDuration == nil || *actualDuration != overrideDuration { + t.Errorf("expected override_duration_minutes %d, got %v", overrideDuration, actualDuration) + } +} + +// TestAdminBookings_CreateWithCustomServicesAndOverrides verifies that an admin +// can create a booking with custom services and apply price/duration overrides +// at creation time via AdminCreateBookingForUserHandler. +func TestAdminBookings_CreateWithCustomServicesAndOverrides(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) + + customServiceID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, customServiceID) + + overridePrice := 60.00 + overrideDuration := 30 + futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) + req := bookings.AdminCreateBookingForUserRequest{ + UserID: userID, + StartTime: futureTime, + CustomServiceIDs: []string{customServiceID}, + CustomOverrides: []bookings.ServiceOverride{ + { + ServiceID: customServiceID, + OverridePrice: &overridePrice, + OverrideDurationMinutes: &overrideDuration, + }, + }, + } + + handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) + } + + var response map[string]interface{} + if err := parseResponseBody(w, &response); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + bookingData, ok := response["booking"].(map[string]interface{}) + if !ok { + t.Fatal("expected booking in response") + } + + bookingID, ok := bookingData["id"].(string) + if !ok || bookingID == "" { + t.Fatal("expected booking id in response") + } + defer fixtures.DeleteBooking(db.DB, bookingID) + + // Verify override was applied to booking_custom_services + var actualPrice *float64 + var actualDuration *int + err = db.DB.QueryRow(context.Background(), + "SELECT override_price, override_duration_minutes FROM booking_custom_services WHERE booking_id = $1 AND custom_service_id = $2", + bookingID, customServiceID).Scan(&actualPrice, &actualDuration) + if err != nil { + t.Fatalf("failed to query booking_custom_services override: %v", err) + } + if actualPrice == nil || *actualPrice != overridePrice { + t.Errorf("expected override_price %.2f, got %v", overridePrice, actualPrice) + } + if actualDuration == nil || *actualDuration != overrideDuration { + t.Errorf("expected override_duration_minutes %d, got %v", overrideDuration, actualDuration) + } +} + +// TestAdminBookings_AdminReserve_WithCustomServices verifies that an admin can +// create a call-in reservation with custom services via AdminReserveSlotHandler. +// It verifies the response duration matches the custom service duration and +// that the time_blocker is created reflecting the custom service duration. +func TestAdminBookings_AdminReserve_WithCustomServices(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin: %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) + + customServiceID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, customServiceID) + + // Set custom service duration to a known value for assertion + _, err = db.DB.Exec(context.Background(), + "UPDATE custom_services SET duration_minutes = 45 WHERE id = $1", customServiceID) + if err != nil { + t.Fatalf("failed to update custom service duration: %v", err) + } + + tomorrow := time.Now().Add(24 * time.Hour).Truncate(time.Second) + tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location()) + + req := bookings.AdminReserveSlotRequest{ + UserID: &userID, + ReservationType: "callin", + StartTime: tomorrow, + CustomServiceIDs: []string{customServiceID}, + TTLMinutes: 15, + } + + handler := http.HandlerFunc(bookings.AdminReserveSlotHandler) + w := makeRequestWithContext(handler, "POST", "/api/admin/bookings/reserve", req, adminID, "admin") + + if w.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp bookings.AdminReserveSlotResponse + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + // Custom service duration is 45 minutes + if resp.DurationMinutes != 45 { + t.Errorf("expected duration 45, got %d", resp.DurationMinutes) + } + + // Verify time_blocker was created with correct description pattern + var desc string + err = db.DB.QueryRow(context.Background(), + "SELECT description FROM time_blockers WHERE description LIKE 'RESERVATION:admin:callin:%'", + ).Scan(&desc) + if err != nil { + t.Errorf("failed to query time_blocker: %v", err) + } + + if !strings.HasPrefix(desc, "RESERVATION:admin:callin:") { + t.Errorf("expected description to start with 'RESERVATION:admin:callin:', got %s", desc) + } +} diff --git a/backend/handlers/admin/custom_services_test.go b/backend/handlers/admin/custom_services_test.go new file mode 100644 index 0000000..be71527 --- /dev/null +++ b/backend/handlers/admin/custom_services_test.go @@ -0,0 +1,858 @@ +//go:build test +// +build test + +package admin + +// Package admin contains tests for admin custom services management endpoints. +// +// Test Coverage: +// - GetCustomServices: GET /api/admin/custom-services - List all custom services +// - CreateCustomService: POST /api/admin/custom-services - Create custom service +// - GetCustomService: GET /api/admin/custom-services/{id} - Get single custom service +// - UpdateCustomService: PUT /api/admin/custom-services/{id} - Update custom service +// - PromoteCustomService: POST /api/admin/custom-services/{id}/promote - Promote to regular service +// - DeleteCustomService: DELETE /api/admin/custom-services/{id} - Delete custom service +// +// Authentication: All endpoints require admin role (403 for non-admins). + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "crussell/db" + "crussell/mw" + "crussell/testutils/fixtures" + + "github.com/go-chi/chi/v5" +) + +// testAdminID is set by each test after creating an admin user via fixtures, +// so that handlers referencing created_by (which has a FK to users) work correctly. +var testAdminID string + +// makeCustomServiceRequest creates an admin request with chi URL params for custom-services paths. +// Uses testAdminID (must be set by the calling test). +func makeCustomServiceRequest(handler http.Handler, method, path string, body interface{}) *httptest.ResponseRecorder { + var req *http.Request + if body != nil { + bodyBytes, _ := json.Marshal(body) + req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, path, nil) + } + + rctx := chi.NewRouteContext() + if strings.HasPrefix(path, "/api/admin/custom-services/") { + suffix := path[len("/api/admin/custom-services/"):] + if slashIdx := strings.Index(suffix, "/"); slashIdx >= 0 { + rctx.URLParams.Add("id", suffix[:slashIdx]) + } else { + rctx.URLParams.Add("id", suffix) + } + } + + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + ctx = context.WithValue(ctx, mw.UserIDKey, testAdminID) + ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w +} + +// ============================================================================= +// List Custom Services Tests +// ============================================================================= + +// TestCustomServices_List verifies that an admin can list all custom services +// with pagination metadata. +func TestCustomServices_List(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) + + csID1, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service 1: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, csID1) + + csID2, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service 2: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, csID2) + + handler := http.HandlerFunc(GetCustomServices) + w := makeAdminRequest(handler, "GET", "/api/admin/custom-services", nil) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp CustomServiceListResponse + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if resp.Total != 2 { + t.Errorf("expected total 2, got %d", resp.Total) + } + + if len(resp.Services) != 2 { + t.Errorf("expected 2 services, got %d", len(resp.Services)) + } +} + +// TestCustomServices_List_Search verifies search filtering via the q parameter, +// including case-insensitive matching and no-results scenarios. +func TestCustomServices_List_Search(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) + + csID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, csID) + + // Set a unique name for search testing + _, err = db.DB.Exec(context.Background(), + "UPDATE custom_services SET name = 'SearchableServiceName' WHERE id = $1", csID) + if err != nil { + t.Fatalf("failed to update custom service name: %v", err) + } + + handler := http.HandlerFunc(GetCustomServices) + + // Matching search (case-insensitive) + w := makeAdminRequest(handler, "GET", "/api/admin/custom-services?q=searchableservicename", nil) + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp CustomServiceListResponse + 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 matching search, got %d", len(resp.Services)) + } + + if resp.Total != 1 { + t.Errorf("expected total 1, got %d", resp.Total) + } + + // Non-matching search + w = makeAdminRequest(handler, "GET", "/api/admin/custom-services?q=NONEXISTENT_QUERY_XYZ", nil) + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if len(resp.Services) != 0 { + t.Errorf("expected 0 services for non-matching search, got %d", len(resp.Services)) + } + + if resp.Total != 0 { + t.Errorf("expected total 0, got %d", resp.Total) + } +} + +// TestCustomServices_List_Popular verifies the popular flag returns custom services +// ordered by usage_count, limited to the specified number. +func TestCustomServices_List_Popular(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) + + csID1, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service 1: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, csID1) + + csID2, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service 2: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, csID2) + + // Set usage counts via direct DB to have services with usage_count > 0 + _, err = db.DB.Exec(context.Background(), + "UPDATE custom_services SET usage_count = 5, last_used_at = NOW() WHERE id = $1", csID1) + if err != nil { + t.Fatalf("failed to set usage count: %v", err) + } + + handler := http.HandlerFunc(GetCustomServices) + w := makeAdminRequest(handler, "GET", "/api/admin/custom-services?popular=3", nil) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + // Popular mode returns a plain array, not the list response struct + var services []CustomService + if err := parseResponseBody(w, &services); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if len(services) == 0 { + t.Errorf("expected at least 1 popular service, got 0") + } + + // Verify the returned service has usage_count > 0 + for _, s := range services { + if s.UsageCount <= 0 { + t.Errorf("expected usage_count > 0 for popular service %s, got %d", s.ID, s.UsageCount) + } + } +} + +// TestCustomServices_List_Pagination verifies page and per_page query parameters. +func TestCustomServices_List_Pagination(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) + + // Create 3 custom services + csIDs := make([]string, 3) + for i := 0; i < 3; i++ { + csID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service %d: %v", i+1, err) + } + csIDs[i] = csID + } + defer func() { + for _, id := range csIDs { + fixtures.DeleteCustomService(db.DB, id) + } + }() + + handler := http.HandlerFunc(GetCustomServices) + + // First page with 2 per page + w := makeAdminRequest(handler, "GET", "/api/admin/custom-services?page=1&per_page=2", nil) + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp CustomServiceListResponse + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if resp.Page != 1 { + t.Errorf("expected page 1, got %d", resp.Page) + } + + if resp.PerPage != 2 { + t.Errorf("expected per_page 2, got %d", resp.PerPage) + } + + if resp.Total != 3 { + t.Errorf("expected total 3, got %d", resp.Total) + } + + if len(resp.Services) != 2 { + t.Errorf("expected 2 services on page 1, got %d", len(resp.Services)) + } +} + +// ============================================================================= +// Create Custom Service Tests +// ============================================================================= + +// TestCustomServices_Create verifies that an admin can create a new custom service +// with name, description, price, duration, minimum age, and notes. +func TestCustomServices_Create(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) + + handler := http.HandlerFunc(CreateCustomService) + + createReq := CreateCustomServiceRequest{ + Name: "Custom French Manicure", + Description: stringPtr("A custom french manicure service"), + Price: 45.00, + DurationMinutes: 60, + MinimumAgeRequired: 16, + Notes: stringPtr("Custom service notes"), + } + + w := makeRequestWithContext(handler, "POST", "/api/admin/custom-services", createReq, adminID, "admin") + + if w.Code != http.StatusCreated { + t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) + } + + var cs CustomService + if err := parseResponseBody(w, &cs); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if cs.Name != "Custom French Manicure" { + t.Errorf("expected name 'Custom French Manicure', got %s", cs.Name) + } + + if cs.Price != 45.00 { + t.Errorf("expected price 45.00, got %f", cs.Price) + } + + if cs.DurationMinutes != 60 { + t.Errorf("expected duration 60, got %d", cs.DurationMinutes) + } + + if cs.MinimumAgeRequired != 16 { + t.Errorf("expected minimum_age_required 16, got %d", cs.MinimumAgeRequired) + } + + if cs.UsageCount != 0 { + t.Errorf("expected usage_count 0 for new custom service, got %d", cs.UsageCount) + } + + if cs.ID == "" { + t.Error("expected non-empty ID for created custom service") + } +} + +// TestCustomServices_Create_Validation verifies that validation errors return +// HTTP 400 for various invalid inputs. +func TestCustomServices_Create_Validation(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) + + tests := []struct { + name string + req CreateCustomServiceRequest + }{ + { + name: "empty name", + req: CreateCustomServiceRequest{ + Name: "", + Price: 50.00, + DurationMinutes: 60, + }, + }, + { + name: "zero price", + req: CreateCustomServiceRequest{ + Name: "Test Service", + Price: 0, + DurationMinutes: 60, + }, + }, + { + name: "negative price", + req: CreateCustomServiceRequest{ + Name: "Test Service", + Price: -10.00, + DurationMinutes: 60, + }, + }, + { + name: "zero duration", + req: CreateCustomServiceRequest{ + Name: "Test Service", + Price: 50.00, + DurationMinutes: 0, + }, + }, + { + name: "excessive duration", + req: CreateCustomServiceRequest{ + Name: "Test Service", + Price: 50.00, + DurationMinutes: 481, + }, + }, + { + name: "negative minimum age", + req: CreateCustomServiceRequest{ + Name: "Test Service", + Price: 50.00, + DurationMinutes: 60, + MinimumAgeRequired: -1, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := http.HandlerFunc(CreateCustomService) + w := makeRequestWithContext(handler, "POST", "/api/admin/custom-services", tt.req, adminID, "admin") + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) + } + }) + } +} + +// ============================================================================= +// Get Single Custom Service Tests +// ============================================================================= + +// TestCustomServices_Get verifies that an admin can retrieve a single custom service by ID. +func TestCustomServices_Get(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) + testAdminID = adminID + + csID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, csID) + + handler := http.HandlerFunc(GetCustomService) + w := makeCustomServiceRequest(handler, "GET", "/api/admin/custom-services/"+csID, nil) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var cs CustomService + if err := parseResponseBody(w, &cs); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if cs.ID != csID { + t.Errorf("expected ID %s, got %s", csID, cs.ID) + } + + if cs.Name == "" { + t.Error("expected non-empty name") + } + + if cs.Price <= 0 { + t.Errorf("expected positive price, got %f", cs.Price) + } +} + +// TestCustomServices_Get_NotFound verifies that requesting a non-existent custom service returns 404. +func TestCustomServices_Get_NotFound(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) + testAdminID = adminID + + handler := http.HandlerFunc(GetCustomService) + w := makeCustomServiceRequest(handler, "GET", "/api/admin/custom-services/nonexistent-id", nil) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// ============================================================================= +// Update Custom Service Tests +// ============================================================================= + +// TestCustomServices_Update verifies that an admin can update a custom service's fields. +func TestCustomServices_Update(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) + testAdminID = adminID + + csID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, csID) + + newName := "Updated Custom Name" + updateReq := UpdateCustomServiceRequest{ + Name: &newName, + } + + handler := http.HandlerFunc(UpdateCustomService) + w := makeCustomServiceRequest(handler, "PUT", "/api/admin/custom-services/"+csID, updateReq) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if resp["message"] != "Custom service updated" { + t.Errorf("expected 'Custom service updated' message, got %s", resp["message"]) + } + + // Verify the update persisted + var dbName string + err = db.DB.QueryRow(context.Background(), + "SELECT name FROM custom_services WHERE id = $1", csID).Scan(&dbName) + if err != nil { + t.Fatalf("failed to query custom service: %v", err) + } + + if dbName != newName { + t.Errorf("expected name '%s' in DB, got %s", newName, dbName) + } +} + +// TestCustomServices_Update_NotFound verifies that updating a non-existent custom service returns 404. +func TestCustomServices_Update_NotFound(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) + testAdminID = adminID + + newName := "Updated Name" + updateReq := UpdateCustomServiceRequest{ + Name: &newName, + } + + handler := http.HandlerFunc(UpdateCustomService) + w := makeCustomServiceRequest(handler, "PUT", "/api/admin/custom-services/nonexistent-id", updateReq) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestCustomServices_Update_NoFields verifies that sending an update with no fields +// returns 400 Bad Request. +func TestCustomServices_Update_NoFields(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) + testAdminID = adminID + + csID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, csID) + + updateReq := UpdateCustomServiceRequest{} + + handler := http.HandlerFunc(UpdateCustomService) + w := makeCustomServiceRequest(handler, "PUT", "/api/admin/custom-services/"+csID, updateReq) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// ============================================================================= +// Promote Custom Service Tests +// ============================================================================= + +// TestCustomServices_Promote verifies that promoting a custom service creates a +// regular service, migrates data, and deletes the original custom service. +func TestCustomServices_Promote(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) + testAdminID = adminID + + csID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + + handler := http.HandlerFunc(PromoteCustomService) + w := makeCustomServiceRequest(handler, "POST", "/api/admin/custom-services/"+csID+"/promote", nil) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + newServiceID := resp["new_service_id"] + if newServiceID == "" { + t.Error("expected non-empty new_service_id in response") + } + + if resp["custom_service_id"] != csID { + t.Errorf("expected custom_service_id %s, got %s", csID, resp["custom_service_id"]) + } + + // Verify the custom service was deleted + var count int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM custom_services WHERE id = $1", csID).Scan(&count) + if err != nil { + t.Fatalf("failed to query custom service: %v", err) + } + if count != 0 { + t.Errorf("expected custom service to be deleted, but it still exists") + } + + // Verify the new regular service was created + var serviceName string + err = db.DB.QueryRow(context.Background(), + "SELECT name FROM services WHERE id = $1", newServiceID).Scan(&serviceName) + if err != nil { + t.Fatalf("failed to query promoted service: %v", err) + } + if serviceName == "" { + t.Error("expected non-empty service name") + } + + // Clean up: delete the promoted service + defer fixtures.DeleteService(db.DB, newServiceID) +} + +// TestCustomServices_Promote_NotFound verifies that promoting a non-existent custom service returns 404. +func TestCustomServices_Promote_NotFound(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) + testAdminID = adminID + + handler := http.HandlerFunc(PromoteCustomService) + w := makeCustomServiceRequest(handler, "POST", "/api/admin/custom-services/nonexistent-id/promote", nil) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// ============================================================================= +// Delete Custom Service Tests +// ============================================================================= + +// TestCustomServices_Delete verifies that an admin can delete an unused custom service. +func TestCustomServices_Delete(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) + testAdminID = adminID + + csID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + + handler := http.HandlerFunc(DeleteCustomService) + w := makeCustomServiceRequest(handler, "DELETE", "/api/admin/custom-services/"+csID, nil) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if resp["message"] != "Custom service deleted" { + t.Errorf("expected 'Custom service deleted' message, got %s", resp["message"]) + } + + // Verify it's gone from the DB + var count int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM custom_services WHERE id = $1", csID).Scan(&count) + if err != nil { + t.Fatalf("failed to query custom service: %v", err) + } + if count != 0 { + t.Error("expected custom service to be deleted from database") + } +} + +// TestCustomServices_Delete_NotFound verifies that deleting a non-existent custom service returns 404. +func TestCustomServices_Delete_NotFound(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) + testAdminID = adminID + + handler := http.HandlerFunc(DeleteCustomService) + w := makeCustomServiceRequest(handler, "DELETE", "/api/admin/custom-services/nonexistent-id", nil) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestCustomServices_Delete_Conflict verifies that deleting a custom service with +// usage_count > 0 returns 409 Conflict. +func TestCustomServices_Delete_Conflict(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) + testAdminID = adminID + + csID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, csID) + + // Simulate usage to trigger conflict + _, err = db.DB.Exec(context.Background(), + "UPDATE custom_services SET usage_count = 3 WHERE id = $1", csID) + if err != nil { + t.Fatalf("failed to set usage count: %v", err) + } + + handler := http.HandlerFunc(DeleteCustomService) + w := makeCustomServiceRequest(handler, "DELETE", "/api/admin/custom-services/"+csID, nil) + + if w.Code != http.StatusConflict { + t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// ============================================================================= +// Non-Admin Tests +// ============================================================================= + +// TestCustomServices_NonAdmin verifies that non-admin users receive HTTP 403 +// Forbidden when attempting to access any admin custom services endpoint. +func TestCustomServices_NonAdmin(t *testing.T) { + resetTestData(t) + + _, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + csID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, csID) + + // Test LIST + w := makeUserRequest(mw.RequireAdmin(http.HandlerFunc(GetCustomServices)), "GET", "/api/admin/custom-services", nil) + if w.Code != http.StatusForbidden { + t.Errorf("LIST: expected status 403, got %d", w.Code) + } + + // Test CREATE + createReq := CreateCustomServiceRequest{ + Name: "Test Service", + Price: 50.00, + DurationMinutes: 60, + } + w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(CreateCustomService)), "POST", "/api/admin/custom-services", createReq) + if w.Code != http.StatusForbidden { + t.Errorf("CREATE: expected status 403, got %d", w.Code) + } + + // Test GET + w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(GetCustomService)), "GET", "/api/admin/custom-services/"+csID, nil) + if w.Code != http.StatusForbidden { + t.Errorf("GET: expected status 403, got %d", w.Code) + } + + // Test UPDATE + updateReq := UpdateCustomServiceRequest{} + w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(UpdateCustomService)), "PUT", "/api/admin/custom-services/"+csID, updateReq) + if w.Code != http.StatusForbidden { + t.Errorf("UPDATE: expected status 403, got %d", w.Code) + } + + // Test PROMOTE + w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(PromoteCustomService)), "POST", "/api/admin/custom-services/"+csID+"/promote", nil) + if w.Code != http.StatusForbidden { + t.Errorf("PROMOTE: expected status 403, got %d", w.Code) + } + + // Test DELETE + w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(DeleteCustomService)), "DELETE", "/api/admin/custom-services/"+csID, nil) + if w.Code != http.StatusForbidden { + t.Errorf("DELETE: expected status 403, got %d", w.Code) + } +} diff --git a/backend/handlers/bookings/bookings_test.go b/backend/handlers/bookings/bookings_test.go index 5a047ec..8036450 100644 --- a/backend/handlers/bookings/bookings_test.go +++ b/backend/handlers/bookings/bookings_test.go @@ -5380,3 +5380,445 @@ func createCompletedBookingWithTime(t *testing.T, userID, serviceID string, star return bookingID } + +func TestBookings_Confirm_WithCustomServiceOverrides(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + csID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, csID) + + london, _ := time.LoadLocation("Europe/London") + startTime := nextWeekday(time.Monday, london).Add(10 * time.Hour) + + var bookingID string + err = db.DB.QueryRow(context.Background(), ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'pending') + RETURNING id + `, userID, startTime).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, bookingID) + + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_services (booking_id, service_id) + VALUES ($1, $2) + `, bookingID, serviceID) + if err != nil { + t.Fatalf("failed to link service: %v", err) + } + + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_custom_services (booking_id, custom_service_id) + VALUES ($1, $2) + `, bookingID, csID) + if err != nil { + t.Fatalf("failed to link custom service: %v", err) + } + + overridePrice := 100.0 + overrideDuration := 60 + svcOverridePrice := 55.0 + svcOverrideDuration := 45 + + req := ConfirmBookingRequest{ + ServiceOverrides: []ServiceOverride{ + {ServiceID: serviceID, OverridePrice: &svcOverridePrice, OverrideDurationMinutes: &svcOverrideDuration}, + }, + CustomServiceOverrides: []ServiceOverride{ + {ServiceID: csID, OverridePrice: &overridePrice, OverrideDurationMinutes: &overrideDuration}, + }, + } + + token := jwt.GenerateUserToken(userID) + handler := http.HandlerFunc(ConfirmBookingHandler) + w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "") + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var booking Booking + if err := parseResponseBody(w, &booking); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if booking.Status != "confirmed" { + t.Errorf("expected status 'confirmed', got %s", booking.Status) + } + + var storedOverridePrice *float64 + var storedOverrideDuration *int + err = db.DB.QueryRow(context.Background(), ` + SELECT override_price, override_duration_minutes + FROM booking_custom_services + WHERE booking_id = $1 AND custom_service_id = $2 + `, bookingID, csID).Scan(&storedOverridePrice, &storedOverrideDuration) + if err != nil { + t.Fatalf("failed to query custom service override: %v", err) + } + if storedOverridePrice == nil || *storedOverridePrice != overridePrice { + t.Errorf("expected override_price %.2f, got %v", overridePrice, storedOverridePrice) + } + if storedOverrideDuration == nil || *storedOverrideDuration != overrideDuration { + t.Errorf("expected override_duration_minutes %d, got %v", overrideDuration, storedOverrideDuration) + } + + var svcStoredPrice *float64 + var svcStoredDuration *int + err = db.DB.QueryRow(context.Background(), ` + SELECT override_price, override_duration_minutes + FROM booking_services + WHERE booking_id = $1 AND service_id = $2 + `, bookingID, serviceID).Scan(&svcStoredPrice, &svcStoredDuration) + if err != nil { + t.Fatalf("failed to query service override: %v", err) + } + if svcStoredPrice == nil || *svcStoredPrice != svcOverridePrice { + t.Errorf("expected service override_price %.2f, got %v", svcOverridePrice, svcStoredPrice) + } + if svcStoredDuration == nil || *svcStoredDuration != svcOverrideDuration { + t.Errorf("expected service override_duration_minutes %d, got %v", svcOverrideDuration, svcStoredDuration) + } +} + +func TestBookings_GetBooking_WithCustomServices(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + csID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, csID) + + london, _ := time.LoadLocation("Europe/London") + startTime := nextWeekday(time.Tuesday, london).Add(10 * time.Hour) + + var bookingID string + err = db.DB.QueryRow(context.Background(), ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'pending') + RETURNING id + `, userID, startTime).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, bookingID) + + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_custom_services (booking_id, custom_service_id) + VALUES ($1, $2) + `, bookingID, csID) + if err != nil { + t.Fatalf("failed to link custom service: %v", err) + } + + token := jwt.GenerateUserToken(userID) + handler := http.HandlerFunc(GetBookingHandler) + w := makeAuthRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token, "") + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var booking Booking + if err := parseResponseBody(w, &booking); err != nil { + t.Fatalf("failed to parse booking: %v", err) + } + + if len(booking.Services) == 0 { + t.Fatal("expected at least one service in response") + } + + found := false + for _, s := range booking.Services { + if s.ServiceID == csID { + found = true + if s.ServiceName == nil || *s.ServiceName != "Test Custom Service" { + t.Errorf("expected service name 'Test Custom Service', got %v", s.ServiceName) + } + if s.Price == nil || *s.Price != 75.00 { + t.Errorf("expected price 75.00, got %v", s.Price) + } + if s.DurationMinutes == nil || *s.DurationMinutes != 45 { + t.Errorf("expected duration 45, got %v", s.DurationMinutes) + } + break + } + } + if !found { + t.Errorf("custom service %s not found in booking services", csID) + } +} + +func TestBookings_Confirm_CustomOverrideValidation(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + csID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, csID) + + london, _ := time.LoadLocation("Europe/London") + startTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour) + + var bookingID string + err = db.DB.QueryRow(context.Background(), ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'pending') + RETURNING id + `, userID, startTime).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, bookingID) + + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_custom_services (booking_id, custom_service_id) + VALUES ($1, $2) + `, bookingID, csID) + if err != nil { + t.Fatalf("failed to link custom service: %v", err) + } + + token := jwt.GenerateUserToken(userID) + handler := http.HandlerFunc(ConfirmBookingHandler) + + t.Run("negative override price", func(t *testing.T) { + negPrice := -10.0 + validDur := 30 + req := ConfirmBookingRequest{ + CustomServiceOverrides: []ServiceOverride{ + {ServiceID: csID, OverridePrice: &negPrice, OverrideDurationMinutes: &validDur}, + }, + } + w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "") + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for negative price, got %d. body: %s", w.Code, w.Body.String()) + } + }) + + t.Run("non-positive override duration", func(t *testing.T) { + validPrice := 50.0 + zeroDur := 0 + req := ConfirmBookingRequest{ + CustomServiceOverrides: []ServiceOverride{ + {ServiceID: csID, OverridePrice: &validPrice, OverrideDurationMinutes: &zeroDur}, + }, + } + w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "") + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for zero duration, got %d. body: %s", w.Code, w.Body.String()) + } + }) + + t.Run("negative override duration", func(t *testing.T) { + validPrice := 50.0 + negDur := -10 + req := ConfirmBookingRequest{ + CustomServiceOverrides: []ServiceOverride{ + {ServiceID: csID, OverridePrice: &validPrice, OverrideDurationMinutes: &negDur}, + }, + } + w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "") + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for negative duration, got %d. body: %s", w.Code, w.Body.String()) + } + }) +} + +func TestBookings_Confirm_CustomServiceNotInBooking(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + csID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, csID) + + london, _ := time.LoadLocation("Europe/London") + startTime := nextWeekday(time.Thursday, london).Add(10 * time.Hour) + + var bookingID string + err = db.DB.QueryRow(context.Background(), ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'pending') + RETURNING id + `, userID, startTime).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, bookingID) + + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_services (booking_id, service_id) + VALUES ($1, $2) + `, bookingID, serviceID) + if err != nil { + t.Fatalf("failed to link service: %v", err) + } + + overridePrice := 80.0 + overrideDuration := 50 + req := ConfirmBookingRequest{ + CustomServiceOverrides: []ServiceOverride{ + {ServiceID: csID, OverridePrice: &overridePrice, OverrideDurationMinutes: &overrideDuration}, + }, + } + + token := jwt.GenerateUserToken(userID) + handler := http.HandlerFunc(ConfirmBookingHandler) + w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "") + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) + } + + if !bytes.Contains(w.Body.Bytes(), []byte("do not belong to this booking")) { + t.Errorf("expected error about custom service not belonging to booking, got: %s", w.Body.String()) + } + + var status string + err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) + if err != nil { + t.Fatalf("failed to query booking status: %v", err) + } + if status != "pending" { + t.Errorf("expected booking to remain 'pending' after failed confirm, got '%s'", status) + } +} + +func TestBookings_Progress_WithCustomServices(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + csID, err := fixtures.CreateTestCustomService(db.DB) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + defer fixtures.DeleteCustomService(db.DB, csID) + + london, _ := time.LoadLocation("Europe/London") + startTime := nextWeekday(time.Friday, london).Add(10 * time.Hour) + + var bookingID string + err = db.DB.QueryRow(context.Background(), ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'pending') + RETURNING id + `, userID, startTime).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, bookingID) + + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_custom_services (booking_id, custom_service_id) + VALUES ($1, $2) + `, bookingID, csID) + if err != nil { + t.Fatalf("failed to link custom service: %v", err) + } + + token := jwt.GenerateUserToken(userID) + handler := http.HandlerFunc(ProgressBookingHandler) + + req := ProgressBookingRequest{Status: "completed"} + w := makeAuthRequest(handler, "PUT", "/api/bookings/"+bookingID+"/progress", req, token, "") + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var booking Booking + if err := parseResponseBody(w, &booking); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if booking.Status != "completed" { + t.Errorf("expected booking status 'completed', got '%s'", booking.Status) + } + + var status string + err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) + if err != nil { + t.Fatalf("failed to query booking status: %v", err) + } + if status != "completed" { + t.Errorf("expected DB status 'completed', got '%s'", status) + } +}