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:
2026-06-15 16:58:19 +01:00
co-authored by Sisyphus
parent ad0af39237
commit c62e8322b5
3 changed files with 1790 additions and 0 deletions
+490
View File
@@ -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)
}
}