Files
Crussell/backend/handlers/admin/update_booking_services_test.go
T
popertotsandSisyphus b03c4f6247 refactor(backend): replace resetTestData with SetupTestDB and add new tests
Migrate all test files from resetTestData(t) to testutils.SetupTestDB(t) for isolated per-package test databases.

- Add new feature tests: name history assertions, referral discount preview,
  time blockers, email validation, GDPR export, loyalty manual redemption
- Update existing tests to use batch queries and SetupTestDB
- Remove test_helpers.go resetTestData infrastructure
- Add comprehensive user profile tests (442 new lines)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-20 16:57:36 +01:00

1278 lines
41 KiB
Go

//go:build test
// +build test
package admin
// Test coverage for UpdateBookingServicesHandler: PUT /api/admin/bookings/{id}
//
// Tests cover:
// - Successful service replacement, addition, removal
// - Price and duration overrides
// - Notes update
// - Validation: invalid IDs, empty services, missing booking, invalid overrides
// - Status rejection: completed, cancelled, no_show
// - Overlap detection with next booking
// - Response shape verification
import (
"context"
"net/http"
"testing"
"time"
"crussell/db"
"crussell/testutils"
"crussell/handlers/bookings"
"crussell/testutils/fixtures"
)
// =============================================================================
// Helpers
// =============================================================================
// createBookingWithStartTime creates a booking at a specific start time with the given service
func createBookingWithStartTime(t *testing.T, userID, serviceID string, startTime time.Time, status string) string {
t.Helper()
ctx := context.Background()
var bookingID string
err := db.DB.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, notes)
VALUES ($1, $2, $3, $4)
RETURNING id
`, userID, startTime, status, "").Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = db.DB.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to link service to booking: %v", err)
}
return bookingID
}
// createSecondService creates an additional test service with the given duration
func createSecondService(t *testing.T, name string, durationMinutes int, price float64) string {
t.Helper()
ctx := context.Background()
var serviceID string
err := db.DB.QueryRow(ctx, `
INSERT INTO services (name, description, price, duration_minutes, is_active)
VALUES ($1, $2, $3, $4, true)
RETURNING id
`, name, "Test service: "+name, price, durationMinutes).Scan(&serviceID)
if err != nil {
t.Fatalf("failed to create service %s: %v", name, err)
}
return serviceID
}
// =============================================================================
// Success Cases
// =============================================================================
// TestAdminBookings_UpdateServices_ReplaceServices verifies that an admin can
// replace all services on a booking with a new set of services.
func TestAdminBookings_UpdateServices_ReplaceServices(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service 1: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
service2 := createSecondService(t, "Service Two", 45, 55.00)
defer fixtures.DeleteService(db.DB, service2)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service2},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.Booking
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, got %d", len(resp.Services))
}
if resp.Services[0].ServiceID != service2 {
t.Errorf("expected service %s, got %s", service2, resp.Services[0].ServiceID)
}
}
// TestAdminBookings_UpdateServices_AddService verifies that an admin can add
// additional services to an existing booking.
func TestAdminBookings_UpdateServices_AddService(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service 1: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
service2 := createSecondService(t, "Service Two", 30, 40.00)
defer fixtures.DeleteService(db.DB, service2)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1, service2},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.Booking
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Services) != 2 {
t.Errorf("expected 2 services, got %d", len(resp.Services))
}
}
// TestAdminBookings_UpdateServices_RemoveService verifies that an admin can
// remove services from a booking by providing fewer service IDs.
func TestAdminBookings_UpdateServices_RemoveService(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service 1: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
service2 := createSecondService(t, "Service Two", 30, 40.00)
defer fixtures.DeleteService(db.DB, service2)
// Create booking with service1
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID)
// Manually add service2 to the booking
ctx := context.Background()
_, err = db.DB.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, service2)
if err != nil {
t.Fatalf("failed to add second service: %v", err)
}
// Update to only service1 (removing service2)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.Booking
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 after removal, got %d", len(resp.Services))
}
}
// TestAdminBookings_UpdateServices_WithPriceOverride verifies that an admin can
// apply a price override to a service.
func TestAdminBookings_UpdateServices_WithPriceOverride(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
"service_overrides": []map[string]interface{}{
{
"service_id": service1,
"override_price": 25.00,
},
},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.Booking
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Services[0].OverridePrice == nil {
t.Fatal("expected override_price to be set")
}
if *resp.Services[0].OverridePrice != 25.00 {
t.Errorf("expected override_price 25.00, got %f", *resp.Services[0].OverridePrice)
}
}
// TestAdminBookings_UpdateServices_WithDurationOverride verifies that an admin can
// apply a duration override to a service.
func TestAdminBookings_UpdateServices_WithDurationOverride(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
"service_overrides": []map[string]interface{}{
{
"service_id": service1,
"override_duration_minutes": 90,
},
},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.Booking
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Services[0].OverrideDurationMinutes == nil {
t.Fatal("expected override_duration_minutes to be set")
}
if *resp.Services[0].OverrideDurationMinutes != 90 {
t.Errorf("expected override_duration_minutes 90, got %d", *resp.Services[0].OverrideDurationMinutes)
}
}
// TestAdminBookings_UpdateServices_WithBothOverrides verifies that an admin can
// apply both price and duration overrides simultaneously.
func TestAdminBookings_UpdateServices_WithBothOverrides(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
"service_overrides": []map[string]interface{}{
{
"service_id": service1,
"override_price": 30.00,
"override_duration_minutes": 75,
},
},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.Booking
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Services[0].OverridePrice == nil || *resp.Services[0].OverridePrice != 30.00 {
t.Errorf("expected override_price 30.00, got %v", resp.Services[0].OverridePrice)
}
if resp.Services[0].OverrideDurationMinutes == nil || *resp.Services[0].OverrideDurationMinutes != 75 {
t.Errorf("expected override_duration_minutes 75, got %v", resp.Services[0].OverrideDurationMinutes)
}
}
// TestAdminBookings_UpdateServices_UpdateNotes verifies that an admin can update
// the booking notes along with services.
func TestAdminBookings_UpdateServices_UpdateNotes(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
"notes": "Updated notes for this booking",
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.Booking
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Notes == nil || *resp.Notes != "Updated notes for this booking" {
t.Errorf("expected notes 'Updated notes for this booking', got %v", resp.Notes)
}
}
// TestAdminBookings_UpdateServices_MultipleOverrides verifies that an admin can
// apply overrides to multiple services in a single request.
func TestAdminBookings_UpdateServices_MultipleOverrides(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service 1: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
service2 := createSecondService(t, "Service Two", 45, 55.00)
defer fixtures.DeleteService(db.DB, service2)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1, service2},
"service_overrides": []map[string]interface{}{
{
"service_id": service1,
"override_price": 20.00,
},
{
"service_id": service2,
"override_duration_minutes": 60,
},
},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.Booking
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Services) != 2 {
t.Fatalf("expected 2 services, got %d", len(resp.Services))
}
// Find each service and verify its override
for _, svc := range resp.Services {
if svc.ServiceID == service1 {
if svc.OverridePrice == nil || *svc.OverridePrice != 20.00 {
t.Errorf("service1: expected override_price 20.00, got %v", svc.OverridePrice)
}
}
if svc.ServiceID == service2 {
if svc.OverrideDurationMinutes == nil || *svc.OverrideDurationMinutes != 60 {
t.Errorf("service2: expected override_duration_minutes 60, got %v", svc.OverrideDurationMinutes)
}
}
}
}
// =============================================================================
// Validation Error Cases
// =============================================================================
// TestAdminBookings_UpdateServices_InvalidBookingID verifies that an invalid
// booking ID returns 404.
func TestAdminBookings_UpdateServices_InvalidBookingID(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/invalid-id", reqBody)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminBookings_UpdateServices_BookingNotFound verifies that a valid-format
// but non-existent booking ID returns 404.
func TestAdminBookings_UpdateServices_BookingNotFound(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/abc123def456", reqBody)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminBookings_UpdateServices_EmptyServiceIDs verifies that an empty
// service_ids array returns 400.
func TestAdminBookings_UpdateServices_EmptyServiceIDs(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminBookings_UpdateServices_InvalidServiceID verifies that an invalid
// service ID in the list returns 400.
func TestAdminBookings_UpdateServices_InvalidServiceID(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{"invalid-service-id"},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminBookings_UpdateServices_ServiceNotFound verifies that a valid-format
// but non-existent service ID returns 400.
func TestAdminBookings_UpdateServices_ServiceNotFound(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{"abc123def456"},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminBookings_UpdateServices_NegativePriceOverride verifies that a negative
// price override returns 400.
func TestAdminBookings_UpdateServices_NegativePriceOverride(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
"service_overrides": []map[string]interface{}{
{
"service_id": service1,
"override_price": -10.00,
},
},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminBookings_UpdateServices_ZeroDurationOverride verifies that a zero or
// negative duration override returns 400.
func TestAdminBookings_UpdateServices_ZeroDurationOverride(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
"service_overrides": []map[string]interface{}{
{
"service_id": service1,
"override_duration_minutes": 0,
},
},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// Status Rejection Cases
// =============================================================================
// TestAdminBookings_UpdateServices_CompletedBookingRejected verifies that
// updating services on a completed booking returns 403.
func TestAdminBookings_UpdateServices_CompletedBookingRejected(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(-1*time.Hour), "completed")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminBookings_UpdateServices_CancelledBookingRejected verifies that
// updating services on a cancelled booking returns 403.
func TestAdminBookings_UpdateServices_CancelledBookingRejected(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "client_cancelled")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminBookings_UpdateServices_NoShowBookingRejected verifies that
// updating services on a no-show booking returns 403.
func TestAdminBookings_UpdateServices_NoShowBookingRejected(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "no_show")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminBookings_UpdateServices_WeCancelledBookingRejected verifies that
// updating services on a we_cancelled booking returns 403.
func TestAdminBookings_UpdateServices_WeCancelledBookingRejected(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "we_cancelled")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// Overlap Detection Cases
// =============================================================================
// TestAdminBookings_UpdateServices_OverlapWithNextBooking verifies that extending
// a booking's duration to overlap with the next booking returns 409.
func TestAdminBookings_UpdateServices_OverlapWithNextBooking(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
// Create a long-duration service for the overlap test
longService := createSecondService(t, "Long Service", 300, 100.00) // 5 hours
defer fixtures.DeleteService(db.DB, longService)
now := time.Now()
// Booking 1 at 10:00 tomorrow
booking1Start := now.Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
bookingID1 := createBookingWithStartTime(t, userID, service1, booking1Start, "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID1)
// Booking 2 at 11:00 tomorrow (1 hour after booking 1)
booking2Start := booking1Start.Add(1 * time.Hour)
bookingID2 := createBookingWithStartTime(t, userID, service1, booking2Start, "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID2)
// Try to update booking 1 to use the 5-hour service (would overlap booking 2)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{longService},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID1, reqBody)
if w.Code != http.StatusConflict {
t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminBookings_UpdateServices_NoOverlapSucceeds verifies that a service update
// that does not overlap with the next booking succeeds.
func TestAdminBookings_UpdateServices_NoOverlapSucceeds(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
service2 := createSecondService(t, "Service Two", 30, 40.00)
defer fixtures.DeleteService(db.DB, service2)
now := time.Now()
// Booking 1 at 10:00 tomorrow
booking1Start := now.Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
bookingID1 := createBookingWithStartTime(t, userID, service1, booking1Start, "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID1)
// Booking 2 at 14:00 tomorrow (4 hours after booking 1 starts)
booking2Start := booking1Start.Add(4 * time.Hour)
bookingID2 := createBookingWithStartTime(t, userID, service1, booking2Start, "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID2)
// Update booking 1 to have both services (total ~60 min, well within the 4-hour gap)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1, service2},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID1, reqBody)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminBookings_UpdateServices_NoNextBookingSucceeds verifies that a service
// update succeeds when there is no next booking (no overlap possible).
func TestAdminBookings_UpdateServices_NoNextBookingSucceeds(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
longService := createSecondService(t, "Long Service", 300, 100.00)
defer fixtures.DeleteService(db.DB, longService)
// Only booking for the day — no next booking to conflict with
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{longService},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// Response Shape Verification
// =============================================================================
// TestAdminBookings_UpdateServices_ResponseShape verifies that the response
// contains all expected fields after a successful update.
func TestAdminBookings_UpdateServices_ResponseShape(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
"notes": "Test notes for response shape verification",
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.Booking
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
// Verify all top-level fields are present
if resp.ID == "" {
t.Error("expected non-empty ID")
}
if resp.StartTime.IsZero() {
t.Error("expected non-zero StartTime")
}
if resp.Status == "" {
t.Error("expected non-empty Status")
}
if resp.Notes == nil {
t.Error("expected non-nil Notes")
}
if resp.User == nil {
t.Error("expected non-nil User")
}
if resp.User.FullName == "" {
t.Error("expected non-empty User.FullName")
}
if len(resp.Services) == 0 {
t.Error("expected at least one service in response")
}
}
// =============================================================================
// Edge Cases
// =============================================================================
// TestAdminBookings_UpdateServices_PendingBooking verifies that services can be
// updated on a pending booking.
func TestAdminBookings_UpdateServices_PendingBooking(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "pending")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminBookings_UpdateServices_InProgressBooking verifies that services can be
// updated on an in_progress booking.
func TestAdminBookings_UpdateServices_InProgressBooking(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(-30*time.Minute), "in_progress")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminBookings_UpdateServices_ClearNotes verifies that setting notes to an
// empty string updates the booking notes accordingly.
func TestAdminBookings_UpdateServices_ClearNotes(t *testing.T) {
testutils.SetupTestDB(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)
service1, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, service1)
bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed")
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
"notes": "",
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
}