diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 420c46a..8bd547c 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -456,6 +456,8 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) { defer rows.Close() var bookings []Booking + var bookingIDs []string + for rows.Next() { var b Booking var createdBy sql.NullString @@ -481,7 +483,88 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) { b.AmountDue = totalAmount - amountPaid b.DurationMinutes = durationMinutes populateDepositFields(&b, depositRequired, preStartAmountPaid) + b.Services = []BookingService{} bookings = append(bookings, b) + bookingIDs = append(bookingIDs, b.ID) + } + + if len(bookingIDs) > 0 { + serviceRows, err := db.DB.Query(r.Context(), ` + SELECT + bs.service_id, bs.override_price, bs.override_duration_minutes, + s.name, s.description, s.price, s.duration_minutes, + bs.booking_id + FROM booking_services bs + LEFT JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = ANY($1) + UNION ALL + SELECT + bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes, + cs.name, cs.description, cs.price, cs.duration_minutes, + bcs.booking_id + FROM booking_custom_services bcs + LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id + WHERE bcs.booking_id = ANY($1) + ORDER BY booking_id, name + `, bookingIDs) + if err != nil { + log.Printf("Failed to fetch booking services: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer serviceRows.Close() + + servicesByBooking := make(map[string][]BookingService) + for serviceRows.Next() { + var s BookingService + var overridePrice sql.NullFloat64 + var overrideDuration sql.NullInt32 + var name, description sql.NullString + var basePrice sql.NullFloat64 + var baseDuration sql.NullInt32 + var bookingID string + + if err := serviceRows.Scan( + &s.ServiceID, + &overridePrice, &overrideDuration, + &name, &description, &basePrice, &baseDuration, + &bookingID, + ); err != nil { + log.Printf("Failed to scan service row: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + s.BookingID = bookingID + + if overridePrice.Valid { + s.OverridePrice = &overridePrice.Float64 + } + if overrideDuration.Valid { + d := int(overrideDuration.Int32) + s.OverrideDurationMinutes = &d + } + if name.Valid { + s.ServiceName = &name.String + } + if description.Valid { + s.ServiceDescription = &description.String + } + if basePrice.Valid { + s.Price = &basePrice.Float64 + } + if baseDuration.Valid { + d := int(baseDuration.Int32) + s.DurationMinutes = &d + } + + servicesByBooking[bookingID] = append(servicesByBooking[bookingID], s) + } + for i := range bookings { + if services, exists := servicesByBooking[bookings[i].ID]; exists { + bookings[i].Services = services + } + } } w.Header().Set("Content-Type", "application/json") diff --git a/backend/handlers/bookings/bookings_test.go b/backend/handlers/bookings/bookings_test.go index 8036450..64d287e 100644 --- a/backend/handlers/bookings/bookings_test.go +++ b/backend/handlers/bookings/bookings_test.go @@ -4202,6 +4202,215 @@ func TestBookings_Get_DepositFieldsReturned(t *testing.T) { } } +// ============================================================================= +// Services End-to-End Tests +// ============================================================================= + +// TestBookings_Get_ServicesReturned verifies that GET /api/bookings returns +// services with correct name, price, and duration for each booking. +func TestBookings_Get_ServicesReturned(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) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + london, _ := time.LoadLocation("Europe/London") + startTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour) + + token := jwt.GenerateUserToken(userID) + + // Create booking with standard service via the handler (production path) + req := CreateBookingRequest{ + StartTime: startTime, + ServiceIDs: []string{serviceID}, + } + handler := http.HandlerFunc(CreateBookingHandler) + w := makeRequest(handler, "POST", "/api/bookings", req, token) + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d. body: %s", w.Code, w.Body.String()) + } + + // Fetch bookings list + w = makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp BookingListResponse + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if len(resp.Bookings) == 0 { + t.Fatal("expected at least one booking") + } + + booking := resp.Bookings[0] + if booking.Services == nil { + t.Fatal("expected services to be non-nil, got nil") + } + if len(booking.Services) == 0 { + t.Fatal("expected at least one service in booking") + } + + svc := booking.Services[0] + if svc.ServiceID != serviceID { + t.Errorf("expected service_id %s, got %s", serviceID, svc.ServiceID) + } + if svc.ServiceName == nil || *svc.ServiceName != "Test Service" { + t.Errorf("expected service_name 'Test Service', got %v", svc.ServiceName) + } + if svc.Price == nil || *svc.Price != 50.00 { + t.Errorf("expected price 50.00, got %v", svc.Price) + } + if svc.DurationMinutes == nil || *svc.DurationMinutes != 60 { + t.Errorf("expected duration 60, got %v", svc.DurationMinutes) + } +} + +// TestBookings_Get_CustomServicesReturned verifies that GET /api/bookings +// returns custom services correctly. +func TestBookings_Get_CustomServicesReturned(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) + + 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) + + // Insert booking + custom service link directly + 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) + w := makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp BookingListResponse + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if len(resp.Bookings) == 0 { + t.Fatal("expected at least one booking") + } + + booking := resp.Bookings[0] + if booking.Services == nil { + t.Fatal("expected services to be non-nil, got nil") + } + if len(booking.Services) == 0 { + t.Fatal("expected at least one service in booking") + } + + 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) + } +} + +// TestBookings_Get_EmptyServices verifies that GET /api/bookings returns an +// empty array (not null) for bookings with no services. +func TestBookings_Get_EmptyServices(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) + + london, _ := time.LoadLocation("Europe/London") + startTime := nextWeekday(time.Friday, london).Add(10 * time.Hour) + + // Insert booking with NO services at all + 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) + + token := jwt.GenerateUserToken(userID) + w := makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp BookingListResponse + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if len(resp.Bookings) == 0 { + t.Fatal("expected at least one booking") + } + + booking := resp.Bookings[0] + if booking.Services == nil { + t.Fatal("expected services to be non-nil (empty array), got nil") + } + if len(booking.Services) != 0 { + t.Errorf("expected empty services array, got %d services", len(booking.Services)) + } +} + // TestBookings_Edit_ClosedDay_UserBlocked verifies that a regular user cannot edit a booking // to fall on a closed day (exceptional hours marked as is_open=false). func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) {