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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user