Files
Crussell/backend/handlers/admin/update_booking_services_test.go
T
popertotsandSisyphus e4b9003439 refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns
Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:50 +01:00

1185 lines
37 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/clock"
"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, tx db.Querier, ctx context.Context, userID, serviceID string, startTime time.Time, status string) string {
t.Helper()
var bookingID string
err := tx.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 = tx.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, tx db.Querier, ctx context.Context, name string, durationMinutes int, price float64) string {
t.Helper()
var serviceID string
err := tx.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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service 1: %v", err)
}
service2 := createSecondService(t, tx, ctx, "Service Two", 45, 55.00)
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "confirmed")
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service2},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service 1: %v", err)
}
service2 := createSecondService(t, tx, ctx, "Service Two", 30, 40.00)
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "confirmed")
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1, service2},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service 1: %v", err)
}
service2 := createSecondService(t, tx, ctx, "Service Two", 30, 40.00)
// Create booking with service1
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "confirmed")
// Manually add service2 to the booking
_, err = tx.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, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "confirmed")
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, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "confirmed")
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, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "confirmed")
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, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "confirmed")
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, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service 1: %v", err)
}
service2 := createSecondService(t, tx, ctx, "Service Two", 45, 55.00)
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "confirmed")
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, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/invalid-id", reqBody, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/abc123def456", reqBody, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "confirmed")
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "confirmed")
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{"invalid-service-id"},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "confirmed")
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{"abc123def456"},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "confirmed")
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, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "confirmed")
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, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(-1*time.Hour), "completed")
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "client_cancelled")
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "no_show")
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "we_cancelled")
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Create a long-duration service for the overlap test
longService := createSecondService(t, tx, ctx, "Long Service", 300, 100.00) // 5 hours
now := clock.Now()
// Booking 1 at 10:00 tomorrow
booking1Start := now.Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
bookingID1 := createBookingWithStartTime(t, tx, ctx, userID, service1, booking1Start, "confirmed")
// Booking 2 at 11:00 tomorrow (1 hour after booking 1)
booking2Start := booking1Start.Add(1 * time.Hour)
_ = createBookingWithStartTime(t, tx, ctx, userID, service1, booking2Start, "confirmed")
// 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, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
service2 := createSecondService(t, tx, ctx, "Service Two", 30, 40.00)
now := clock.Now()
// Booking 1 at 10:00 tomorrow
booking1Start := now.Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
bookingID1 := createBookingWithStartTime(t, tx, ctx, userID, service1, booking1Start, "confirmed")
// Booking 2 at 14:00 tomorrow (4 hours after booking 1 starts)
booking2Start := booking1Start.Add(4 * time.Hour)
_ = createBookingWithStartTime(t, tx, ctx, userID, service1, booking2Start, "confirmed")
// 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, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
longService := createSecondService(t, tx, ctx, "Long Service", 300, 100.00)
// Only booking for the day — no next booking to conflict with
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "confirmed")
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{longService},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "confirmed")
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, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "pending")
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(-30*time.Minute), "in_progress")
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
service1, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID := createBookingWithStartTime(t, tx, ctx, userID, service1, clock.Now().Add(24*time.Hour), "confirmed")
handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler)
reqBody := map[string]interface{}{
"service_ids": []string{service1},
"notes": "",
}
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
}