test(backend): add comprehensive test suite for custom services feature
Add 18 test functions for admin custom services CRUD handlers, 6 tests for admin booking creation with custom services, and 5 tests for booking custom service integrations (confirm overrides, get, validation, progress). Also fix FK constraint issues in test request creation. Ultraworked with Sisyphus (https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user