Files
Crussell/backend/handlers/admin/bookings_test.go
T
popertotsandSisyphus b549716b89 test: fix flaky time_blockers test and improve testdb migration
Un-harden TestTimeBlockers_List dates (now relative to current time) and
update TestCleanupOldReservations to use fixture users instead of hardcoded IDs.
Improve Migrate() to drop types before tables (CASCADE dependency fix) and
add forgiven_no_shows to truncate list. Seed default working hours in admin
booking tests to prevent business-logic check failures.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-29 22:55:58 +01:00

2353 lines
76 KiB
Go

//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
// - 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"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/db"
"crussell/handlers/bookings"
"crussell/mw"
"crussell/testutils/fixtures"
"github.com/go-chi/chi/v5"
"github.com/lib/pq"
)
// =============================================================================
func seedDefaultWorkingHours(t *testing.T) {
t.Helper()
// Seed 7 days of working hours (Monday=0 to Sunday=6)
// Use wide hours to avoid test failures due to business logic time checks
hours := []struct {
weekday int
startTime string
endTime string
isOpen bool
}{
{0, "08:00", "20:00", true}, // Monday
{1, "08:00", "20:00", true}, // Tuesday
{2, "08:00", "20:00", true}, // Wednesday
{3, "08:00", "20:00", true}, // Thursday
{4, "08:00", "20:00", true}, // Friday
{5, "08:00", "20:00", true}, // Saturday
{6, "08:00", "20:00", true}, // Sunday
}
for _, h := range hours {
_, err := db.DB.Exec(context.Background(), `
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
VALUES ($1, $2, $3, $4)
ON CONFLICT (weekday) DO UPDATE SET start_time = $2, end_time = $3, is_open = $4
`, h.weekday, h.startTime, h.endTime, h.isOpen)
if err != nil {
t.Fatalf("failed to seed working hours: %v", err)
}
}
}
// 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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID1, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking 1: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID1)
bookingID2, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking 2: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID2)
handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings", nil)
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)
}
}
// TestAdminBookings_List_FilterByStatus tests that an admin can filter
// bookings by status (e.g., pending, confirmed, completed).
func TestAdminBookings_List_FilterByStatus(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings?status=pending", nil)
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)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
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)
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 = db.DB.QueryRow(context.Background(),
"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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
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)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
_, err = db.DB.Exec(context.Background(),
"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)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search", nil)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.GetAdminBookingHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
handler := http.HandlerFunc(bookings.GetAdminBookingHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/nonexistent-id", nil)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID1, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking 1: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID1)
bookingID2, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking 2: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID2)
handler := http.HandlerFunc(bookings.GetAllBookingsByUserHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/user/"+userID, nil)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
req := bookings.ProgressBookingRequest{
Status: "confirmed",
}
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", req)
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 = db.DB.QueryRow(context.Background(),
"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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
req := bookings.ProgressBookingRequest{
Status: "invalid_status",
}
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", req)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
req := bookings.ProgressBookingRequest{
Status: "confirmed",
}
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/nonexistent-id/progress", req)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
req := bookings.ConfirmBookingRequest{}
handler := http.HandlerFunc(bookings.ConfirmBookingHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/confirm", req)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
ctx := context.Background()
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
_, err = db.DB.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)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
ctx := context.Background()
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
_, err = db.DB.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)
if w.Code != http.StatusNoContent {
t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String())
}
var dbStatus string
err = db.DB.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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
handler := http.HandlerFunc(bookings.AdminCancelBookingHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings/nonexistent-id/cancel", nil)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
ctx := context.Background()
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Booking stays in 'pending' status (no confirmation)
handler := http.HandlerFunc(bookings.AdminCancelBookingHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil)
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 = db.DB.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 = db.DB.QueryRow(ctx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1`,
bookingID).Scan(&notifCount)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
ctx := context.Background()
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Confirm the booking
_, err = db.DB.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)
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 = db.DB.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 = db.DB.QueryRow(ctx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'cancelled_booking'`,
bookingID).Scan(&notifCount)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
ctx := context.Background()
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Set booking to in-progress status
_, err = db.DB.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)
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 = db.DB.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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
ctx := context.Background()
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Set to already cancelled
_, err = db.DB.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)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
ctx := context.Background()
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Set to completed
_, err = db.DB.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)
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) {
cleanup := setupTestDB(t)
defer cleanup()
_, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
w := makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.GetAllAdminBookingsHandler)), "GET", "/api/admin/bookings", nil)
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)
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)
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)
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)
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)
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)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// 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 = db.DB.QueryRow(context.Background(), `
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)
}
defer db.DB.Exec(context.Background(), "DELETE FROM exceptional_working_hours_groups WHERE id = $1", groupID)
// Add closed hours for targetDate (closed all day)
_, err = db.DB.Exec(context.Background(), `
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
VALUES ($1, $2, $3, $4, $5)
`, groupID, int(targetDate.Weekday()), "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)
targetWeekday := int(targetDate.Weekday())
if targetWeekday == 0 {
targetWeekday = 7 // Sunday -> 7
}
mondayOfWeek := targetDate.AddDate(0, 0, -targetWeekday+1)
_, err = db.DB.Exec(context.Background(), `
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)
// 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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Update notes with mixed case
_, err = db.DB.Exec(context.Background(),
"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)
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)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler)
// Search with non-matching query
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=NONEXISTENT_QUERY_XYZ123", nil)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// Create multiple bookings with similar notes
bookingID1, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking 1: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID1)
bookingID2, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking 2: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID2)
// Update both with searchable notes
_, err = db.DB.Exec(context.Background(),
"UPDATE bookings SET notes = 'Search Query Pattern' WHERE id = $1", bookingID1)
if err != nil {
t.Fatalf("failed to update booking 1: %v", err)
}
_, err = db.DB.Exec(context.Background(),
"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)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Update booking status to confirmed (required for edit requests)
_, err = db.DB.Exec(context.Background(),
"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 = db.DB.Exec(context.Background(), "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 = db.DB.Exec(context.Background(), `
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),
pq.Array(&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)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// Create confirmed booking
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Confirm the booking
_, err = db.DB.Exec(context.Background(), "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 = db.DB.QueryRow(context.Background(),
"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 = db.DB.QueryRow(context.Background(),
`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)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, mw.UserIDKey, adminID)
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
req = req.WithContext(ctx)
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 = db.DB.QueryRow(context.Background(),
"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 = db.DB.QueryRow(context.Background(),
"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) {
cleanup := setupTestDB(t)
defer cleanup()
// Create admin user
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
// Create regular user
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0
_, err = db.DB.Exec(context.Background(), "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(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// Create confirmed booking
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Confirm the booking
_, err = db.DB.Exec(context.Background(),
"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 = db.DB.QueryRow(context.Background(),
"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 = db.DB.QueryRow(context.Background(),
`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, pq.Array(&emptyServices)).Scan(&editRequestID)
if err != nil {
t.Fatalf("failed to create edit request: %v", err)
}
// Create admin notification
_, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $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)
req := httptest.NewRequest("POST", path, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
rctx.URLParams.Add("request_id", editRequestID)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, mw.UserIDKey, adminID)
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
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 = db.DB.QueryRow(context.Background(),
"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 = db.DB.QueryRow(context.Background(),
"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 = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'`,
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// 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 = db.DB.QueryRow(context.Background(), `
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)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Link service to booking
_, err = db.DB.Exec(context.Background(), `
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)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// Create booking via SQL with deposit_required=true
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
var bookingID string
err = db.DB.QueryRow(context.Background(), `
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)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Link service to booking
_, err = db.DB.Exec(context.Background(), `
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)
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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// 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 = db.DB.Exec(context.Background(), `
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)
// 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.
func TestAdminBookings_Edit_OverlappingBlocker_WithWarning(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// Create a booking first
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Create a time blocker for a specific time
ukLocation, _ := time.LoadLocation("Europe/London")
blockerTime := time.Date(2099, 12, 31, 14, 0, 0, 0, ukLocation)
_, err = db.DB.Exec(context.Background(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'Staff meeting', NULL)
`, blockerTime)
if err != nil {
t.Fatalf("failed to create time blocker: %v", err)
}
// Admin edits booking to overlap the blocker
req := bookings.EditBookingRequest{
StartTime: blockerTime,
}
// Set up chi router for URL param
r := chi.NewRouter()
r.Put("/api/admin/bookings/{id}", bookings.AdminEditBookingHandler)
// Create request with chi context and admin auth
bodyBytes, _ := json.Marshal(req)
reqHTTP := httptest.NewRequest("PUT", "/api/admin/bookings/"+bookingID, bytes.NewReader(bodyBytes))
reqHTTP.Header.Set("Content-Type", "application/json")
// Add admin context
ctx := context.WithValue(reqHTTP.Context(), mw.UserIDKey, adminID)
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
reqHTTP = reqHTTP.WithContext(ctx)
w := httptest.NewRecorder()
r.ServeHTTP(w, reqHTTP)
// Admin should get 200 OK (not 409 Conflict)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Parse response
var response map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
// Check for warnings
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)
}
}
}
// 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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// Set user to have outstanding deposits (deposits_required = 3)
_, err = db.DB.Exec(context.Background(), "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)
// 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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// Set user to have outstanding deposits
_, err = db.DB.Exec(context.Background(), "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)
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)
// 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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// Seed working hours so we have a valid booking window
seedDefaultWorkingHours(t)
// 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)
// 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) {
cleanup := setupTestDB(t)
defer cleanup()
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// Seed working hours
seedDefaultWorkingHours(t)
// Set user to have outstanding deposits
_, err = db.DB.Exec(context.Background(), "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)
// 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())
}
}