//go:build test // +build test package admin // Package admin contains tests for admin booking management endpoints. // // Test Coverage: // - GetAllAdminBookingsHandler: GET /api/admin/bookings - List all bookings with filters // - GetAllAdminBookingsHandler Pagination: per_page cap up to 500, fallback to 10 // - SearchAdminBookingsHandler: POST /api/admin/bookings/search - Search bookings // - GetAdminBookingHandler: GET /api/admin/bookings/{id} - Get booking details // - AdminCreateBookingForUserHandler: POST /api/admin/bookings - Create booking for user // - ProgressBookingHandler: PUT /api/admin/bookings/{id}/progress - Update booking status // - ConfirmBookingHandler: POST /api/admin/bookings/{id}/confirm - Confirm booking // - AdminCancelBookingHandler: POST /api/admin/bookings/{id}/cancel - Cancel booking // - AdminListEditRequestsHandler: GET /api/admin/bookings/edit-requests - List edit requests // - AdminApproveEditRequestHandler: POST /api/admin/bookings/{id}/approve-edit - Approve edit // - AdminRejectEditRequestHandler: POST /api/admin/bookings/{id}/reject-edit - Reject edit // // Authentication: All endpoints require admin role (403 for non-admins). import ( "bytes" "context" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "crussell/db" "crussell/testutils" "crussell/handlers/bookings" "crussell/mw" "crussell/testutils/fixtures" "github.com/go-chi/chi/v5" ) // ============================================================================= // List Admin Bookings Tests // ============================================================================= // TestAdminBookings_List verifies that an admin can list all bookings in the // system with pagination support. func TestAdminBookings_List(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Insert name history for the user _, err = tx.Exec(ctx, ` INSERT INTO name_history (user_id, previous_first_name, previous_last_name) VALUES ($1, 'OldFirst', 'OldLast') `, userID) if err != nil { t.Fatalf("failed to insert name_history: %v", err) } _, err = fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking 1: %v", err) } _, err = fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking 2: %v", err) } handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler) w := makeAdminRequest(handler, "GET", "/api/admin/bookings", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp bookings.BookingListResponse if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Bookings) != 2 { t.Errorf("expected 2 bookings, got %d", len(resp.Bookings)) } if resp.Total != 2 { t.Errorf("expected total 2, got %d", resp.Total) } // Verify name history appears in booking user info for _, b := range resp.Bookings { if b.User == nil { continue } if b.User.PreviousFirstName == nil || *b.User.PreviousFirstName != "OldFirst" { t.Errorf("expected previousFirstName 'OldFirst' in booking, got %v", b.User.PreviousFirstName) } if b.User.PreviousLastName == nil || *b.User.PreviousLastName != "OldLast" { t.Errorf("expected previousLastName 'OldLast' in booking, got %v", b.User.PreviousLastName) } } } // TestAdminBookings_List_PerPageCap tests that per_page parameter up to 500 // is accepted (was previously capped at 100, causing per_page=500 to fall // back to default 10 and miss bookings on page 2+). func TestAdminBookings_List_PerPageCap(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Create 3 bookings so we can verify per_page=500 returns all of them for i := 0; i < 3; i++ { _, err = fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking %d: %v", i+1, err) } } // Test per_page=500 (should be accepted and return all 3 bookings) handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler) w := makeAdminRequest(handler, "GET", "/api/admin/bookings?per_page=500", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp bookings.BookingListResponse if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Bookings) != 3 { t.Errorf("expected 3 bookings with per_page=500, got %d (per_page was likely rejected)", len(resp.Bookings)) } if resp.PerPage != 500 { t.Errorf("expected per_page=500 in response, got %d", resp.PerPage) } if resp.Total != 3 { t.Errorf("expected total 3, got %d", resp.Total) } // Test per_page=600 (should be rejected and fall back to default 10) w2 := makeAdminRequest(handler, "GET", "/api/admin/bookings?per_page=600", nil, ctx) if w2.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w2.Code) } var resp2 bookings.BookingListResponse if err := parseResponseBody(w2, &resp2); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp2.PerPage != 10 { t.Errorf("expected per_page defaults to 10 when >500, got %d", resp2.PerPage) } } // TestAdminBookings_List_FilterByStatus tests that an admin can filter // bookings by status (e.g., pending, confirmed, completed). func TestAdminBookings_List_FilterByStatus(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } _, err = fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler) w := makeAdminRequest(handler, "GET", "/api/admin/bookings?status=pending", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) } var resp bookings.BookingListResponse if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Bookings) != 1 { t.Errorf("expected 1 pending booking, got %d", len(resp.Bookings)) } w = makeAdminRequest(handler, "GET", "/api/admin/bookings?status=completed", nil, ctx) 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.Bookings) != 0 { t.Errorf("expected 0 bookings for completed status, got %d", len(resp.Bookings)) } } // ============================================================================= // Admin Create Booking Tests // ============================================================================= // TestAdminBookings_Create verifies that an admin can create a booking // on behalf of a user. The booking is created with 'confirmed' status. func TestAdminBookings_Create(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) req := bookings.AdminCreateBookingForUserRequest{ UserID: userID, StartTime: futureTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } // Parse response with new format {"booking": {...}, "warnings": [...]} 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") } status, ok := bookingData["status"].(string) if !ok { t.Fatal("expected status in booking") } if status != "confirmed" { t.Errorf("expected status 'confirmed', got %s", status) } var count int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM bookings WHERE user_id = $1", userID).Scan(&count) if err != nil { t.Errorf("failed to query bookings: %v", err) } if count != 1 { t.Errorf("expected 1 booking, got %d", count) } } // TestAdminBookings_Create_InvalidInput verifies that admin booking // creation fails with HTTP 400 when required fields (userID, startTime, serviceIDs) // are missing or invalid. func TestAdminBookings_Create_InvalidInput(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) } tests := []struct { name string req bookings.AdminCreateBookingForUserRequest }{ { name: "missing user ID", req: bookings.AdminCreateBookingForUserRequest{ StartTime: time.Now().Add(72 * time.Hour), ServiceIDs: []string{"some-service-id"}, }, }, { name: "missing start time", req: bookings.AdminCreateBookingForUserRequest{ UserID: userID, ServiceIDs: []string{"some-service-id"}, }, }, { name: "missing service IDs", req: bookings.AdminCreateBookingForUserRequest{ UserID: userID, StartTime: time.Now().Add(72 * time.Hour), }, }, { name: "empty service IDs", req: bookings.AdminCreateBookingForUserRequest{ UserID: userID, StartTime: time.Now().Add(72 * time.Hour), ServiceIDs: []string{}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings", tt.req, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d", w.Code) } }) } } // ============================================================================= // Search Admin Bookings Tests // ============================================================================= // TestAdminBookings_Search tests that an admin can search bookings by // notes, customer name, or other text fields. func TestAdminBookings_Search(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET notes = 'Test booking for search' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to update booking notes: %v", err) } handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler) w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=Test", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp bookings.BookingListResponse if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Bookings) != 1 { t.Errorf("expected 1 booking, got %d", len(resp.Bookings)) } } // TestAdminBookings_Search_MissingQuery verifies that searching without // a query parameter returns HTTP 400 Bad Request. func TestAdminBookings_Search_MissingQuery(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) } handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler) w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search", nil, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d", w.Code) } } // ============================================================================= // Get Single Admin Booking Tests // ============================================================================= // TestAdminBookings_Get verifies that an admin can retrieve a single booking by ID and that // the response includes populated user and services relationships. func TestAdminBookings_Get(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } handler := http.HandlerFunc(bookings.GetAdminBookingHandler) w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil, ctx) if w.Code != http.StatusOK { t.Errorf("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.ID != bookingID { t.Errorf("expected booking ID %s, got %s", bookingID, booking.ID) } if booking.User == nil { t.Error("expected user to be populated") } if len(booking.Services) != 1 { t.Errorf("expected 1 service, got %d", len(booking.Services)) } } // TestAdminBookings_Get_NotFound verifies that requesting a non-existent booking returns 404. func TestAdminBookings_Get_NotFound(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) } handler := http.HandlerFunc(bookings.GetAdminBookingHandler) w := makeAdminRequest(handler, "GET", "/api/admin/bookings/nonexistent-id", nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) } } // ============================================================================= // Get User's Bookings Tests (Admin view) // ============================================================================= // TestAdminBookings_GetUserBookings verifies that an admin can retrieve all bookings for a specific user. func TestAdminBookings_GetUserBookings(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } _, err = fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking 1: %v", err) } _, err = fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking 2: %v", err) } handler := http.HandlerFunc(bookings.GetAllBookingsByUserHandler) w := makeAdminRequest(handler, "GET", "/api/admin/bookings/user/"+userID, nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp bookings.BookingListResponse if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Bookings) != 2 { t.Errorf("expected 2 bookings, got %d", len(resp.Bookings)) } if resp.Total != 2 { t.Errorf("expected total 2, got %d", resp.Total) } } // ============================================================================= // Progress Booking Tests // ============================================================================= // TestAdminBookings_Progress verifies that an admin can change a booking's status (e.g., pending to confirmed), // and that the status is correctly updated in both the response and database. func TestAdminBookings_Progress(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } req := bookings.ProgressBookingRequest{ Status: "confirmed", } handler := http.HandlerFunc(bookings.ProgressBookingHandler) w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", req, ctx) if w.Code != http.StatusOK { t.Errorf("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) } var dbStatus string err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&dbStatus) if err != nil { t.Fatalf("failed to query booking: %v", err) } if dbStatus != "confirmed" { t.Errorf("expected status 'confirmed' in DB, got %s", dbStatus) } } // TestAdminBookings_Progress_InvalidStatus verifies that providing an invalid status value returns 400 Bad Request. func TestAdminBookings_Progress_InvalidStatus(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } req := bookings.ProgressBookingRequest{ Status: "invalid_status", } handler := http.HandlerFunc(bookings.ProgressBookingHandler) w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", req, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d", w.Code) } } // TestAdminBookings_Progress_NotFound verifies that attempting to progress a non-existent booking returns 404. func TestAdminBookings_Progress_NotFound(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) } req := bookings.ProgressBookingRequest{ Status: "confirmed", } handler := http.HandlerFunc(bookings.ProgressBookingHandler) w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/nonexistent-id/progress", req, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) } } // ============================================================================= // Confirm Booking Tests // ============================================================================= // TestAdminBookings_Confirm verifies that an admin can confirm a pending booking, updating its status to 'confirmed'. func TestAdminBookings_Confirm(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } req := bookings.ConfirmBookingRequest{} handler := http.HandlerFunc(bookings.ConfirmBookingHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/confirm", req, ctx) if w.Code != http.StatusOK { t.Errorf("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) } } // TestAdminBookings_Confirm_AlreadyConfirmed verifies idempotency - attempting to confirm // an already-confirmed booking returns 404 Not Found. func TestAdminBookings_Confirm_AlreadyConfirmed(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to update booking status: %v", err) } req := bookings.ConfirmBookingRequest{} handler := http.HandlerFunc(bookings.ConfirmBookingHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/confirm", req, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) } } // ============================================================================= // Cancel Booking Tests // ============================================================================= // TestAdminBookings_Cancel verifies that an admin can cancel a booking, updating its status to 'we_cancelled'. func TestAdminBookings_Cancel(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to update booking status: %v", err) } handler := http.HandlerFunc(bookings.AdminCancelBookingHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) } var dbStatus string err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&dbStatus) if err != nil { t.Fatalf("failed to query booking: %v", err) } if dbStatus != "we_cancelled" { t.Errorf("expected status 'we_cancelled' in DB, got %s", dbStatus) } } // TestAdminBookings_Cancel_NotFound verifies that attempting to cancel a non-existent booking returns 404. func TestAdminBookings_Cancel_NotFound(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) } handler := http.HandlerFunc(bookings.AdminCancelBookingHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings/nonexistent-id/cancel", nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) } } // TestAdminBookings_Cancel_PendingStatus verifies that admin cancellations of pending bookings // do NOT create admin notifications (pending cancellations don't require staff attention). func TestAdminBookings_Cancel_PendingStatus(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } // Booking stays in 'pending' status (no confirmation) handler := http.HandlerFunc(bookings.AdminCancelBookingHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) } // Verify status changed to we_cancelled var dbStatus string err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&dbStatus) if err != nil { t.Fatalf("failed to query booking: %v", err) } if dbStatus != "we_cancelled" { t.Errorf("expected status 'we_cancelled' in DB, got %s", dbStatus) } // Verify NO admin notification was created for pending cancellations var notifCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1`, bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query notifications: %v", err) } if notifCount != 0 { t.Errorf("expected 0 admin notifications for pending cancel, got %d", notifCount) } } // TestAdminBookings_Cancel_ConfirmedCreatesNotification verifies that cancelling a confirmed // booking creates an admin notification for staff awareness. func TestAdminBookings_Cancel_ConfirmedCreatesNotification(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } // Confirm the booking _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } handler := http.HandlerFunc(bookings.AdminCancelBookingHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) } // Verify status changed to we_cancelled var dbStatus string err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&dbStatus) if err != nil { t.Fatalf("failed to query booking: %v", err) } if dbStatus != "we_cancelled" { t.Errorf("expected status 'we_cancelled' in DB, got %s", dbStatus) } // Verify admin notification WAS created for confirmed->cancelled var notifCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'cancelled_booking'`, bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query notifications: %v", err) } if notifCount != 1 { t.Errorf("expected 1 admin notification for confirmed cancel, got %d", notifCount) } } // TestAdminBookings_Cancel_InProgressStatus verifies cancellation of in-progress bookings. func TestAdminBookings_Cancel_InProgressStatus(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } // Set booking to in-progress status _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set in_progress status: %v", err) } handler := http.HandlerFunc(bookings.AdminCancelBookingHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) } // Verify status changed to we_cancelled var dbStatus string err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&dbStatus) if err != nil { t.Fatalf("failed to query booking: %v", err) } if dbStatus != "we_cancelled" { t.Errorf("expected status 'we_cancelled' in DB, got %s", dbStatus) } } // TestAdminBookings_Cancel_AlreadyCancelledRejectsCancellation verifies that attempting to // cancel an already-cancelled booking returns 404 Not Found (idempotency guard). func TestAdminBookings_Cancel_AlreadyCancelledRejectsCancellation(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } // Set to already cancelled _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'we_cancelled' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set we_cancelled status: %v", err) } // Try to cancel again handler := http.HandlerFunc(bookings.AdminCancelBookingHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404 for already-cancelled booking, got %d", w.Code) } } // TestAdminBookings_Cancel_CompletedRejectsCancellation verifies that attempting to cancel // a completed booking returns 404 Not Found (cannot cancel finished appointments). func TestAdminBookings_Cancel_CompletedRejectsCancellation(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } // Set to completed _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'completed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to set completed status: %v", err) } // Try to cancel handler := http.HandlerFunc(bookings.AdminCancelBookingHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404 for completed booking, got %d", w.Code) } } // ============================================================================= // Non-Admin Tests // ============================================================================= // TestAdminBookings_NonAdmin verifies that regular users receive 403 Forbidden when attempting // to access any admin booking endpoints (list, get, create, progress, confirm, cancel, search). func TestAdminBookings_NonAdmin(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } w := makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.GetAllAdminBookingsHandler)), "GET", "/api/admin/bookings", nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("LIST: expected status 403, got %d", w.Code) } req := bookings.AdminCreateBookingForUserRequest{ UserID: userID, StartTime: time.Now().Add(72 * time.Hour), ServiceIDs: []string{serviceID}, } w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)), "POST", "/api/admin/bookings", req, ctx) if w.Code != http.StatusForbidden { t.Errorf("CREATE: expected status 403, got %d", w.Code) } w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.SearchAdminBookingsHandler)), "GET", "/api/admin/bookings/search?q=test", nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("SEARCH: expected status 403, got %d", w.Code) } w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.GetAdminBookingHandler)), "GET", "/api/admin/bookings/"+bookingID, nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("GET: expected status 403, got %d", w.Code) } progressReq := bookings.ProgressBookingRequest{Status: "confirmed"} w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.ProgressBookingHandler)), "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, ctx) if w.Code != http.StatusForbidden { t.Errorf("PROGRESS: expected status 403, got %d", w.Code) } confirmReq := bookings.ConfirmBookingRequest{} w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.ConfirmBookingHandler)), "POST", "/api/admin/bookings/"+bookingID+"/confirm", confirmReq, ctx) if w.Code != http.StatusForbidden { t.Errorf("CONFIRM: expected status 403, got %d", w.Code) } w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.AdminCancelBookingHandler)), "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx) if w.Code != http.StatusForbidden { t.Errorf("CANCEL: expected status 403, got %d", w.Code) } } // ============================================================================= // Admin Booking Holiday Conflict Tests // ============================================================================= // TestAdminBookings_Create_DuringHolidayHours_Rejected verifies that an admin cannot create a booking // during hours marked as closed in the exceptional working hours (holiday) system. func TestAdminBookings_Create_DuringHolidayHours_Rejected(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Create an exceptional (holiday) hours group for a fixed date (Thursday) // Using absolute date - no timezone conversions targetDate := time.Date(2026, 2, 26, 0, 0, 0, 0, time.UTC) // Thursday Feb 26, 2026 var groupID int err = tx.QueryRow(ctx, ` INSERT INTO exceptional_working_hours_groups (name, description) VALUES ($1, $2) RETURNING id `, "Holiday Closure", "Test holiday").Scan(&groupID) if err != nil { t.Fatalf("failed to create holiday group: %v", err) } // Add closed hours for targetDate (closed all day) // DB convention: 0=Monday..6=Sunday; Go: 0=Sunday..6=Saturday. Convert. dbWeekday := (int(targetDate.Weekday()) + 6) % 7 _, err = tx.Exec(ctx, ` INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, $2, $3, $4, $5) `, groupID, dbWeekday, "00:00:00", "23:59:59", false) if err != nil { t.Fatalf("failed to create holiday hours: %v", err) } // Apply the group to the week containing targetDate // Must use Monday of that week (matching handler logic) daysToMonday := int(targetDate.Weekday()) if daysToMonday == 0 { daysToMonday = 7 } mondayOfWeek := targetDate.AddDate(0, 0, -daysToMonday+1) _, err = tx.Exec(ctx, ` INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2) `, groupID, mondayOfWeek) if err != nil { t.Fatalf("failed to create holiday application: %v", err) } // Try to create booking during holiday - should fail targetTime := targetDate.Add(14 * time.Hour).Truncate(time.Second) // 2 PM on targetDate req := bookings.AdminCreateBookingForUserRequest{ UserID: userID, StartTime: targetTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) // Should be rejected (400 or 409 depending on implementation) if w.Code == http.StatusCreated { t.Errorf("expected booking to be rejected during holiday hours, but got 201") } } // ============================================================================= // Search Edge Case Tests // ============================================================================= // TestAdminBookings_Search_CaseInsensitive verifies that the admin booking search is case-insensitive, // matching booking notes regardless of uppercase/lowercase differences. func TestAdminBookings_Search_CaseInsensitive(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } // Update notes with mixed case _, err = tx.Exec(ctx, "UPDATE bookings SET notes = 'TestBooking With MixedCase' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to update booking notes: %v", err) } handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler) // Test 1: Uppercase search should find mixed case notes w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=TESTBOOKING", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) } var resp bookings.BookingListResponse if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Bookings) != 1 { t.Errorf("expected 1 booking for uppercase search, got %d", len(resp.Bookings)) } // Test 2: Lowercase search should also find w = makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=testbooking", nil, ctx) 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.Bookings) != 1 { t.Errorf("expected 1 booking for lowercase search, got %d", len(resp.Bookings)) } } // TestAdminBookings_Search_NoResults verifies that searching with a query that matches no bookings // returns an empty list with total count of 0. func TestAdminBookings_Search_NoResults(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } _, err = fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler) // Search with non-matching query w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=NONEXISTENT_QUERY_XYZ123", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200 for no results, got %d", w.Code) } var resp bookings.BookingListResponse if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Bookings) != 0 { t.Errorf("expected 0 bookings for non-matching search, got %d", len(resp.Bookings)) } if resp.Total != 0 { t.Errorf("expected total 0 for non-matching search, got %d", resp.Total) } } // TestAdminBookings_Search_MultipleResults verifies that search returns all bookings whose notes // contain the search query, with correct total count. func TestAdminBookings_Search_MultipleResults(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Create multiple bookings with similar notes bookingID1, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking 1: %v", err) } bookingID2, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking 2: %v", err) } // Update both with searchable notes _, err = tx.Exec(ctx, "UPDATE bookings SET notes = 'Search Query Pattern' WHERE id = $1", bookingID1) if err != nil { t.Fatalf("failed to update booking 1: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET notes = 'Another Search Query' WHERE id = $1", bookingID2) if err != nil { t.Fatalf("failed to update booking 2: %v", err) } handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler) // Search for "Query" - should match both w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=Query", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) } var resp bookings.BookingListResponse if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Bookings) != 2 { t.Errorf("expected 2 bookings matching 'Query', got %d", len(resp.Bookings)) } if resp.Total != 2 { t.Errorf("expected total 2, got %d", resp.Total) } } // ============================================================================= // Admin List Edit Requests Tests // ============================================================================= // TestAdminBookings_ListEditRequests verifies that an admin can list all pending edit requests // for a specific booking, and that the response includes the correct count and request details. func TestAdminBookings_ListEditRequests(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } // Update booking status to confirmed (required for edit requests) _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to update booking status: %v", err) } // Clean up ALL existing edit requests in DB to ensure clean state _, err = tx.Exec(ctx, "DELETE FROM booking_edit_requests") if err != nil { t.Fatalf("failed to clean up edit requests: %v", err) } // Create 3 edit requests via direct SQL insert var emptyServices []string for i := 1; i <= 3; i++ { _, err = tx.Exec(ctx, ` INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes, has_overrides) VALUES ($1, $2, $3, $4, $5, $6)`, bookingID, userID, time.Now().Add(time.Duration(i)*24*time.Hour), emptyServices, fmt.Sprintf("Edit request %d", i), false) if err != nil { t.Fatalf("failed to create edit request %d: %v", i, err) } } handler := http.HandlerFunc(bookings.AdminListEditRequestsHandler) w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID+"/edit-requests", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp struct { Requests []bookings.BookingEditRequest `json:"requests"` Total int `json:"total"` } if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Requests) != 3 { t.Errorf("expected 3 edit requests, got %d", len(resp.Requests)) } if resp.Total != 3 { t.Errorf("expected total 3, got %d", resp.Total) } // Verify the requests are for the correct booking for _, req := range resp.Requests { if req.BookingID != bookingID { t.Errorf("expected booking ID %s, got %s", bookingID, req.BookingID) } } } // ============================================================================= // Admin Deny Edit Request Tests // ============================================================================= // TestAdminBookings_DenyEditRequest verifies that denying an edit request deletes the request // while keeping the original booking time unchanged, and returns success. func TestAdminBookings_DenyEditRequest(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) adminID, 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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Create confirmed booking bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } // Confirm the booking _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Get original start_time var originalStartTime time.Time err = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&originalStartTime) if err != nil { t.Fatalf("failed to get original start_time: %v", err) } // Create edit request with new_start_time via direct SQL newStartTime := originalStartTime.Add(24 * time.Hour).Truncate(time.Minute) var editRequestID string err = tx.QueryRow(ctx, `INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, notes) VALUES ($1, $2, $3, 'Please change time') RETURNING id`, bookingID, userID, newStartTime).Scan(&editRequestID) if err != nil { t.Fatalf("failed to create edit request: %v", err) } // Call admin deny endpoint handler := http.HandlerFunc(bookings.AdminRejectEditRequestHandler) path := fmt.Sprintf("/api/admin/bookings/%s/edit-requests/%s/deny", bookingID, editRequestID) req := httptest.NewRequest("POST", path, nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("request_id", editRequestID) reqCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx) reqCtx = context.WithValue(reqCtx, mw.UserIDKey, adminID) reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") req = req.WithContext(reqCtx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusOK && w.Code != http.StatusNoContent { t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String()) } // Verify edit request is deleted var erCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) } if erCount != 0 { t.Errorf("expected edit request to be deleted after deny, got %d", erCount) } // Verify booking start_time unchanged var finalStartTime time.Time err = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&finalStartTime) if err != nil { t.Fatalf("failed to get final start_time: %v", err) } if !originalStartTime.Equal(finalStartTime) { t.Errorf("expected booking start_time to remain %v, got %v", originalStartTime, finalStartTime) } } // TestAdminBookings_ApproveEditRequest verifies that approving an edit request updates the booking's // start_time to the requested time, deletes the edit request, and acknowledges the admin notification. func TestAdminBookings_ApproveEditRequest(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // Create admin user _, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } // Create regular user userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } // Set deposits_required=0 _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } // Create service serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Create confirmed booking bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } // Confirm the booking _, err = tx.Exec(ctx, "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Get original booking start_time var originalStartTime time.Time err = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&originalStartTime) if err != nil { t.Fatalf("failed to get original start_time: %v", err) } // Create edit request with new_start_time newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Minute) var editRequestID string var emptyServices []string err = tx.QueryRow(ctx, `INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes) VALUES ($1, $2, $3, $4, 'Please change time') RETURNING id`, bookingID, userID, newStartTime, emptyServices).Scan(&editRequestID) if err != nil { t.Fatalf("failed to create edit request: %v", err) } // Create admin notification _, err = tx.Exec(ctx, `INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('edit_requested', $1, $2)`, bookingID, userID) if err != nil { t.Fatalf("failed to create admin notification: %v", err) } // Call admin approve endpoint handler := http.HandlerFunc(bookings.AdminApproveEditRequestHandler) path := fmt.Sprintf("/api/admin/bookings/%s/edit-requests/%s/approve", bookingID, editRequestID) w := makeAdminRequest(handler, "POST", path, nil, ctx) if w.Code != http.StatusNoContent { t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) } // Verify edit request was deleted (approved) var erCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) } if erCount != 0 { t.Errorf("expected 0 edit requests after approve, got %d", erCount) } // Verify booking start_time was updated to new_start_time var updatedStartTime time.Time err = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&updatedStartTime) if err != nil { t.Fatalf("failed to query booking: %v", err) } if !updatedStartTime.Equal(newStartTime) { t.Errorf("expected booking start_time %v, got %v", newStartTime, updatedStartTime) } // Verify admin notification was acknowledged var ackTime *time.Time err = tx.QueryRow(ctx, `SELECT acknowledged_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(&ackTime) if err != nil { t.Fatalf("failed to query notification: %v", err) } if ackTime == nil { t.Errorf("expected notification to be acknowledged after approve, but acknowledged_at is still NULL") } } // ============================================================================= // Deposit System Tests // ============================================================================= // TestAdminBookings_Get_DepositFields verifies that admin booking endpoints return // deposit-related fields (deposit_required, deposit_amount, deposit_paid, deposit_deadline) // for bookings that have deposit_required=true. func TestAdminBookings_Get_DepositFields(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Create booking via SQL with deposit_required=true (simulating user-created booking) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) var bookingID string err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'confirmed', true) RETURNING id `, userID, futureTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Link service to booking _, 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: %v", err) } // GET single booking via admin endpoint w := makeAdminRequest(http.HandlerFunc(bookings.GetAdminBookingHandler), "GET", "/api/admin/bookings/"+bookingID, nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var fetchedBooking bookings.Booking if err := parseResponseBody(w, &fetchedBooking); err != nil { t.Fatalf("failed to parse response: %v", err) } // Verify deposit fields are populated if !fetchedBooking.DepositRequired { t.Error("expected DepositRequired to be true") } if fetchedBooking.DepositAmount <= 0 { t.Error("expected DepositAmount to be positive") } // DepositPaid is a bool, just verify it exists _ = fetchedBooking.DepositPaid if fetchedBooking.DepositDeadline == nil { t.Error("expected DepositDeadline to be set") } } // TestAdminBookings_List_DepositFields verifies that admin booking list returns // deposit-related fields for each booking. func TestAdminBookings_List_DepositFields(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Create booking via SQL with deposit_required=true futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) var bookingID string err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'pending', true) RETURNING id `, userID, futureTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Link service to booking _, 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: %v", err) } // GET all bookings via admin endpoint w := makeAdminRequest(http.HandlerFunc(bookings.GetAllAdminBookingsHandler), "GET", "/api/admin/bookings", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp bookings.BookingListResponse if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Bookings) != 1 { t.Fatalf("expected 1 booking, got %d", len(resp.Bookings)) } booking := resp.Bookings[0] // Verify deposit fields are populated in list if !booking.DepositRequired { t.Error("expected DepositRequired to be true in list") } if booking.DepositAmount <= 0 { t.Error("expected DepositAmount to be positive in list") } } // ============================================================================= // Time Blocker Tests for Admin Bookings // ============================================================================= // TestAdminBookings_Create_OverlappingBlocker_WithWarning verifies that admins can // create bookings that overlap with time blockers, but receive a warning. // The booking is still created (201 Created), unlike regular users who get 409. func TestAdminBookings_Create_OverlappingBlocker_WithWarning(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) adminID, 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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Create a time blocker for a specific time ukLocation, _ := time.LoadLocation("Europe/London") blockerTime := time.Date(2099, 12, 31, 10, 0, 0, 0, ukLocation) _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff meeting', $2) `, blockerTime, adminID) if err != nil { t.Fatalf("failed to create time blocker: %v", err) } // Admin creates booking overlapping the blocker req := bookings.AdminCreateBookingForUserRequest{ UserID: userID, StartTime: blockerTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) // Admin should get 201 Created (not 409 Conflict) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } // Parse response to check for warnings var response map[string]interface{} if err := parseResponseBody(w, &response); err != nil { t.Fatalf("failed to parse response: %v", err) } // Check for warnings array warnings, ok := response["warnings"].([]interface{}) if !ok || len(warnings) == 0 { t.Error("expected warnings array with at least one warning") } else { // Verify warning mentions the blocker warningStr, ok := warnings[0].(string) if !ok { t.Errorf("expected warning to be a string, got: %v", warnings[0]) } else if !bytes.Contains([]byte(warningStr), []byte("blocker")) { t.Errorf("expected warning to mention 'blocker', got: %s", warningStr) } } // Verify booking was created bookingData, ok := response["booking"].(map[string]interface{}) if !ok { t.Fatal("expected booking in response") } if bookingData["id"] == nil { t.Error("expected booking ID to be set") } } // TestAdminBookings_Edit_OverlappingBlocker_WithWarning verifies that admins can // edit bookings to overlap with time blockers, but receive a warning. // The booking is still updated (200 OK with warnings), unlike regular users who get 409. // TestAdminBookings_Create_EnforceDeposits_Bypass tests that admin can create bookings // for users with outstanding deposits by setting enforce_deposits=false. func TestAdminBookings_Create_EnforceDeposits_Bypass(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Set user to have outstanding deposits (deposits_required = 3) _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) falseVal := false req := bookings.AdminCreateBookingForUserRequest{ UserID: userID, StartTime: futureTime, ServiceIDs: []string{serviceID}, EnforceDeposits: &falseVal, // Bypass deposit check } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) // Should succeed (not 409 Conflict) because deposits check was bypassed if w.Code != http.StatusCreated { t.Errorf("expected status 201 when enforce_deposits=false, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminBookings_Create_EnforceDeposits_Enforced tests that by default (or when enforce_deposits=true), // admin bookings respect the deposit requirement rules. func TestAdminBookings_Create_EnforceDeposits_Enforced(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Set user to have outstanding deposits _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } // Create first booking for user (will have it active) firstTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) firstReq := bookings.AdminCreateBookingForUserRequest{ UserID: userID, StartTime: firstTime, ServiceIDs: []string{serviceID}, EnforceDeposits: nil, // Default: enforce deposits } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings", firstReq, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create first booking: %d. body: %s", w.Code, w.Body.String()) } // Try to create second booking (should fail due to one-active-booking limit) secondTime := time.Now().Add(96 * time.Hour).Truncate(time.Second) secondReq := bookings.AdminCreateBookingForUserRequest{ UserID: userID, StartTime: secondTime, ServiceIDs: []string{serviceID}, EnforceDeposits: nil, // Default: enforce (deposits_required > 0 still active) } w = makeAdminRequest(handler, "POST", "/api/admin/bookings", secondReq, ctx) // Should get 409 Conflict because user has active booking and deposits outstanding if w.Code != http.StatusConflict { t.Errorf("expected status 409 when enforce_deposits is enforced, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminBookings_Create_WalkIn tests that admins can create walk-in bookings // (no advance time requirement), including immediate/past times if needed. func TestAdminBookings_Create_WalkIn(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Seed working hours so we have a valid booking window // Try to create booking with walk-in time (30 minutes from now - less than 1h requirement) // Regular users would be rejected, but admin should succeed walkInTime := time.Now().Add(30 * time.Minute).Truncate(time.Second) walkInTime = time.Date(walkInTime.Year(), walkInTime.Month(), walkInTime.Day(), 10, 0, 0, 0, walkInTime.Location()) req := bookings.AdminCreateBookingForUserRequest{ UserID: userID, StartTime: walkInTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) // Should succeed - admins can bypass the 1-hour minimum requirement if w.Code != http.StatusCreated { t.Errorf("expected status 201 for admin walk-in, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminBookings_Create_WalkInWithDeposits tests that admins can create walk-ins // even when user has outstanding deposits and enforce_deposits=false. func TestAdminBookings_Create_WalkInWithDeposits(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Seed working hours // Set user to have outstanding deposits _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits: %v", err) } // Create walk-in booking with enforce_deposits=false walkInTime := time.Now().Add(15 * time.Minute).Truncate(time.Second) walkInTime = time.Date(walkInTime.Year(), walkInTime.Month(), walkInTime.Day(), 10, 0, 0, 0, walkInTime.Location()) falseVal := false req := bookings.AdminCreateBookingForUserRequest{ UserID: userID, StartTime: walkInTime, ServiceIDs: []string{serviceID}, EnforceDeposits: &falseVal, // Bypass deposit checks } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) // Should succeed - admin walk-in with deposit bypass if w.Code != http.StatusCreated { t.Errorf("expected status 201 for admin walk-in with deposits, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // Deposit Reduction Tests (Tasks 4, 6, 10) // ============================================================================= // TestAdminBookings_Confirm_CompletesWithPayment_ReducesDeposits verifies that when a // confirmed booking is progressed to completed with at least one payment, the user's // deposits_required is reduced by 1. func TestAdminBookings_Confirm_CompletesWithPayment_ReducesDeposits(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Set user to have deposits_required = 2 _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 2 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } // Create confirmed booking futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) var bookingID string err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'confirmed') RETURNING id `, userID, futureTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Link service to booking _, 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: %v", err) } // Add a payment for the booking _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, amount, payment_type, payment_method, status) VALUES ($1, 50.00, 'deposit', 'online_square', 'completed') `, bookingID) if err != nil { t.Fatalf("failed to create payment: %v", err) } // Progress booking to completed via admin progress handler progressReq := bookings.ProgressBookingRequest{ Status: "completed", } handler := http.HandlerFunc(bookings.ProgressBookingHandler) w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify deposits_required was reduced from 2 to 1 var depositsRequired int err = tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired) if err != nil { t.Fatalf("failed to query deposits_required: %v", err) } if depositsRequired != 1 { t.Errorf("expected deposits_required = 1 after completing booking with payment, got %d", depositsRequired) } } // TestAdminBookings_Confirm_CompletesWithoutPayment_NoReduction verifies that when a // booking is completed without any payments, the user's deposits_required is NOT reduced. func TestAdminBookings_Confirm_CompletesWithoutPayment_NoReduction(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Set user to have deposits_required = 2 _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 2 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } // Create confirmed booking futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) var bookingID string err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'confirmed') RETURNING id `, userID, futureTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Link service to booking _, 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: %v", err) } // Note: NO payment added - this is the key difference // Progress booking to completed via admin progress handler progressReq := bookings.ProgressBookingRequest{ Status: "completed", } handler := http.HandlerFunc(bookings.ProgressBookingHandler) w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify deposits_required is still 2 (no reduction because no payment) var depositsRequired int err = tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired) if err != nil { t.Fatalf("failed to query deposits_required: %v", err) } if depositsRequired != 2 { t.Errorf("expected deposits_required = 2 (unchanged) after completing booking without payment, got %d", depositsRequired) } } // TestAdminBookings_Create_EnforceDepositsFalse_BypassesLimit verifies that when // enforce_deposits is set to false, the admin can create a second booking for a user // who already has an active booking, bypassing the one-active-booking limit. func TestAdminBookings_Create_EnforceDepositsFalse_BypassesLimit(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Set user to have deposits_required = 3 _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } // Create first booking (will be active) firstTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) firstReq := bookings.AdminCreateBookingForUserRequest{ UserID: userID, StartTime: firstTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings", firstReq, ctx) if w.Code != http.StatusCreated { t.Fatalf("failed to create first booking: %d. body: %s", w.Code, w.Body.String()) } // Try to create second booking with enforce_deposits=false secondTime := time.Now().Add(96 * time.Hour).Truncate(time.Second) falseVal := false secondReq := bookings.AdminCreateBookingForUserRequest{ UserID: userID, StartTime: secondTime, ServiceIDs: []string{serviceID}, EnforceDeposits: &falseVal, // Bypass deposit checks } w = makeAdminRequest(handler, "POST", "/api/admin/bookings", secondReq, ctx) // Should succeed (201 Created) because enforce_deposits=false bypasses the limit if w.Code != http.StatusCreated { t.Errorf("expected status 201 when enforce_deposits=false, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminBookings_Create_EnforceDepositsFalse_Within24h verifies that when // enforce_deposits is set to false, the admin can create a booking within 24 hours // for a user with outstanding deposits. func TestAdminBookings_Create_EnforceDepositsFalse_Within24h(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Seed working hours // Set user to have deposits_required = 3 _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } // Try to create booking within 24 hours with enforce_deposits=false // Use a time 12 hours from now (within 24h) within24h := time.Now().Add(12 * time.Hour).Truncate(time.Second) // Adjust to a valid slot within working hours within24h = time.Date(within24h.Year(), within24h.Month(), within24h.Day(), 14, 0, 0, 0, within24h.Location()) falseVal := false req := bookings.AdminCreateBookingForUserRequest{ UserID: userID, StartTime: within24h, ServiceIDs: []string{serviceID}, EnforceDeposits: &falseVal, // Bypass deposit checks } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) // Should succeed (201 Created) because enforce_deposits=false bypasses the 24h check if w.Code != http.StatusCreated { t.Errorf("expected status 201 when enforce_deposits=false for within-24h booking, got %d. body: %s", w.Code, w.Body.String()) } } // TestAdminBookings_Create_WalkInGuestUser verifies that an admin can create a booking // for a guest user (created via fixtures.CreateTestGuestUser). func TestAdminBookings_Create_WalkInGuestUser(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) } guestID, err := fixtures.CreateTestGuestUser(tx) if err != nil { t.Fatalf("failed to create guest user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Seed working hours // Create booking for tomorrow 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.AdminCreateBookingForUserRequest{ UserID: guestID, StartTime: tomorrow, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } // Verify booking exists with correct user_id var foundUserID string err = tx.QueryRow(ctx, ` SELECT user_id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1 `, guestID).Scan(&foundUserID) if err != nil { t.Fatalf("failed to verify booking exists: %v", err) } if foundUserID != guestID { t.Errorf("expected booking user_id = %s, got %s", guestID, foundUserID) } } // ============================================================================= // Exceptional Hours Tests // ============================================================================= // TestAdminUpdateBooking_ClosedExceptionalHours_WarningOnly verifies that an admin can // update a booking's time to fall within a closed exceptional hours period, receiving // a warning but proceeding with the update. func TestGetBookingsByCreatedRange(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Create bookings with specific created_at timestamps _, err = tx.Exec(ctx, ` INSERT INTO bookings (id, user_id, start_time, status, created_at) VALUES ('book00000001', $1, '2099-12-31 10:00:00+00', 'confirmed', '2025-01-15 09:00:00+00') `, userID) if err != nil { t.Fatalf("failed to create booking 1: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000001', $1) `, serviceID) if err != nil { t.Fatalf("failed to link service to booking 1: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO bookings (id, user_id, start_time, status, created_at) VALUES ('book00000002', $1, '2099-12-31 11:00:00+00', 'pending', '2025-01-15 14:00:00+00') `, userID) if err != nil { t.Fatalf("failed to create booking 2: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000002', $1) `, serviceID) if err != nil { t.Fatalf("failed to link service to booking 2: %v", err) } // Booking outside the range (created before) _, err = tx.Exec(ctx, ` INSERT INTO bookings (id, user_id, start_time, status, created_at) VALUES ('book00000003', $1, '2099-12-31 12:00:00+00', 'confirmed', '2025-01-10 09:00:00+00') `, userID) if err != nil { t.Fatalf("failed to create booking 3: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000003', $1) `, serviceID) if err != nil { t.Fatalf("failed to link service to booking 3: %v", err) } handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler) w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-01-15T00:00:00Z&end=2025-01-16T00:00:00Z", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp bookings.OverlappingBookingsResponse if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Bookings) != 2 { t.Errorf("expected 2 bookings in range, got %d", len(resp.Bookings)) } } // TestGetBookingsByCreatedRange_Empty verifies that the endpoint returns an // empty array when no bookings fall within the created_at range. func TestGetBookingsByCreatedRange_Empty(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) } handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler) w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-01-01T00:00:00Z&end=2025-01-02T00:00:00Z", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp bookings.OverlappingBookingsResponse if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Bookings) != 0 { t.Errorf("expected 0 bookings, got %d", len(resp.Bookings)) } } // TestGetBookingsByCreatedRange_MissingParams verifies that the endpoint // returns 400 when start or end query parameters are missing. func TestGetBookingsByCreatedRange_MissingParams(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) } handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler) // Missing both params w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range", nil, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 (missing both), got %d", w.Code) } // Missing end param w = makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-01-01T00:00:00Z", nil, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 (missing end), got %d", w.Code) } // Missing start param w = makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?end=2025-01-02T00:00:00Z", nil, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 (missing start), got %d", w.Code) } } // TestGetBookingsByCreatedRange_InvalidFormat verifies that the endpoint // returns 400 when the date format is invalid. func TestGetBookingsByCreatedRange_InvalidFormat(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) } handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler) w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=not-a-date&end=2025-01-02T00:00:00Z", nil, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } // TestGetBookingsByCreatedRange_OrderedByCreatedAt verifies that results // are returned in ascending order by created_at. func TestGetBookingsByCreatedRange_OrderedByCreatedAt(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Create bookings with created_at in reverse order _, err = tx.Exec(ctx, ` INSERT INTO bookings (id, user_id, start_time, status, created_at) VALUES ('book00000010', $1, '2099-12-31 10:00:00+00', 'confirmed', '2025-03-01 15:00:00+00') `, userID) if err != nil { t.Fatalf("failed to create booking 10: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000010', $1) `, serviceID) if err != nil { t.Fatalf("failed to link service to booking 10: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO bookings (id, user_id, start_time, status, created_at) VALUES ('book00000011', $1, '2099-12-31 11:00:00+00', 'pending', '2025-03-01 10:00:00+00') `, userID) if err != nil { t.Fatalf("failed to create booking 11: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000011', $1) `, serviceID) if err != nil { t.Fatalf("failed to link service to booking 11: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO bookings (id, user_id, start_time, status, created_at) VALUES ('book00000012', $1, '2099-12-31 12:00:00+00', 'confirmed', '2025-03-01 12:00:00+00') `, userID) if err != nil { t.Fatalf("failed to create booking 12: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000012', $1) `, serviceID) if err != nil { t.Fatalf("failed to link service to booking 12: %v", err) } handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler) w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-03-01T00:00:00Z&end=2025-03-02T00:00:00Z", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp bookings.OverlappingBookingsResponse if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Bookings) != 3 { t.Fatalf("expected 3 bookings, got %d", len(resp.Bookings)) } // Verify order: book00000011 (10:00) < book00000012 (12:00) < book00000010 (15:00) expectedOrder := []string{"book00000011", "book00000012", "book00000010"} for i, expected := range expectedOrder { if resp.Bookings[i].ID != expected { t.Errorf("booking[%d] expected %s, got %s", i, expected, resp.Bookings[i].ID) } } } func TestGetAdminBooking_WithDiscounts(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } // Create completed booking bookingTime := time.Now().Add(-24 * time.Hour) bookingID := createCompletedBookingWithTimeForAdmin(t, ctx, tx, userID, serviceID, bookingTime, 50.00) // Create a discount campaign and apply it var campaignID string err = tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '2 days', NOW() + INTERVAL '2 days') RETURNING id `, "Admin Test Campaign").Scan(&campaignID) if err != nil { t.Fatalf("failed to create campaign: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'campaign', $3, 'time_based', 10.0, 50.00, 5.00) `, bookingID, userID, campaignID) if err != nil { t.Fatalf("failed to create booking discount: %v", err) } // Call GetAdminBookingHandler handler := http.HandlerFunc(bookings.GetAdminBookingHandler) w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil, ctx) 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 booking: %v", err) } if len(booking.Discounts) != 1 { t.Fatalf("expected 1 discount, got %d", len(booking.Discounts)) } d := booking.Discounts[0] if d.CampaignName == nil || *d.CampaignName != "Admin Test Campaign" { t.Errorf("expected campaign name 'Admin Test Campaign', got %v", d.CampaignName) } if d.DiscountAmount != 5.00 { t.Errorf("expected discount amount 5.00, got %.2f", d.DiscountAmount) } } func createCompletedBookingWithTimeForAdmin(t *testing.T, ctx context.Context, tx db.Querier, userID, serviceID string, startTime time.Time, price float64) string { t.Helper() var bookingID string err := tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id `, userID, startTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create completed booking: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO booking_services (booking_id, service_id, override_price) VALUES ($1, $2, $3) `, bookingID, serviceID, price) if err != nil { t.Fatalf("failed to link service: %v", err) } 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) { 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) } customServiceID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } 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, ctx) 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") } // Verify booking_custom_services has the custom service linked var bcsCount int err = tx.QueryRow(ctx, "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 = tx.QueryRow(ctx, "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) { 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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } customServiceID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } 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, ctx) 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") } // Verify booking_services has the regular service var svcCount int err = tx.QueryRow(ctx, "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 = tx.QueryRow(ctx, "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) { 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) } 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, ctx) 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, ctx) 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) { customServiceID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } 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, ctx) 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) { 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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } customServiceID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } // Create a pending booking with a regular service bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } // Link a custom service to the booking _, err = tx.Exec(ctx, "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, ctx) 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 = tx.QueryRow(ctx, "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) { 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) } customServiceID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } 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, ctx) 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") } // Verify override was applied to booking_custom_services var actualPrice *float64 var actualDuration *int err = tx.QueryRow(ctx, "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) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } customServiceID, err := fixtures.CreateTestCustomService(tx) if err != nil { t.Fatalf("failed to create custom service: %v", err) } // Set custom service duration to a known value for assertion _, err = tx.Exec(ctx, "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", ctx) 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 = tx.QueryRow(ctx, "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) } } // ============================================================================= // GetAdminBookingHandler — out_of_hours field // ============================================================================= func TestGetAdminBooking_OutOfHoursField(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) var bookingID string err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, out_of_hours) VALUES ($1, $2, 'confirmed', true) RETURNING id `, userID, futureTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create out-of-hours 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: %v", err) } handler := http.HandlerFunc(bookings.GetAdminBookingHandler) w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil, ctx) 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 booking: %v", err) } if !booking.OutOfHours { t.Error("expected out_of_hours=true in booking response") } } func TestGetAdminBooking_OutOfHoursFalseByDefault(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } var bookingID string err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'confirmed') RETURNING id `, userID, time.Now().Add(72*time.Hour).Truncate(time.Second)).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: %v", err) } handler := http.HandlerFunc(bookings.GetAdminBookingHandler) w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil, ctx) 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 booking: %v", err) } if booking.OutOfHours { t.Error("expected out_of_hours=false (default) for normal booking") } } // ============================================================================= // GetAllAdminBookingsHandler — out_of_hours field // ============================================================================= func TestAdminBookings_List_OutOfHoursField(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) var bookingID string err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status, out_of_hours) VALUES ($1, $2, 'confirmed', true) RETURNING id `, userID, futureTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create out-of-hours 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: %v", err) } handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler) w := makeAdminRequest(handler, "GET", "/api/admin/bookings", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp bookings.BookingListResponse if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Bookings) == 0 { t.Fatal("expected at least 1 booking in list response") } var found bool for _, b := range resp.Bookings { if b.ID == bookingID { if !b.OutOfHours { t.Error("expected out_of_hours=true for the out-of-hours booking in list response") } found = true break } } if !found { t.Error("expected out-of-hours booking to appear in list response") } } func TestAdminBookings_List_OutOfHoursFalseByDefault(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } var bookingID string err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'confirmed') RETURNING id `, userID, time.Now().Add(72*time.Hour).Truncate(time.Second)).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: %v", err) } handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler) w := makeAdminRequest(handler, "GET", "/api/admin/bookings", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp bookings.BookingListResponse if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Bookings) == 0 { t.Fatal("expected at least 1 booking in list response") } var found bool for _, b := range resp.Bookings { if b.ID == bookingID { if b.OutOfHours { t.Error("expected out_of_hours=false for normal booking in list response") } found = true break } } if !found { t.Error("expected normal booking to appear in list response") } } // ============================================================================= // AdminCreateBookingForUserHandler — out_of_hours field // ============================================================================= func TestAdminBookings_Create_OutOfHours(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) req := bookings.AdminCreateBookingForUserRequest{ UserID: userID, StartTime: futureTime, ServiceIDs: []string{serviceID}, OutOfHours: true, } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) 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") } outOfHours, ok := bookingData["out_of_hours"].(bool) if !ok { t.Fatal("expected out_of_hours field in booking response") } if !outOfHours { t.Error("expected out_of_hours=true in create booking response") } bookingID, ok := bookingData["id"].(string) if !ok || bookingID == "" { t.Fatal("expected booking id in response") } var dbOutOfHours bool err = tx.QueryRow(ctx, "SELECT out_of_hours FROM bookings WHERE id = $1", bookingID).Scan(&dbOutOfHours) if err != nil { t.Fatalf("failed to query booking out_of_hours: %v", err) } if !dbOutOfHours { t.Error("expected out_of_hours=true in database") } } func TestAdminBookings_Create_OutOfHoursFalseByDefault(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create test service: %v", err) } _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) req := bookings.AdminCreateBookingForUserRequest{ UserID: userID, StartTime: futureTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) 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") } outOfHours, ok := bookingData["out_of_hours"].(bool) if !ok { t.Fatal("expected out_of_hours field in booking response") } if outOfHours { t.Error("expected out_of_hours=false (default) in create booking response") } }