//go:build test && dev // +build test,dev package bookings // Package bookings contains tests for user-facing booking endpoints. // // Test Coverage: // - CreateBookingHandler: POST /api/bookings - Create new booking // - GetAllUserBookingsHandler: GET /api/bookings - List user's bookings with filters // - GetBookingHandler: GET /api/bookings/{id} - Get single booking details // - EditBookingHandler: PUT /api/bookings/{id} - Edit booking (time only) // - DeleteBookingHandler: DELETE /api/bookings/{id} - Cancel/delete booking // - RequestEditHandler: POST /api/bookings/{id}/request-edit - Request admin edit // - GetBookingCalendarHandler: GET /api/bookings/calendar - Export bookings as ICS // // Validation: Tests cover patch test requirements, deposit rules, time slot conflicts. import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "crussell/db" "crussell/testutils" "crussell/handlers/user" "crussell/internal/validators" "crussell/mw" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" ) // seedDefaultWorkingHours seeds default working hours for tests 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) } } } // nextWorkingHour returns a time within working hours (08:00-19:00) that is // always < 24h from now. This avoids time-of-day flakiness in tests that need // a booking within the 24-hour no-show window. Working hours are 08:00-20:00; // we cap at 19:00 so a 60-minute service finishes before closing. func nextWorkingHour() time.Time { now := time.Now() soon := now.Add(2 * time.Hour).Truncate(time.Second) if soon.Hour() < 8 { return time.Date(soon.Year(), soon.Month(), soon.Day(), 9, 0, 0, 0, soon.Location()) } if soon.Hour() >= 19 { return time.Date(soon.Year(), soon.Month(), soon.Day()+1, 9, 0, 0, 0, soon.Location()) } return soon } // helper function to make JSON request with JWT auth // For authenticated requests, use makeAuthRequest which extracts user from JWT func makeRequest(handler http.Handler, method, path string, body interface{}, token string) *httptest.ResponseRecorder { return makeAuthRequest(handler, method, path, body, token, "") } // makeAuthRequest creates request with optional JWT auth and userID override func makeAuthRequest(handler http.Handler, method, path string, body interface{}, token, userIDOverride string) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } // Set up chi routing context for path params rctx := chi.NewRouteContext() if id, paramName := extractIDFromPath(path); id != "" { rctx.URLParams.Add(paramName, id) } ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) // Set user context - either from override or attempt to extract from token var userID, userRole string if userIDOverride != "" { userID = userIDOverride userRole = "verified_email" } else if token != "" { // For test JWTs, extract user info from token by parsing it // Use JWT secret to parse if info := extractUserFromTestJWT(token); info != nil { userID = info.userID userRole = info.role } } if userID != "" { ctx = context.WithValue(ctx, mw.UserIDKey, userID) ctx = context.WithValue(ctx, mw.UserRoleKey, userRole) } req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } // userInfo holds extracted user from JWT type userInfo struct { userID string role string } // extractUserFromTestJWT extracts user info from test JWT func extractUserFromTestJWT(token string) *userInfo { // Parse the JWT without verification for tests // JWT format: header.payload.signature parts := strings.Split(token, ".") if len(parts) != 3 { return nil } // Decode the payload (middle part) payload := parts[1] // Add padding if needed if len(payload)%4 != 0 { payload += strings.Repeat("=", 4-len(payload)%4) } decoded, err := base64URLDecode(payload) if err != nil { return nil } // Parse JSON to get claims var claims map[string]interface{} if err := json.Unmarshal(decoded, &claims); err != nil { return nil } // Extract user_id (not "sub" - auth.GenerateToken uses "user_id") userID, _ := claims["user_id"].(string) role, _ := claims["role"].(string) if userID == "" { return nil } return &userInfo{userID: userID, role: role} } func base64URLDecode(s string) ([]byte, error) { return base64.URLEncoding.DecodeString(s) } // extractIDFromPath extracts the ID from URL paths func extractIDFromPath(path string) (string, string) { patterns := []struct { prefix string paramName string }{ {"/api/bookings/", "id"}, {"/api/admin/bookings/", "id"}, {"/api/services/", "id"}, } for _, p := range patterns { if idx := findLastSegment(path, p.prefix); idx >= 0 { // Extract only up to next '/' or end of path endIdx := len(path) for i := idx; i < len(path); i++ { if path[i] == '/' { endIdx = i break } } return path[idx:endIdx], p.paramName } } return "", "" } func findLastSegment(path, prefix string) int { for i := len(path) - 1; i >= len(prefix); i-- { if len(path) > i && path[i-len(prefix):i] == prefix { return i } } return -1 } // Helper to parse response body func parseResponseBody(w *httptest.ResponseRecorder, dest interface{}) error { return json.Unmarshal(w.Body.Bytes(), dest) } // ============================================================================= // Create Booking Tests // ============================================================================= // TestBookings_Create tests that a user can successfully create a new booking // with a valid future time and at least one service. The test verifies the // booking is created in the database and associated with the correct user. func TestBookings_Create(t *testing.T) { testutils.SetupTestDB(t) // Seed working hours for booking tests seedDefaultWorkingHours(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now (was 3) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Generate token for user token := jwt.GenerateUserToken(userID) // Create booking request - use future time (1h+ advance is enforced for USERS only) // Use 10:00 to ensure service fits within working hours (08:00-20:00) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } // Verify booking was created in DB 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) } // Verify booking_services was created err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM booking_services WHERE booking_id IN (SELECT id FROM bookings WHERE user_id = $1)", userID).Scan(&count) if err != nil { t.Errorf("failed to query booking_services: %v", err) } if count != 1 { t.Errorf("expected 1 booking_service, got %d", count) } } // TestBookings_Create_InvalidInput verifies that booking creation fails // with HTTP 400 when required fields are missing: start time or service IDs. func TestBookings_Create_InvalidInput(t *testing.T) { testutils.SetupTestDB(t) // Create test 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) // Note: deposits_required=0 by default now _, 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) } token := jwt.GenerateUserToken(userID) tests := []struct { name string req CreateBookingRequest }{ { name: "missing start time", req: CreateBookingRequest{ ServiceIDs: []string{"some-service-id"}, }, }, { name: "missing service IDs", req: CreateBookingRequest{ StartTime: time.Now().Add(72 * time.Hour), }, }, { name: "empty service IDs", req: CreateBookingRequest{ StartTime: time.Now().Add(72 * time.Hour), ServiceIDs: []string{}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", tt.req, token) // Both missing start time and missing/empty service IDs should return 400 if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d", w.Code) } }) } } // ============================================================================= // List Bookings Tests // ============================================================================= // TestBookings_List tests that a user can retrieve their list of bookings. // The test verifies the response includes the correct total count and that // bookings are properly returned. func TestBookings_List(t *testing.T) { testutils.SetupTestDB(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, 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) } 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 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) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetAllUserBookingsHandler) w := makeRequest(handler, "GET", "/api/bookings", nil, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp BookingListResponse if err := parseResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if len(resp.Bookings) != 1 { t.Errorf("expected 1 booking, got %d", len(resp.Bookings)) } if resp.Total != 1 { t.Errorf("expected total 1, got %d", resp.Total) } } // TestBookings_List_FilterByStatus tests that booking list can be filtered // by status (e.g., pending, completed). It verifies that non-matching statuses // return empty results. func TestBookings_List_FilterByStatus(t *testing.T) { testutils.SetupTestDB(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, 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) } 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 pending 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) token := jwt.GenerateUserToken(userID) // Test filtering by status handler := http.HandlerFunc(GetAllUserBookingsHandler) w := makeRequest(handler, "GET", "/api/bookings?status=pending", nil, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) } var resp BookingListResponse if err := parseResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if len(resp.Bookings) != 1 { t.Errorf("expected 1 booking, got %d", len(resp.Bookings)) } // Test filtering by non-matching status w = makeRequest(handler, "GET", "/api/bookings?status=completed", nil, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) } if err := parseResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if len(resp.Bookings) != 0 { t.Errorf("expected 0 bookings for completed status, got %d", len(resp.Bookings)) } } // ============================================================================= // Get Single Booking Tests // ============================================================================= // TestBookings_Get tests that a user can retrieve a single booking by its ID. // The test verifies the booking details including services are returned. func TestBookings_Get(t *testing.T) { testutils.SetupTestDB(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, 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) } 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 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) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingHandler) w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Errorf("failed to parse response: %v", err) } if booking.ID != bookingID { t.Errorf("expected booking ID %s, got %s", bookingID, booking.ID) } if len(booking.Services) != 1 { t.Errorf("expected 1 service, got %d", len(booking.Services)) } } // TestBookings_Get_NotFound verifies that requesting a non-existent booking // returns HTTP 404 Not Found. func TestBookings_Get_NotFound(t *testing.T) { testutils.SetupTestDB(t) // Create test 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) // Note: deposits_required=0 by default now _, 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) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingHandler) w := makeRequest(handler, "GET", "/api/bookings/nonexistent-id", nil, token) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) } } // TestBookings_Get_AccessDenied tests that a user cannot access another user's // booking. The test creates two users, one creates a booking, and the other // attempts to access it - expecting HTTP 404 (not found/access denied). func TestBookings_Get_AccessDenied(t *testing.T) { testutils.SetupTestDB(t) // Create two test users userID1, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user 1: %v", err) } defer fixtures.DeleteUser(db.DB, userID1) userID2, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user 2: %v", err) } defer fixtures.DeleteUser(db.DB, userID2) 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 for user1 bookingID, err := fixtures.CreateTestBooking(db.DB, userID1, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) // Try to access with user2's token token := jwt.GenerateUserToken(userID2) handler := http.HandlerFunc(GetBookingHandler) w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token) // Should return not found (or access denied) since user2 doesn't own the booking if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) } } // ============================================================================= // Get Calendar Tests // ============================================================================= // TestBookings_GetCalendar tests that a user can export their booking // as an ICS calendar file. It verifies the response has the correct // text/calendar Content-Type and contains ICS-formatted data. func TestBookings_GetCalendar(t *testing.T) { testutils.SetupTestDB(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, 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) } 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 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) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingCalendarHandler) w := makeRequest(handler, "GET", "/api/bookings/"+bookingID+"/calendar", nil, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Check that Content-Type is text/calendar contentType := w.Header().Get("Content-Type") if contentType != "text/calendar; charset=utf-8" { t.Errorf("expected Content-Type text/calendar, got %s", contentType) } // Check that response contains ICS data body := w.Body.String() if len(body) == 0 { t.Error("expected non-empty ICS response") } // Basic ICS validation if !bytes.Contains([]byte(body), []byte("BEGIN:VCALENDAR")) { t.Error("expected ICS to contain BEGIN:VCALENDAR") } if !bytes.Contains([]byte(body), []byte("BEGIN:VEVENT")) { t.Error("expected ICS to contain BEGIN:VEVENT") } } // TestBookings_GetCalendar_NotFound verifies that attempting to export // a non-existent booking to calendar returns HTTP 404. func TestBookings_GetCalendar_NotFound(t *testing.T) { testutils.SetupTestDB(t) // Create test 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) // Note: deposits_required=0 by default now _, 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) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingCalendarHandler) w := makeRequest(handler, "GET", "/api/bookings/nonexistent-id/calendar", nil, token) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) } } // ============================================================================= // Edit Booking Tests // ============================================================================= // TestBookings_Edit tests that a user can modify the start time of // their existing booking. The test verifies the time is updated in the DB. func TestBookings_Edit(t *testing.T) { testutils.SetupTestDB(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, 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) } 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 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) token := jwt.GenerateUserToken(userID) // Update to a future time newStartTime := time.Now().Add(96 * time.Hour).Truncate(time.Second) req := EditBookingRequest{ StartTime: newStartTime, } handler := http.HandlerFunc(EditBookingHandler) w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Errorf("failed to parse response: %v", err) } // Verify the start time was updated in DB var dbStartTime time.Time err = db.DB.QueryRow(context.Background(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&dbStartTime) if err != nil { t.Errorf("failed to query booking: %v", err) } // Times should match (truncated to seconds) if !dbStartTime.Truncate(time.Second).Equal(newStartTime) { t.Errorf("expected start_time %v, got %v", newStartTime, dbStartTime) } } // TestBookings_Edit_InvalidInput verifies that editing fails with HTTP 400 // when the start time is missing or is in the past. func TestBookings_Edit_InvalidInput(t *testing.T) { testutils.SetupTestDB(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, 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) } 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 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) token := jwt.GenerateUserToken(userID) tests := []struct { name string req EditBookingRequest }{ { name: "missing start time", req: EditBookingRequest{}, }, { name: "past start time", req: EditBookingRequest{ StartTime: time.Now().Add(-1 * time.Hour), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler := http.HandlerFunc(EditBookingHandler) w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, tt.req, token) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d", w.Code) } }) } } // TestBookings_Edit_NotFound verifies that editing a non-existent // booking returns HTTP 404. func TestBookings_Edit_NotFound(t *testing.T) { testutils.SetupTestDB(t) // Create test 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) // Note: deposits_required=0 by default now _, 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) } token := jwt.GenerateUserToken(userID) req := EditBookingRequest{ StartTime: time.Now().Add(96 * time.Hour), } handler := http.HandlerFunc(EditBookingHandler) w := makeRequest(handler, "PUT", "/api/bookings/nonexistent-id", req, token) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) } } // ============================================================================= // Delete Booking Tests // ============================================================================= // TestBookings_Delete tests that a user can delete (cancel) their booking. // For bookings without payments, it performs a hard delete. The test verifies // the booking is removed from the database. func TestBookings_Delete(t *testing.T) { testutils.SetupTestDB(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, 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) } 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 (without payments) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } token := jwt.GenerateUserToken(userID) // Delete the booking handler := http.HandlerFunc(DeleteBookingHandler) w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify booking was deleted from DB var count int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM bookings WHERE id = $1", bookingID).Scan(&count) if err != nil { t.Errorf("failed to query bookings: %v", err) } if count != 0 { t.Errorf("expected booking to be deleted, but found %d", count) } } // TestBookings_Delete_WithAdminNotifications verifies that a booking with existing // admin_notifications can be hard-deleted (regression test for FK constraint bug). // Every booking in production has at least one admin_notification row, and the // admin_notifications.booking_id FK has no ON DELETE CASCADE, so the DELETE must // explicitly clean up notifications first. func TestBookings_Delete_WithAdminNotifications(t *testing.T) { testutils.SetupTestDB(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } 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) } _, err = db.DB.Exec(context.Background(), `INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3)`, "pending_booking", bookingID, userID) if err != nil { t.Fatalf("failed to create admin_notification: %v", err) } _, err = db.DB.Exec(context.Background(), `INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3)`, "new_booking", bookingID, userID) if err != nil { t.Fatalf("failed to create second admin_notification: %v", err) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(DeleteBookingHandler) w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify booking was deleted from DB var bookingCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM bookings WHERE id = $1", bookingID).Scan(&bookingCount) if err != nil { t.Fatalf("failed to query bookings: %v", err) } if bookingCount != 0 { t.Errorf("expected booking to be deleted, but found %d", bookingCount) } // Verify admin_notifications for this booking were also cleaned up var notifCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1", bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query admin_notifications: %v", err) } if notifCount != 0 { t.Errorf("expected admin_notifications to be cleaned up, but found %d", notifCount) } } // TestBookings_Delete_WithReason verifies that cancelling a booking with // an associated payment requires a reason (client_cancelled). Without a reason, // the request fails with HTTP 400. With a reason, the booking is soft-deleted // (status changed to client_cancelled). func TestBookings_Delete_WithReason(t *testing.T) { testutils.SetupTestDB(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, 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) } 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 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) // Add a payment to the booking (so it requires a reason) _, err = db.DB.Exec(context.Background(), "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", bookingID) if err != nil { t.Fatalf("failed to create payment: %v", err) } token := jwt.GenerateUserToken(userID) // Try to delete without reason - should fail handler := http.HandlerFunc(DeleteBookingHandler) w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for missing reason, got %d", w.Code) } // Delete with reason req := map[string]string{"reason": "client_cancelled"} w = makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, req, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify booking status was updated (not hard deleted) var status string err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Errorf("failed to query booking: %v", err) } if status != "client_cancelled" { t.Errorf("expected status client_cancelled, got %s", status) } } // TestBookings_Delete_NotFound verifies that deleting a non-existent // booking returns HTTP 404. func TestBookings_Delete_NotFound(t *testing.T) { testutils.SetupTestDB(t) // Create test 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) // Note: deposits_required=0 by default now _, 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) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(DeleteBookingHandler) w := makeRequest(handler, "DELETE", "/api/bookings/nonexistent-id", nil, token) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) } } // TestBookings_Delete_NoShow24hThreshold tests the 24-hour no-show rule: // - Cancellation < 24 hours before appointment: treated as no-show (deposits = 3) // - Cancellation >= 24 hours before appointment: treated as late_cancellation func TestBookings_Delete_NoShow24hThreshold(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) 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) token := jwt.GenerateUserToken(userID) // Create booking 23 hours from now (will be < 24h when deleted) soonTime := time.Now().Add(23 * time.Hour).Truncate(time.Second) soonTime = time.Date(soonTime.Year(), soonTime.Month(), soonTime.Day(), 10, 0, 0, 0, soonTime.Location()) bookingReq := CreateBookingRequest{ StartTime: soonTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token) var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Fatalf("failed to parse booking response: %v", err) } // Add payment so deletion works _, _ = db.DB.Exec(context.Background(), "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking.ID) // Delete within 24 hours (no forgiveness) - should result in no-show + deposits penalty delHandler := http.HandlerFunc(DeleteBookingHandler) delReq := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": false} w = makeRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token) 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 deposits were applied var deposits int err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } if deposits != 0 { t.Errorf("expected deposits=0 (admin flexibility, not auto-applied), got %d", deposits) } } // TestBookings_Delete_NoShow_WithForgiveness tests that admin can forgive a no-show func TestBookings_Delete_NoShow_WithForgiveness(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) 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) token := jwt.GenerateUserToken(userID) // Create booking 20 hours from now (will be < 24h) soonTime := time.Now().Add(20 * time.Hour).Truncate(time.Second) soonTime = time.Date(soonTime.Year(), soonTime.Month(), soonTime.Day(), 10, 0, 0, 0, soonTime.Location()) bookingReq := CreateBookingRequest{ StartTime: soonTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token) var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Fatalf("failed to parse booking response: %v", err) } // Add payment _, _ = db.DB.Exec(context.Background(), "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking.ID) // Delete within 24 hours WITH forgiveness trueVal := true delHandler := http.HandlerFunc(DeleteBookingHandler) delReq := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": trueVal} w = makeRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token) 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 NO deposits were applied (forgiveness worked) var deposits int err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } if deposits != 0 { t.Errorf("expected deposits=0 after forgiveness, got %d", deposits) } } // ============================================================================= // Unauthorized Tests // ============================================================================= // TestBookings_Unauthorized tests that all booking endpoints require // authentication. It verifies that requests without a token are rejected with // HTTP 401 for protected endpoints. func TestBookings_Unauthorized(t *testing.T) { testutils.SetupTestDB(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, 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) } 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) tests := []struct { name string method string path string body interface{} }{ { name: "Create without token", method: "POST", path: "/api/bookings", body: CreateBookingRequest{ServiceIDs: []string{serviceID}}, }, { name: "List without token", method: "GET", path: "/api/bookings", body: nil, }, { name: "Get without token", method: "GET", path: "/api/bookings/" + bookingID, body: nil, }, { name: "GetCalendar without token", method: "GET", path: "/api/bookings/" + bookingID + "/calendar", body: nil, }, { name: "Edit without token", method: "PUT", path: "/api/bookings/" + bookingID, body: EditBookingRequest{StartTime: time.Now().Add(96 * time.Hour)}, }, { name: "Delete without token", method: "DELETE", path: "/api/bookings/" + bookingID, body: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Get the appropriate handler var handler http.HandlerFunc switch tt.path { case "/api/bookings": if tt.method == "POST" { handler = CreateBookingHandler } else { handler = GetAllUserBookingsHandler } case "/api/bookings/" + bookingID: if tt.method == "GET" { handler = GetBookingHandler } else if tt.method == "PUT" { handler = EditBookingHandler } else { handler = DeleteBookingHandler } case "/api/bookings/" + bookingID + "/calendar": handler = GetBookingCalendarHandler } w := makeRequest(http.HandlerFunc(handler), tt.method, tt.path, tt.body, "") if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String()) } }) } } // ============================================================================= // Additional Edge Case Tests // ============================================================================= // TestBookings_List_Empty tests that listing bookings for a user with no // bookings returns an empty list with total 0. func TestBookings_List_Empty(t *testing.T) { testutils.SetupTestDB(t) // Create test user (with no bookings) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, 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) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetAllUserBookingsHandler) w := makeRequest(handler, "GET", "/api/bookings", nil, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) } var resp BookingListResponse if err := parseResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if len(resp.Bookings) != 0 { t.Errorf("expected 0 bookings, got %d", len(resp.Bookings)) } if resp.Total != 0 { t.Errorf("expected total 0, got %d", resp.Total) } } // TestBookings_Get_InvalidBookingID verifies that using an invalid // booking ID format returns HTTP 404 or 400. func TestBookings_Get_InvalidBookingID(t *testing.T) { testutils.SetupTestDB(t) // Create test 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) // Note: deposits_required=0 by default now _, 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) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingHandler) w := makeRequest(handler, "GET", "/api/bookings/", nil, token) // trailing slash // Should return 404 or 400 depending on routing if w.Code != http.StatusNotFound && w.Code != http.StatusBadRequest { t.Errorf("expected status 404 or 400, got %d", w.Code) } } // TestBookings_Create_PastDate verifies that creating a booking with a // past start time fails with HTTP 400 Bad Request. func TestBookings_Create_PastDate(t *testing.T) { testutils.SetupTestDB(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) pastTime := time.Now().Add(-24 * time.Hour).Truncate(time.Second) req := CreateBookingRequest{ StartTime: pastTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for past date, got %d. body: %s", w.Code, w.Body.String()) } } // TestBookings_Create_MinimumAdvance tests that bookings must be made at least // 1 hour in advance (changed from 48h deposit requirement to universal 1h rule). func TestBookings_Create_MinimumAdvance(t *testing.T) { testutils.SetupTestDB(t) // Seed working hours for booking tests seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Test booking 1+ hour in advance - should succeed aheadTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) aheadTime = time.Date(aheadTime.Year(), aheadTime.Month(), aheadTime.Day(), 10, 0, 0, 0, aheadTime.Location()) req := CreateBookingRequest{ StartTime: aheadTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Errorf("failed to parse response: %v", err) } if booking.DepositRequired { t.Error("expected deposit_required=false when user has deposits_required=0") } } // TestBookings_Create_WithNotes_StatusPending tests that when a booking is created with notes, // the booking status is automatically set to 'pending' (requires admin approval). func TestBookings_Create_WithNotes_StatusPending(t *testing.T) { testutils.SetupTestDB(t) // Seed working hours seedDefaultWorkingHours(t) 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) token := jwt.GenerateUserToken(userID) // Booking 2+ hours ahead with notes futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) notes := "Special treatment needed" req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, Notes: ¬es, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Errorf("failed to parse response: %v", err) } if booking.Status != "pending" { t.Errorf("expected status 'pending' when notes provided, got %q", booking.Status) } if booking.Notes == nil || *booking.Notes != notes { t.Error("notes not preserved in booking") } } // TestBookings_Create_WithoutNotes_StatusConfirmed tests that when a booking is created without notes, // the booking status is automatically set to 'confirmed' (auto-approved). func TestBookings_Create_WithoutNotes_StatusConfirmed(t *testing.T) { testutils.SetupTestDB(t) // Seed working hours seedDefaultWorkingHours(t) 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) token := jwt.GenerateUserToken(userID) // Booking 2+ hours ahead without notes futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, Notes: nil, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Errorf("failed to parse response: %v", err) } if booking.Status != "confirmed" { t.Errorf("expected status 'confirmed' without notes, got %q", booking.Status) } } // TestBookings_Create_Within1Hour_ShouldFail tests that bookings less than 1 hour in advance are rejected. func TestBookings_Create_Within1Hour_ShouldFail(t *testing.T) { testutils.SetupTestDB(t) // Seed working hours seedDefaultWorkingHours(t) 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) token := jwt.GenerateUserToken(userID) // Booking less than 1 hour in advance (30 minutes) sooonTime := time.Now().Add(30 * time.Minute).Truncate(time.Second) req := CreateBookingRequest{ StartTime: sooonTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } // TestBookings_Create_MultipleServices verifies that a booking can include // multiple services at once, and all services are properly associated with // the booking in the database. func TestBookings_Create_MultipleServices(t *testing.T) { testutils.SetupTestDB(t) // Seed working hours for booking tests seedDefaultWorkingHours(t) 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 to avoid 48h advance booking requirement _, 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) } serviceID1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create service 1: %v", err) } defer fixtures.DeleteService(db.DB, serviceID1) serviceID2, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create service 2: %v", err) } defer fixtures.DeleteService(db.DB, serviceID2) token := jwt.GenerateUserToken(userID) // Use 10:00 to ensure services fit within working hours (08:00-20:00) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID1, serviceID2}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Errorf("failed to parse response: %v", err) } if len(booking.Services) != 2 { t.Errorf("expected 2 services in booking, got %d", len(booking.Services)) } } // ============================================================================= // No-Show Deposit Logic Tests (Task 5) // ============================================================================= // TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3 tests that when a confirmed // booking is cancelled within 24 hours (with no forgiveness), the system // overrides the cancellation to "no_show" and deposits_required stays 0. func TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) soonTime := nextWorkingHour() bookingReq := CreateBookingRequest{ StartTime: soonTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token) var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Fatalf("failed to parse booking response: %v", err) } _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", booking.ID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } _, err = db.DB.Exec(context.Background(), "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking.ID) if err != nil { t.Fatalf("failed to create payment: %v", err) } delHandler := http.HandlerFunc(DeleteBookingHandler) delReq := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": false} w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "") if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var deposits int err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } if deposits != 0 { t.Errorf("expected deposits_required=0, got %d", deposits) } var status string err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status) if err != nil { t.Errorf("failed to query booking status: %v", err) } if status != "no_show" { t.Errorf("expected status 'no_show', got %s", status) } } // TestDeleteBooking_CancelOver24h_NoDepositPenalty tests that when a booking is // cancelled with more than 24 hours notice (client_cancelled), no deposit penalty // is applied and deposits_required remains 0. func TestDeleteBooking_CancelOver24h_NoDepositPenalty(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) // Create test user with deposits_required = 0 userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Ensure 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Create booking with start_time = now + 48 hours (> 24h notice) laterTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) laterTime = time.Date(laterTime.Year(), laterTime.Month(), laterTime.Day(), 10, 0, 0, 0, laterTime.Location()) bookingReq := CreateBookingRequest{ StartTime: laterTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token) var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Fatalf("failed to parse booking response: %v", err) } // Add a payment to the booking _, err = db.DB.Exec(context.Background(), "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking.ID) if err != nil { t.Fatalf("failed to create payment: %v", err) } // Delete booking with reason "client_cancelled" (>= 24h notice) delHandler := http.HandlerFunc(DeleteBookingHandler) delReq := map[string]interface{}{"reason": "client_cancelled"} w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "") if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify deposits_required = 0 (no penalty) var deposits int err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } if deposits != 0 { t.Errorf("expected deposits_required=0, got %d", deposits) } // Verify booking status = "client_cancelled" var status string err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status) if err != nil { t.Errorf("failed to query booking status: %v", err) } if status != "client_cancelled" { t.Errorf("expected status 'client_cancelled', got %s", status) } } // TestDeleteBooking_NoShowWithForgiveness_NoPenalty tests that when a confirmed // booking is cancelled within 24 hours with forgiveness, the system overrides to // "client_cancelled" (no no-show penalty). func TestDeleteBooking_NoShowWithForgiveness_NoPenalty(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) soonTime := nextWorkingHour() bookingReq := CreateBookingRequest{ StartTime: soonTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token) var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Fatalf("failed to parse booking response: %v", err) } _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", booking.ID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } _, err = db.DB.Exec(context.Background(), "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking.ID) if err != nil { t.Fatalf("failed to create payment: %v", err) } delHandler := http.HandlerFunc(DeleteBookingHandler) delReq := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": true} w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "") if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify deposits_required = 0 (no penalty due to forgiveness) var deposits int err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } if deposits != 0 { t.Errorf("expected deposits_required=0 after forgiveness, got %d", deposits) } // Verify booking status = "client_cancelled" (not "no_show") var status string err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status) if err != nil { t.Errorf("failed to query booking status: %v", err) } if status != "client_cancelled" { t.Errorf("expected status 'client_cancelled' with forgiveness, got %s", status) } } // TestDeleteBooking_SecondNoShow_StaysAt3 tests that when a user has their second // no-show, the deposits_required stays at 3 (not 6). The handler sets deposits to 3 // on the first no-show and doesn't increment on subsequent no-shows. func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) // Create test user with deposits_required = 0 userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Ensure 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // === First booking: no-show === soonTime1 := nextWorkingHour() bookingReq1 := CreateBookingRequest{ StartTime: soonTime1, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", bookingReq1, token) var booking1 Booking if err := parseResponseBody(w, &booking1); err != nil { t.Fatalf("failed to parse booking response: %v", err) } _, _ = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", booking1.ID) _, _ = db.DB.Exec(context.Background(), "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking1.ID) delHandler := http.HandlerFunc(DeleteBookingHandler) delReq1 := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": false} w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking1.ID, delReq1, token, "") if w.Code != http.StatusOK { t.Fatalf("first delete failed: %d body: %s", w.Code, w.Body.String()) } var deposits1 int err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits1) if err != nil { t.Errorf("failed to query deposits: %v", err) } if deposits1 != 0 { t.Errorf("expected deposits=0 after first no-show, got %d", deposits1) } // === Second booking: no-show === soonTime2 := nextWorkingHour().AddDate(0, 0, 1) bookingReq2 := CreateBookingRequest{ StartTime: soonTime2, ServiceIDs: []string{serviceID}, } w = makeRequest(handler, "POST", "/api/bookings", bookingReq2, token) var booking2 Booking if err := parseResponseBody(w, &booking2); err != nil { t.Fatalf("failed to parse second booking response: %v", err) } _, _ = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", booking2.ID) _, _ = db.DB.Exec(context.Background(), "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking2.ID) delReq2 := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": false} w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking2.ID, delReq2, token, "") if w.Code != http.StatusOK { t.Fatalf("second delete failed: %d body: %s", w.Code, w.Body.String()) } var deposits2 int err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits2) if err != nil { t.Errorf("failed to query deposits: %v", err) } if deposits2 != 0 { t.Errorf("expected deposits=0, got %d", deposits2) } } // TestCountUnforgivenNoShows_ExcludesForgiven tests that CountUnforgivenNoShows // excludes bookings that have been forgiven (in forgiven_no_shows table). func TestCountUnforgivenNoShows_ExcludesForgiven(t *testing.T) { testutils.SetupTestDB(t) // Create test 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) 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 3 bookings with status "no_show" within last 6 months now := time.Now() for i := 0; i < 3; i++ { startTime := now.Add(time.Duration(i*30) * 24 * time.Hour) // 0, 30, 60 days ago var bookingID string err := db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status, notes) VALUES ($1, $2, 'no_show', 'test no-show') RETURNING id `, userID, startTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking %d: %v", i, err) } // Link service _, 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) } // Forgive the first one (i == 0) if i == 0 { _, err = db.DB.Exec(context.Background(), "INSERT INTO forgiven_no_shows (booking_id) VALUES ($1)", bookingID) if err != nil { t.Fatalf("failed to forgive no-show: %v", err) } } } // Call CountUnforgivenNoShows directly (unexported but in same package) count, err := CountUnforgivenNoShows(context.Background(), userID) if err != nil { t.Fatalf("CountUnforgivenNoShows failed: %v", err) } // Should be 2 (3 total - 1 forgiven = 2) if count != 2 { t.Errorf("expected count=2 (3 total - 1 forgiven), got %d", count) } } // TestCountUnforgivenNoShows_ExcludesOld tests that CountUnforgivenNoShows // excludes no-shows older than 6 months. func TestCountUnforgivenNoShows_ExcludesOld(t *testing.T) { testutils.SetupTestDB(t) // Create test 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) serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) now := time.Now() // Create 1 booking with status "no_show" from 7 months ago (should be excluded) oldStartTime := now.Add(-7 * 30 * 24 * time.Hour) var oldBookingID string err = db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status, notes) VALUES ($1, $2, 'no_show', 'old no-show') RETURNING id `, userID, oldStartTime).Scan(&oldBookingID) if err != nil { t.Fatalf("failed to create old booking: %v", err) } _, _ = db.DB.Exec(context.Background(), "INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)", oldBookingID, serviceID) // Create 1 booking with status "no_show" from 1 month ago (should be included) recentStartTime := now.Add(-30 * 24 * time.Hour) var recentBookingID string err = db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status, notes) VALUES ($1, $2, 'no_show', 'recent no-show') RETURNING id `, userID, recentStartTime).Scan(&recentBookingID) if err != nil { t.Fatalf("failed to create recent booking: %v", err) } _, _ = db.DB.Exec(context.Background(), "INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)", recentBookingID, serviceID) // Call CountUnforgivenNoShows directly count, err := CountUnforgivenNoShows(context.Background(), userID) if err != nil { t.Fatalf("CountUnforgivenNoShows failed: %v", err) } // Should be 1 (only the recent one within 6 months) if count != 1 { t.Errorf("expected count=1 (only recent within 6 months), got %d", count) } } // TestApplyDepositsIfNeeded_AppliesAt2Plus tests that ApplyDepositsIfNeeded // applies 3 deposits when user has 2 or more unforgiven no-shows in last 6 months. func TestApplyDepositsIfNeeded_AppliesAt2Plus(t *testing.T) { testutils.SetupTestDB(t) // Create test user with deposits_required = 0 userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Ensure 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) } 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 2 bookings with status "no_show" within last 6 months now := time.Now() for i := 0; i < 2; i++ { startTime := now.Add(time.Duration(i*30) * 24 * time.Hour) // 0, 30 days ago var bookingID string err := db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status, notes) VALUES ($1, $2, 'no_show', 'test no-show') RETURNING id `, userID, startTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking %d: %v", i, err) } // Link service _, 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) } } // Call ApplyDepositsIfNeeded directly applied, err := ApplyDepositsIfNeeded(context.Background(), userID) if err != nil { t.Fatalf("ApplyDepositsIfNeeded failed: %v", err) } // Should return true (deposits were applied) if !applied { t.Error("expected ApplyDepositsIfNeeded to return true when 2+ no-shows") } // Verify deposits_required = 3 var deposits int err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } if deposits != 3 { t.Errorf("expected deposits_required=3, got %d", deposits) } } // TestApplyDepositsIfNeeded_DoesNotApplyAt1 tests that ApplyDepositsIfNeeded // does NOT apply deposits when user has only 1 unforgiven no-show. func TestApplyDepositsIfNeeded_DoesNotApplyAt1(t *testing.T) { testutils.SetupTestDB(t) // Create test user with deposits_required = 0 userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Ensure 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) } 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 1 booking with status "no_show" within last 6 months startTime := time.Now().Add(-30 * 24 * time.Hour) // 30 days ago var bookingID string err = db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status, notes) VALUES ($1, $2, 'no_show', 'test no-show') RETURNING id `, userID, startTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Link service _, 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) } // Call ApplyDepositsIfNeeded directly applied, err := ApplyDepositsIfNeeded(context.Background(), userID) if err != nil { t.Fatalf("ApplyDepositsIfNeeded failed: %v", err) } // Should return false (deposits were NOT applied) if applied { t.Error("expected ApplyDepositsIfNeeded to return false when only 1 no-show") } // Verify deposits_required = 0 (unchanged) var deposits int err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } if deposits != 0 { t.Errorf("expected deposits_required=0 (unchanged), got %d", deposits) } } // ============================================================================= // Notes Validation Tests // ============================================================================= // TestNotesValidation_UnderLimit verifies that a Notes string of 999,999 // characters passes go-playground/validator validation (max=1000000). func TestNotesValidation_UnderLimit(t *testing.T) { longNotes := strings.Repeat("a", 999999) req := CreateBookingRequest{ StartTime: time.Now().Add(72 * time.Hour), ServiceIDs: []string{"test-service-id"}, Notes: &longNotes, } err := validators.Validate.Struct(&req) if err != nil { t.Errorf("expected validation to pass for 999999-char notes, got: %v", err) } } // TestNotesValidation_AtLimit verifies that a Notes string of exactly // 1,000,000 characters passes validation (max=1000000, boundary test). func TestNotesValidation_AtLimit(t *testing.T) { exactNotes := strings.Repeat("b", 1000000) req := CreateBookingRequest{ StartTime: time.Now().Add(72 * time.Hour), ServiceIDs: []string{"test-service-id"}, Notes: &exactNotes, } err := validators.Validate.Struct(&req) if err != nil { t.Errorf("expected validation to pass for 1000000-char notes (boundary), got: %v", err) } } // TestNotesValidation_OverLimit verifies that a Notes string of 1,000,001 // characters fails validation with max=1000000. func TestNotesValidation_OverLimit(t *testing.T) { tooLongNotes := strings.Repeat("c", 1000001) req := CreateBookingRequest{ StartTime: time.Now().Add(72 * time.Hour), ServiceIDs: []string{"test-service-id"}, Notes: &tooLongNotes, } err := validators.Validate.Struct(&req) if err == nil { t.Error("expected validation to fail for 1000001-char notes (over limit)") } } // TestNotesValidation_NilPointer verifies that a nil Notes pointer passes // validation (omitempty tag allows nil/empty). func TestNotesValidation_NilPointer(t *testing.T) { req := CreateBookingRequest{ StartTime: time.Now().Add(72 * time.Hour), ServiceIDs: []string{"test-service-id"}, Notes: nil, } err := validators.Validate.Struct(&req) if err != nil { t.Errorf("expected validation to pass for nil Notes (omitempty), got: %v", err) } } // TestNotesValidation_EmptyString verifies that an empty string Notes passes // validation (omitempty and max=1000000 allows empty strings). func TestNotesValidation_EmptyString(t *testing.T) { emptyNotes := "" req := CreateBookingRequest{ StartTime: time.Now().Add(72 * time.Hour), ServiceIDs: []string{"test-service-id"}, Notes: &emptyNotes, } err := validators.Validate.Struct(&req) if err != nil { t.Errorf("expected validation to pass for empty string Notes, got: %v", err) } } // Ensure test compilation - import pgxpool to avoid unused import var _ = func() *pgxpool.Pool { return nil } // Import mw to avoid unused import var _ = mw.UserIDKey // ============================================================================= // Auth and Security Tests // ============================================================================= // TestBookings_Get_NoAuthHeader confirms that accessing a booking without // an Authorization header returns HTTP 401 Unauthorized. func TestBookings_Get_NoAuthHeader(t *testing.T) { testutils.SetupTestDB(t) 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 to avoid 48h advance booking requirement _, 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) } 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) // Make request WITHOUT token (empty string passed as token parameter) handler := http.HandlerFunc(GetBookingHandler) w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, "") if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401 for missing auth, got %d", w.Code) } } // ============================================================================= // Calendar Export Tests - ICS Format Validation // ============================================================================= // TestBookings_GetCalendar_ValidICS validates that the ICS calendar export // contains all required fields: BEGIN:VCALENDAR, END:VCALENDAR, BEGIN:VEVENT, // END:VEVENT, DTSTART, DTEND, and SUMMARY. func TestBookings_GetCalendar_ValidICS(t *testing.T) { testutils.SetupTestDB(t) 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 to avoid 48h advance booking requirement _, 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) } 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) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingCalendarHandler) w := makeRequest(handler, "GET", "/api/bookings/"+bookingID+"/calendar", nil, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } body := w.Body.String() // Validate ICS structure is present if !bytes.Contains(w.Body.Bytes(), []byte("BEGIN:VCALENDAR")) { t.Error("ICS response missing BEGIN:VCALENDAR") } if !bytes.Contains(w.Body.Bytes(), []byte("END:VCALENDAR")) { t.Error("ICS response missing END:VCALENDAR") } if !bytes.Contains(w.Body.Bytes(), []byte("BEGIN:VEVENT")) { t.Error("ICS response missing BEGIN:VEVENT") } if !bytes.Contains(w.Body.Bytes(), []byte("END:VEVENT")) { t.Error("ICS response missing END:VEVENT") } // Validate basic ICS properties if !bytes.Contains(w.Body.Bytes(), []byte("DTSTART")) { t.Error("ICS response missing DTSTART") } if !bytes.Contains(w.Body.Bytes(), []byte("DTEND")) { t.Error("ICS response missing DTEND") } if !bytes.Contains(w.Body.Bytes(), []byte("SUMMARY")) { t.Error("ICS response missing SUMMARY") } if len(body) == 0 { t.Error("expected non-empty ICS response") } } // ============================================================================= // Cancellation and Notification Tests // ============================================================================= // TestUserCancelBooking_ConfirmedCreatesNotification verifies that when a // user cancels a confirmed booking (one with payments), an admin notification // is created to alert staff of the cancellation. func TestUserCancelBooking_ConfirmedCreatesNotification(t *testing.T) { testutils.SetupTestDB(t) 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 to avoid 48h advance booking requirement _, 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) } 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) // First, confirm the booking (so it's not pending) _, 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) } // Add a payment to trigger soft delete path (bookings with payments use soft delete) _, err = db.DB.Exec(context.Background(), ` INSERT INTO payments (id, booking_id, payment_type, payment_method, amount, status, created_at) VALUES ($1, $2, 'deposit', 'in_person_card', 50.00, 'completed', NOW()) `, bookingID[:8]+"pay", bookingID) if err != nil { t.Fatalf("failed to add payment: %v", err) } token := jwt.GenerateUserToken(userID) // Cancel the confirmed booking with a reason (required for soft delete) handler := http.HandlerFunc(DeleteBookingHandler) reqBody := map[string]string{"reason": "client_cancelled"} w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, reqBody, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify admin notification was created var notifCount int err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'cancelled_booking'`, bookingID).Scan(¬ifCount) if err != nil { t.Errorf("failed to query notifications: %v", err) } if notifCount != 1 { t.Errorf("expected 1 admin notification for confirmed->cancelled, got %d", notifCount) } } // TestUserCancelBooking_PendingNoNotification verifies that cancelling a // pending booking (one without payments) does NOT create an admin notification, // as pending cancellations don't require staff attention. func TestUserCancelBooking_PendingNoNotification(t *testing.T) { testutils.SetupTestDB(t) 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 to avoid 48h advance booking requirement _, 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) } 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) // Verify booking is in 'pending' status var status string err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if status != "pending" { t.Fatalf("expected booking status 'pending', got %s", status) } token := jwt.GenerateUserToken(userID) // Cancel the pending booking handler := http.HandlerFunc(DeleteBookingHandler) w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify NO admin notification was created (pending cancellations don't notify) var notifCount int err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'cancelled_booking'`, bookingID).Scan(¬ifCount) if err != nil { t.Errorf("failed to query notifications: %v", err) } if notifCount != 0 { t.Errorf("expected NO admin notification for pending->cancelled, got %d", notifCount) } } // ============================================================================= // Transaction and Error Handling Tests // ============================================================================= // TestUserCancelBooking_TransactionIntegrity verifies that if any part of the // cancellation transaction fails, the booking status is NOT changed (rollback behavior) func TestUserCancelBooking_TransactionIntegrity(t *testing.T) { testutils.SetupTestDB(t) 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 to avoid 48h advance booking requirement _, 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) } 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) // 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) } // Add a payment to trigger soft delete path (bookings with payments use soft delete) _, err = db.DB.Exec(context.Background(), ` INSERT INTO payments (id, booking_id, payment_type, payment_method, amount, status, created_at) VALUES ($1, $2, 'deposit', 'in_person_card', 50.00, 'completed', NOW()) `, bookingID[:8]+"pay", bookingID) if err != nil { t.Fatalf("failed to add payment: %v", err) } // Verify initial state var statusBefore string err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&statusBefore) if statusBefore != "confirmed" { t.Fatalf("expected status 'confirmed' before cancel, got %s", statusBefore) } token := jwt.GenerateUserToken(userID) // Cancel the booking with a reason (required for soft delete) handler := http.HandlerFunc(DeleteBookingHandler) reqBody := map[string]string{"reason": "client_cancelled"} w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, reqBody, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify that status WAS changed (successful transaction commit) var statusAfter string err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&statusAfter) if statusAfter == "confirmed" { t.Error("booking status should have changed after cancellation (transaction should have committed)") } } // TestCreateEditRequest verifies that a user can request an edit to their // confirmed booking (e.g., change time). This creates a booking_edit_request record // and generates an admin notification for staff review. func TestCreateEditRequest(t *testing.T) { testutils.SetupTestDB(t) 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 to avoid 48h advance booking requirement _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Use a start time ~36h from now so auto-approval (>=48h) does not fire bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) 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) } token := jwt.GenerateUserToken(userID) // Create edit request handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "notes": "Please change the time", } w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } // Verify edit request was created var erCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) } if erCount != 1 { t.Errorf("expected 1 edit request, got %d", erCount) } // Verify admin notification was created var notifCount int err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL`, bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query notifications: %v", err) } if notifCount != 1 { t.Errorf("expected 1 unacknowledged admin notification, got %d", notifCount) } } // TestCreateEditRequest_WithTimeChange verifies that a user can request an edit to // change the booking time, and a time_blocker is created to reserve the new slot. func TestCreateEditRequest_WithTimeChange(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Use a start time ~36h from now so auto-approval (>=48h) does not fire bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) 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 status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } token := jwt.GenerateUserToken(userID) newStartTime := time.Now().Add(24 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var erNewTime time.Time err = db.DB.QueryRow(context.Background(), "SELECT new_start_time FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erNewTime) if err != nil { t.Fatalf("failed to query edit request: %v", err) } if !erNewTime.Truncate(time.Second).Equal(newStartTime) { t.Errorf("expected new_start_time %v, got %v", newStartTime, erNewTime) } var blockerCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCount) if err != nil { t.Fatalf("failed to query time_blockers: %v", err) } if blockerCount != 1 { t.Errorf("expected 1 time_blocker for edit request, got %d", blockerCount) } var blockerStart time.Time var blockerDuration int err = db.DB.QueryRow(context.Background(), "SELECT start_time, duration_minutes FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerStart, &blockerDuration) if err != nil { t.Fatalf("failed to query time_blocker details: %v", err) } if !blockerStart.Truncate(time.Second).Equal(newStartTime) { t.Errorf("expected blocker start_time %v, got %v", newStartTime, blockerStart) } if blockerDuration < 15 { t.Errorf("expected blocker duration >= 15, got %d", blockerDuration) } } // TestDeleteEditRequest tests that user deleting their edit request deletes the admin notification func TestDeleteEditRequest(t *testing.T) { testutils.SetupTestDB(t) 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) 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) } // Create edit request directly in DB (simulating user request) var editRequestID string err = db.DB.QueryRow(context.Background(), `INSERT INTO booking_edit_requests (booking_id, requested_by, notes) VALUES ($1, $2, 'Please change time') RETURNING id`, bookingID, userID).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_requested', $1, $2)`, bookingID, userID) if err != nil { t.Fatalf("failed to create admin notification: %v", err) } token := jwt.GenerateUserToken(userID) // Delete edit request (user cancels their request) handler := http.HandlerFunc(DeleteEditRequestHandler) w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, token) if w.Code != http.StatusNoContent { t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) } // Verify edit request was 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 0 edit requests after delete, got %d", erCount) } // Verify admin notification was DELETED (not acknowledged) var notifCount int err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query notifications: %v", err) } if notifCount != 0 { t.Errorf("expected 0 admin notifications after delete, got %d", notifCount) } } // TestAdminApproveEditRequest tests that admin approving acknowledges the notification (not deletes) func TestAdminApproveEditRequest(t *testing.T) { testutils.SetupTestDB(t) 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) } 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) // 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) } // Create edit request directly in DB var editRequestID string newTime := time.Now().Add(24 * time.Hour).Truncate(time.Minute) 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, newTime, emptyServices).Scan(&editRequestID) if err != nil { t.Fatalf("failed to create edit request: %v", err) } 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_requested', $1, $2)`, bookingID, userID) if err != nil { t.Fatalf("failed to create admin notification: %v", err) } // Verify notification starts as unacknowledged var ackTime *time.Time err = db.DB.QueryRow(context.Background(), `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.Fatalf("expected notification to be unacknowledged initially") } // Simulate admin approval adminToken := jwt.GenerateAdminToken() // Create request with chi context req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil) req.Header.Set("Authorization", "Bearer "+adminToken) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("request_id", editRequestID) req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) w := httptest.NewRecorder() AdminApproveEditRequestHandler(w, req) if w.Code != http.StatusNoContent && w.Code != http.StatusOK { t.Errorf("expected status 200/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 admin notification was ACKNOWLEDGED (not deleted) - history preserved var ackTimeAfter *time.Time err = db.DB.QueryRow(context.Background(), `SELECT acknowledged_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(&ackTimeAfter) if err != nil { t.Fatalf("failed to query notification: %v", err) } if ackTimeAfter == nil { t.Errorf("expected notification to be acknowledged after approve, but acknowledged_at is still NULL") } } // TestAdminRejectEditRequest tests that admin rejecting acknowledges the notification (not deletes) func TestAdminRejectEditRequest(t *testing.T) { testutils.SetupTestDB(t) 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) } 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) // 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) } // Create edit request directly in DB var editRequestID string err = db.DB.QueryRow(context.Background(), `INSERT INTO booking_edit_requests (booking_id, requested_by, notes) VALUES ($1, $2, 'Please change time') RETURNING id`, bookingID, userID).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_requested', $1, $2)`, bookingID, userID) if err != nil { t.Fatalf("failed to create admin notification: %v", err) } // Verify notification starts as unacknowledged var ackTime *time.Time err = db.DB.QueryRow(context.Background(), `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.Fatalf("expected notification to be unacknowledged initially") } // Simulate admin denial adminToken := jwt.GenerateAdminToken() // Create request with chi context req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/deny", nil) req.Header.Set("Authorization", "Bearer "+adminToken) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("request_id", editRequestID) req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) w := httptest.NewRecorder() AdminRejectEditRequestHandler(w, req) if w.Code != http.StatusNoContent && w.Code != http.StatusOK { t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String()) } // Verify edit request was deleted (rejected) 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 reject, got %d", erCount) } // Verify admin notification was ACKNOWLEDGED (not deleted) - history preserved var ackTimeAfter *time.Time err = db.DB.QueryRow(context.Background(), `SELECT acknowledged_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(&ackTimeAfter) if err != nil { t.Fatalf("failed to query notification: %v", err) } if ackTimeAfter == nil { t.Errorf("expected notification to be acknowledged after reject, but acknowledged_at is still NULL") } } // TestAdminApproveEditRequest_DeletesTimeBlocker verifies that when admin approves // an edit request, the associated time_blocker reservation is deleted. func TestAdminApproveEditRequest_DeletesTimeBlocker(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) 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 status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } userToken := jwt.GenerateUserToken(userID) newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) createReq := http.HandlerFunc(RequestEditHandler) createBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken) if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String()) } var blockerCountBefore int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore) if err != nil { t.Fatalf("failed to query blockers: %v", err) } if blockerCountBefore != 1 { t.Fatalf("expected 1 blocker before approval, got %d", blockerCountBefore) } var editRequestID string err = db.DB.QueryRow(context.Background(), "SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID) if err != nil { t.Fatalf("failed to get edit request ID: %v", err) } r := chi.NewRouter() r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/approve", AdminApproveEditRequestHandler) req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil) ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin") rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("request_id", editRequestID) ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) req = req.WithContext(ctx) w = httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusNoContent && w.Code != http.StatusOK { t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String()) } var blockerCountAfter int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter) if err != nil { t.Fatalf("failed to query blockers after approval: %v", err) } if blockerCountAfter != 0 { t.Errorf("expected 0 blockers after approval, got %d", blockerCountAfter) } } // TestAdminRejectEditRequest_DeletesTimeBlocker verifies that when admin rejects // an edit request, the associated time_blocker reservation is deleted. func TestAdminRejectEditRequest_DeletesTimeBlocker(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) 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 status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } userToken := jwt.GenerateUserToken(userID) newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) createReq := http.HandlerFunc(RequestEditHandler) createBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken) if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String()) } var blockerCountBefore int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore) if err != nil { t.Fatalf("failed to query blockers: %v", err) } if blockerCountBefore != 1 { t.Fatalf("expected 1 blocker before rejection, got %d", blockerCountBefore) } var editRequestID string err = db.DB.QueryRow(context.Background(), "SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID) if err != nil { t.Fatalf("failed to get edit request ID: %v", err) } r := chi.NewRouter() r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/deny", AdminRejectEditRequestHandler) req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/deny", nil) ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin") rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("request_id", editRequestID) ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) req = req.WithContext(ctx) w = httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusNoContent && w.Code != http.StatusOK { t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String()) } var blockerCountAfter int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter) if err != nil { t.Fatalf("failed to query blockers after rejection: %v", err) } if blockerCountAfter != 0 { t.Errorf("expected 0 blockers after rejection, got %d", blockerCountAfter) } } // TestDeleteEditRequest_DeletesTimeBlocker verifies that when user cancels their // own edit request, the associated time_blocker reservation is deleted. func TestDeleteEditRequest_DeletesTimeBlocker(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) 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 status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } userToken := jwt.GenerateUserToken(userID) newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) createReq := http.HandlerFunc(RequestEditHandler) createBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken) if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String()) } var blockerCountBefore int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore) if err != nil { t.Fatalf("failed to query blockers: %v", err) } if blockerCountBefore != 1 { t.Fatalf("expected 1 blocker before delete, got %d", blockerCountBefore) } delHandler := http.HandlerFunc(DeleteEditRequestHandler) w = makeRequest(delHandler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, userToken) if w.Code != http.StatusOK && w.Code != http.StatusNoContent { t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String()) } var blockerCountAfter int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter) if err != nil { t.Fatalf("failed to query blockers after delete: %v", err) } if blockerCountAfter != 0 { t.Errorf("expected 0 blockers after user delete, got %d", blockerCountAfter) } } // TestAdminApproveEditRequest_TimeBlockerOverlap tests that approving an edit // request fails when the new time conflicts with an existing time_blocker. func TestAdminApproveEditRequest_TimeBlockerOverlap(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) 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 status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } userToken := jwt.GenerateUserToken(userID) newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) createReq := http.HandlerFunc(RequestEditHandler) createBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken) if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String()) } _, err = db.DB.Exec(context.Background(), ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Existing blocker', $2) `, newStartTime, userID) if err != nil { t.Fatalf("failed to create blocking time_blocker: %v", err) } var editRequestID string err = db.DB.QueryRow(context.Background(), "SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID) if err != nil { t.Fatalf("failed to get edit request ID: %v", err) } r := chi.NewRouter() r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/approve", AdminApproveEditRequestHandler) req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil) ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin") rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("request_id", editRequestID) ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) req = req.WithContext(ctx) w = httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusConflict { t.Errorf("expected status 409 Conflict due to time_blocker overlap, got %d. body: %s", w.Code, w.Body.String()) } } // TestBookings_RequestEdit_BookingNotFound tests that requesting an edit for a non-existent booking returns 404 func TestBookings_RequestEdit_BookingNotFound(t *testing.T) { testutils.SetupTestDB(t) // Create test 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) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "notes": "Please change the time", } w := makeRequest(handler, "POST", "/api/bookings/nonexistent-booking-id/edit-request", reqBody, token) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) } } // TestBookings_RequestEdit_AlreadyHasPending tests that a user cannot create a second edit request while one already exists func TestBookings_RequestEdit_AlreadyHasPending(t *testing.T) { testutils.SetupTestDB(t) 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 to avoid 48h advance booking requirement _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) 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) } // Create a pending edit request directly in DB (pre-condition) _, err = db.DB.Exec(context.Background(), `INSERT INTO booking_edit_requests (booking_id, requested_by, notes) VALUES ($1, $2, 'Please change the time')`, bookingID, userID) if err != nil { t.Fatalf("failed to create initial edit request: %v", err) } // Create admin notification for the initial edit request _, err = db.DB.Exec(context.Background(), `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) } token := jwt.GenerateUserToken(userID) // Try to create another edit request via API // Note: The handler actually replaces (upserts) the existing request, not rejects it handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "notes": "Please change to a different day", } w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) // Expect HTTP 201 Created (handler replaces existing request) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } // Verify only 1 edit request exists in DB (the old one was replaced) var erCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) } if erCount != 1 { t.Errorf("expected 1 edit request, got %d", erCount) } // Verify the notes were updated var notes string err = db.DB.QueryRow(context.Background(), "SELECT notes FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(¬es) if err != nil { t.Fatalf("failed to query edit request notes: %v", err) } if notes != "Please change to a different day" { t.Errorf("expected notes 'Please change to a different day', got '%s'", notes) } } // ============================================================================= // Patch Test Validation Tests // ============================================================================= // TestBookings_Create_PatchTestRequired_NoRecord verifies that a user without a patch test record // cannot book a service that requires a patch test. The booking should be rejected with 400. func TestBookings_Create_PatchTestRequired_NoRecord(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) // Create user and service with patch test requirement 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 to avoid 48h requirement _, 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) } serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(db.DB) if err != nil { t.Fatalf("failed to create test service with patch test: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) _ = patchTestID // We don't delete patch tests, they cascade with service token := jwt.GenerateUserToken(userID) // Try to book service requiring patch test - user has no patch test record futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for missing patch test, got %d. body: %s", w.Code, w.Body.String()) } if !bytes.Contains(w.Body.Bytes(), []byte("patch test")) { t.Errorf("expected error message about patch test, got: %s", w.Body.String()) } } // TestBookings_Create_PatchTestRequired_WithinNoticePeriod verifies that a user // cannot book within the notice period after completing a patch test (e.g., 24h wait). func TestBookings_Create_PatchTestRequired_WithinNoticePeriod(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(db.DB) if err != nil { t.Fatalf("failed to create test service with patch test: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create patch test record with tested_at only 1 hour ago (notice is 24h) testedAt := time.Now().Add(-1 * time.Hour).Format("2006-01-02 15:04:05") err = fixtures.CreateUserPatchTest(db.DB, userID, patchTestID, testedAt) if err != nil { t.Fatalf("failed to create user patch test: %v", err) } token := jwt.GenerateUserToken(userID) // Try to book within notice period (24h required, but only 1h passed). // Booking start_time must be before testedAt + noticeHours to trigger this. // Use a booking time in the future (passes 1-hour advance check) but before // eligibleFrom (testedAt + 24h = now + 23h). futureTime := time.Now().Add(2 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), futureTime.Hour(), 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for within notice period, got %d. body: %s", w.Code, w.Body.String()) } if !bytes.Contains(w.Body.Bytes(), []byte("notice period")) { t.Errorf("expected error message about notice period, got: %s", w.Body.String()) } } // TestBookings_Create_PatchTestRequired_Expired verifies that a user // with an expired patch test cannot book services requiring patch test. func TestBookings_Create_PatchTestRequired_Expired(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(db.DB) if err != nil { t.Fatalf("failed to create test service with patch test: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create patch test record from 7 months ago (expiry is 6 months) testedAt := time.Now().AddDate(0, -7, 0).Format("2006-01-02 15:04:05") err = fixtures.CreateUserPatchTest(db.DB, userID, patchTestID, testedAt) if err != nil { t.Fatalf("failed to create user patch test: %v", err) } token := jwt.GenerateUserToken(userID) // Try to book with expired patch test futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for expired patch test, got %d. body: %s", w.Code, w.Body.String()) } if !bytes.Contains(w.Body.Bytes(), []byte("expired")) { t.Errorf("expected error message about expiry, got: %s", w.Body.String()) } } // TestBookings_Create_PatchTestRequired_ValidRecord verifies that a user // with a valid patch test record can successfully book services requiring patch test. func TestBookings_Create_PatchTestRequired_ValidRecord(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(db.DB) if err != nil { t.Fatalf("failed to create test service with patch test: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create patch test record from 48 hours ago (notice is 24h, so valid now) testedAt := time.Now().Add(-48 * time.Hour).Format("2006-01-02 15:04:05") err = fixtures.CreateUserPatchTest(db.DB, userID, patchTestID, testedAt) if err != nil { t.Fatalf("failed to create user patch test: %v", err) } token := jwt.GenerateUserToken(userID) // Book with valid patch test record (after notice period) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201 for valid patch test, got %d. body: %s", w.Code, w.Body.String()) } // Verify booking was created 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) } } // ============================================================================= // Deposit Requirement Tests // ============================================================================= // TestBookings_Create_DepositRequired_WithinAdvanceWindow verifies that a user with deposits_required > 0 // cannot book within the deposit advance window. They must complete more appointments // to remove this restriction. func TestBookings_Create_DepositRequired_WithinAdvanceWindow(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) 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=3 to trigger deposit advance booking requirement _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Try to book within the advance window (should be blocked by deposit advance rule). // Use 10h advance: passes 1h minimum, fails deposit advance window. withinWindow := time.Now().Add(10 * time.Hour) req := CreateBookingRequest{ StartTime: withinWindow, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for within advance-window booking with deposit requirement, got %d. body: %s", w.Code, w.Body.String()) } if !bytes.Contains(w.Body.Bytes(), []byte("hours in advance")) { t.Errorf("expected error message about advance window, got: %s", w.Body.String()) } } // TestBookings_Create_DepositRequired_After48Hours verifies that a user with deposits_required > 0 // CAN book if the start time is at least 48 hours in the future. func TestBookings_Create_DepositRequired_After48Hours(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) 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=3 to trigger 48h advance booking requirement _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Book more than 48 hours in advance (should succeed) after48h := time.Now().Add(72 * time.Hour).Truncate(time.Second) after48h = time.Date(after48h.Year(), after48h.Month(), after48h.Day(), 10, 0, 0, 0, after48h.Location()) req := CreateBookingRequest{ StartTime: after48h, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201 for booking after 48h, got %d. body: %s", w.Code, w.Body.String()) } // Verify booking was created 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) } } // TestBookings_Create_NoDepositRequired_Within48Hours verifies that a user with deposits_required=0 // can book at any time (no 48h restriction). func TestBookings_Create_NoDepositRequired_Within48Hours(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // deposits_required=0 means no 48h restriction _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Book within 48 hours (should succeed since no deposit required) within48h := time.Now().Add(24 * time.Hour).Truncate(time.Second) within48h = time.Date(within48h.Year(), within48h.Month(), within48h.Day(), 10, 0, 0, 0, within48h.Location()) req := CreateBookingRequest{ StartTime: within48h, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201 for booking within 48h with no deposit required, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // Deposit Snapshot and Field Tests // ============================================================================= // TestBookings_Create_DepositSnapshot verifies that deposit_required is snapshotted // at booking creation time from user's current deposits_required value. func TestBookings_Create_DepositSnapshot(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) 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=3 BEFORE creating booking _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Create booking after deposits_required is set after48h := time.Now().Add(72 * time.Hour).Truncate(time.Second) after48h = time.Date(after48h.Year(), after48h.Month(), after48h.Day(), 10, 0, 0, 0, after48h.Location()) req := CreateBookingRequest{ StartTime: after48h, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } // Verify deposit_required was snapshotted on the booking var depositRequired bool err = db.DB.QueryRow(context.Background(), "SELECT deposit_required FROM bookings WHERE user_id = $1", userID).Scan(&depositRequired) if err != nil { t.Fatalf("failed to query booking: %v", err) } if !depositRequired { t.Error("expected deposit_required=true to be snapshotted on booking") } // Now change user's deposits_required to 0 _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to update deposits_required: %v", err) } // Verify the booking's deposit_required is still true (snapshot is not updated) err = db.DB.QueryRow(context.Background(), "SELECT deposit_required FROM bookings WHERE user_id = $1", userID).Scan(&depositRequired) if err != nil { t.Fatalf("failed to query booking: %v", err) } if !depositRequired { t.Error("expected deposit_required to remain true after user's deposits_required changed") } } // TestBookings_Create_DepositRequired_OneActiveBookingLimit verifies that a user // with deposits_required > 0 can only have ONE active booking at a time. func TestBookings_Create_DepositRequired_OneActiveBookingLimit(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) 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=3 (triggers one-active-booking limit) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Create first booking (should succeed) after48h := time.Now().Add(72 * time.Hour).Truncate(time.Second) after48h = time.Date(after48h.Year(), after48h.Month(), after48h.Day(), 10, 0, 0, 0, after48h.Location()) req1 := CreateBookingRequest{ StartTime: after48h, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req1, token) if w.Code != http.StatusCreated { t.Fatalf("expected first booking to succeed, got %d. body: %s", w.Code, w.Body.String()) } // Try to create second booking (should fail - one active booking limit) after72h := time.Now().Add(96 * time.Hour).Truncate(time.Second) after72h = time.Date(after72h.Year(), after72h.Month(), after72h.Day(), 10, 0, 0, 0, after72h.Location()) req2 := CreateBookingRequest{ StartTime: after72h, ServiceIDs: []string{serviceID}, } w = makeRequest(handler, "POST", "/api/bookings", req2, token) if w.Code != http.StatusConflict { t.Errorf("expected status 409 for second booking attempt, got %d. body: %s", w.Code, w.Body.String()) } if !bytes.Contains(w.Body.Bytes(), []byte("active booking")) { t.Errorf("expected error about active booking, got: %s", w.Body.String()) } } // TestBookings_Get_DepositFieldsReturned verifies that GET /api/bookings returns // the deposit-related fields (deposit_required, deposit_amount, deposit_paid, deposit_deadline). func TestBookings_Get_DepositFieldsReturned(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) 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=3 and create booking _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Create booking with deposit requirement after48h := time.Now().Add(72 * time.Hour).Truncate(time.Second) after48h = time.Date(after48h.Year(), after48h.Month(), after48h.Day(), 10, 0, 0, 0, after48h.Location()) req := CreateBookingRequest{ StartTime: after48h, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } // GET the booking and verify deposit fields w = makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp 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 exist // Verify deposit fields exist if !booking.DepositRequired { t.Error("expected DepositRequired to be true") } if booking.DepositAmount <= 0 { t.Error("expected DepositAmount to be positive") } // DepositPaid is a bool, check it's set (should be false for new booking) // Just verify the field exists by accessing it _ = booking.DepositPaid if booking.DepositDeadline == nil { t.Error("expected DepositDeadline to be set") } } // ============================================================================= // Services End-to-End Tests // ============================================================================= // TestBookings_Get_ServicesReturned verifies that GET /api/bookings returns // services with correct name, price, and duration for each booking. func TestBookings_Get_ServicesReturned(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) 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) london, _ := time.LoadLocation("Europe/London") startTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour) token := jwt.GenerateUserToken(userID) // Create booking with standard service via the handler (production path) req := CreateBookingRequest{ StartTime: startTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d. body: %s", w.Code, w.Body.String()) } // Fetch bookings list w = makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } var resp 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 one booking") } booking := resp.Bookings[0] if booking.Services == nil { t.Fatal("expected services to be non-nil, got nil") } if len(booking.Services) == 0 { t.Fatal("expected at least one service in booking") } svc := booking.Services[0] if svc.ServiceID != serviceID { t.Errorf("expected service_id %s, got %s", serviceID, svc.ServiceID) } if svc.ServiceName == nil || *svc.ServiceName != "Test Service" { t.Errorf("expected service_name 'Test Service', got %v", svc.ServiceName) } if svc.Price == nil || *svc.Price != 50.00 { t.Errorf("expected price 50.00, got %v", svc.Price) } if svc.DurationMinutes == nil || *svc.DurationMinutes != 60 { t.Errorf("expected duration 60, got %v", svc.DurationMinutes) } } // TestBookings_Get_CustomServicesReturned verifies that GET /api/bookings // returns custom services correctly. func TestBookings_Get_CustomServicesReturned(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) csID, err := fixtures.CreateTestCustomService(db.DB) if err != nil { t.Fatalf("failed to create custom service: %v", err) } defer fixtures.DeleteCustomService(db.DB, csID) london, _ := time.LoadLocation("Europe/London") startTime := nextWeekday(time.Thursday, london).Add(10 * time.Hour) // Insert booking + custom service link directly var bookingID string err = db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'pending') RETURNING id `, userID, startTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) _, err = db.DB.Exec(context.Background(), ` INSERT INTO booking_custom_services (booking_id, custom_service_id) VALUES ($1, $2) `, bookingID, csID) if err != nil { t.Fatalf("failed to link custom service: %v", err) } token := jwt.GenerateUserToken(userID) w := makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } var resp 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 one booking") } booking := resp.Bookings[0] if booking.Services == nil { t.Fatal("expected services to be non-nil, got nil") } if len(booking.Services) == 0 { t.Fatal("expected at least one service in booking") } found := false for _, s := range booking.Services { if s.ServiceID == csID { found = true if s.ServiceName == nil || *s.ServiceName != "Test Custom Service" { t.Errorf("expected service_name 'Test Custom Service', got %v", s.ServiceName) } if s.Price == nil || *s.Price != 75.00 { t.Errorf("expected price 75.00, got %v", s.Price) } if s.DurationMinutes == nil || *s.DurationMinutes != 45 { t.Errorf("expected duration 45, got %v", s.DurationMinutes) } break } } if !found { t.Errorf("custom service %s not found in booking services", csID) } } // TestBookings_Get_EmptyServices verifies that GET /api/bookings returns an // empty array (not null) for bookings with no services. func TestBookings_Get_EmptyServices(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) london, _ := time.LoadLocation("Europe/London") startTime := nextWeekday(time.Friday, london).Add(10 * time.Hour) // Insert booking with NO services at all var bookingID string err = db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'pending') RETURNING id `, userID, startTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) token := jwt.GenerateUserToken(userID) w := makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } var resp 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 one booking") } booking := resp.Bookings[0] if booking.Services == nil { t.Fatal("expected services to be non-nil (empty array), got nil") } if len(booking.Services) != 0 { t.Errorf("expected empty services array, got %d services", len(booking.Services)) } } // TestBookings_Edit_ClosedDay_UserBlocked verifies that a regular user cannot edit a booking // to fall on a closed day (exceptional hours marked as is_open=false). func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } 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) // Create exceptional hours group for holiday var groupID string err = db.DB.QueryRow(context.Background(), "INSERT INTO exceptional_working_hours_groups (name, description) VALUES ($1, $2) RETURNING id", "Holiday Closure", "Closed for holiday").Scan(&groupID) if err != nil { t.Fatalf("failed to create exceptional hours group: %v", err) } // Calculate week start for the booking target date targetDate := time.Now().Add(96 * time.Hour) // DB convention: 0=Monday..6=Sunday; Go: 0=Sunday..6=Saturday. Convert. dbWeekday := (int(targetDate.Weekday()) + 6) % 7 daysToMonday := int(targetDate.Weekday()) if daysToMonday == 0 { daysToMonday = 7 } mondayDate := targetDate.AddDate(0, 0, -daysToMonday+1) weekStart := time.Date(mondayDate.Year(), mondayDate.Month(), mondayDate.Day(), 0, 0, 0, 0, mondayDate.Location()) // Apply group to this week _, err = db.DB.Exec(context.Background(), "INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2)", groupID, weekStart) if err != nil { t.Fatalf("failed to apply exceptional hours group: %v", err) } // Create closed exceptional hours for that weekday _, err = db.DB.Exec(context.Background(), `INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, $2, '08:00:00', '20:00:00', false)`, groupID, dbWeekday) if err != nil { t.Fatalf("failed to create closed exceptional hours: %v", err) } token := jwt.GenerateUserToken(userID) // Try to edit booking to closed day newStartTime := targetDate.Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 10, 0, 0, 0, newStartTime.Location()) req := EditBookingRequest{ StartTime: newStartTime, } handler := http.HandlerFunc(EditBookingHandler) w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for closed day edit, got %d. body: %s", w.Code, w.Body.String()) } if !bytes.Contains(w.Body.Bytes(), []byte("closed day")) { t.Errorf("expected error message about closed day, got: %s", w.Body.String()) } } // TestBookings_Edit_OpenDay_UserAllowed verifies that a user CAN edit a booking // to a day that is marked as open in exceptional hours. func TestBookings_Edit_OpenDay_UserAllowed(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } 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) // Create exceptional hours group with OPEN hours (is_open=true) var groupID string err = db.DB.QueryRow(context.Background(), "INSERT INTO exceptional_working_hours_groups (name, description) VALUES ($1, $2) RETURNING id", "Special Opening", "Extended hours").Scan(&groupID) if err != nil { t.Fatalf("failed to create exceptional hours group: %v", err) } // Calculate week start for the booking target date targetDate := time.Now().Add(96 * time.Hour) weekday := int(targetDate.Weekday()) daysToMonday := weekday if daysToMonday == 0 { daysToMonday = 7 } mondayDate := targetDate.AddDate(0, 0, -daysToMonday+1) weekStart := time.Date(mondayDate.Year(), mondayDate.Month(), mondayDate.Day(), 0, 0, 0, 0, mondayDate.Location()) // Apply group to this week _, err = db.DB.Exec(context.Background(), "INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2)", groupID, weekStart) if err != nil { t.Fatalf("failed to apply exceptional hours group: %v", err) } // Create OPEN exceptional hours for that weekday _, err = db.DB.Exec(context.Background(), `INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, $2, '08:00:00', '20:00:00', true)`, groupID, weekday) if err != nil { t.Fatalf("failed to create open exceptional hours: %v", err) } token := jwt.GenerateUserToken(userID) // Edit booking to open day (should succeed) newStartTime := targetDate.Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 10, 0, 0, 0, newStartTime.Location()) req := EditBookingRequest{ StartTime: newStartTime, } handler := http.HandlerFunc(EditBookingHandler) w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token) if w.Code != http.StatusOK { t.Errorf("expected status 200 for open day edit, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // Time Blocker Tests for User Bookings // ============================================================================= // TestBookings_Create_OverlappingBlocker_UserBlocked verifies that a regular user // CANNOT create a booking that overlaps with a time blocker. They receive 409 Conflict. func TestBookings_Create_OverlappingBlocker_UserBlocked(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) 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 to avoid 48h advance booking requirement _, 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) } 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', NULL) `, blockerTime) if err != nil { t.Fatalf("failed to create time blocker: %v", err) } token := jwt.GenerateUserToken(userID) // User tries to create booking overlapping the blocker req := CreateBookingRequest{ StartTime: blockerTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) // User should get 409 Conflict (not 201 Created) if w.Code != http.StatusConflict { t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String()) } // Verify error message mentions the blocker if !bytes.Contains(w.Body.Bytes(), []byte("blocked")) { t.Errorf("expected error message to mention 'blocked', got: %s", w.Body.String()) } // Verify NO booking was created var count int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM bookings WHERE user_id = $1", userID).Scan(&count) if err != nil { t.Fatalf("failed to query bookings: %v", err) } if count != 0 { t.Errorf("expected 0 bookings (user should be blocked), got %d", count) } } // TestBookings_Edit_OverlappingBlocker_UserBlocked verifies that a regular user // CANNOT edit a booking to a time that overlaps with a time blocker. func TestBookings_Edit_OverlappingBlocker_UserBlocked(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) 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 to avoid 48h advance booking requirement _, 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) } 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, 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', NULL) `, blockerTime) if err != nil { t.Fatalf("failed to create time blocker: %v", err) } token := jwt.GenerateUserToken(userID) // User tries to edit booking to overlap the blocker req := EditBookingRequest{ StartTime: blockerTime, } handler := http.HandlerFunc(EditBookingHandler) w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token) // User should get 409 Conflict if w.Code != http.StatusConflict { t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String()) } // Verify error message mentions the blocker if !bytes.Contains(w.Body.Bytes(), []byte("blocked")) { t.Errorf("expected error message to mention 'blocked', got: %s", w.Body.String()) } } // --- Guest Booking Tests --- func TestGuestUser_Create_Success(t *testing.T) { testutils.SetupTestDB(t) req := map[string]string{ "firstName": "Jane", "lastName": "Doe", "email": "jane.doe@test.com", "phone": "07123456789", } handler := http.HandlerFunc(user.CreateGuestUserHandler) w := makeRequest(handler, "POST", "/api/users/guest", req, "") if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var resp map[string]string json.Unmarshal(w.Body.Bytes(), &resp) if resp["role"] != "guest" { t.Errorf("expected role 'guest', got '%s'", resp["role"]) } if resp["id"] == "" { t.Error("expected non-empty user ID") } // Verify user exists in DB var role string err := db.DB.QueryRow(context.Background(), `SELECT account_role FROM users WHERE id = $1`, resp["id"]).Scan(&role) if err != nil { t.Fatalf("failed to query user: %v", err) } if role != "guest" { t.Errorf("expected role 'guest' in DB, got '%s'", role) } } func TestGuestUser_Create_DuplicateEmail(t *testing.T) { testutils.SetupTestDB(t) // First guest creation req := map[string]string{ "firstName": "John", "lastName": "Smith", "email": "john@test.com", "phone": "07123456789", } handler := http.HandlerFunc(user.CreateGuestUserHandler) w1 := makeRequest(handler, "POST", "/api/users/guest", req, "") if w1.Code != http.StatusCreated { t.Fatalf("first guest creation failed: %d", w1.Code) } var resp1 map[string]string json.Unmarshal(w1.Body.Bytes(), &resp1) // Second guest with same email — should create a NEW account req2 := map[string]string{ "firstName": "Jane", "lastName": "Smith", "email": "john@test.com", // same email "phone": "07123456780", } w2 := makeRequest(handler, "POST", "/api/users/guest", req2, "") if w2.Code != http.StatusCreated { t.Errorf("expected status 201 for second guest, got %d. body: %s", w2.Code, w2.Body.String()) } var resp2 map[string]string json.Unmarshal(w2.Body.Bytes(), &resp2) if resp1["id"] == resp2["id"] { t.Error("expected different user IDs for duplicate email, got same ID") } // Verify two separate guest accounts exist var count int db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM users WHERE email = $1 AND account_role = 'guest'`, "john@test.com").Scan(&count) if count != 2 { t.Errorf("expected 2 guest accounts with same email, got %d", count) } } func TestGuestUser_Create_RegisteredEmailCollision(t *testing.T) { testutils.SetupTestDB(t) // Create a registered user with a known email registeredEmail := "registered@example.com" db.DB.Exec(context.Background(), ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, account_role) VALUES ('Registered', 'User', $1, '07123456700', '1990-01-01', 'verified_email') `, registeredEmail) // Try to create a guest with same email as the registered user req := map[string]string{ "firstName": "Evil", "lastName": "Guest", "email": registeredEmail, "phone": "07123456799", } handler := http.HandlerFunc(user.CreateGuestUserHandler) w := makeRequest(handler, "POST", "/api/users/guest", req, "") if w.Code != http.StatusConflict { t.Errorf("expected status 409 for registered email collision, got %d. body: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "log in") { t.Errorf("expected error to mention 'log in', got: %s", w.Body.String()) } } func TestGuestBooking_Create_Success(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) // Create guest user guestReq := map[string]string{ "firstName": "Guest", "lastName": "User", "email": "guest@test.com", "phone": "07123456789", } handler := http.HandlerFunc(user.CreateGuestUserHandler) w := makeRequest(handler, "POST", "/api/users/guest", guestReq, "") if w.Code != http.StatusCreated { t.Fatalf("failed to create guest user: %d", w.Code) } var guestResp map[string]string json.Unmarshal(w.Body.Bytes(), &guestResp) guestID := guestResp["id"] // Create booking as guest serviceID, _ := fixtures.CreateTestService(db.DB) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, UserID: &guestID, } bookingHandler := http.HandlerFunc(CreateBookingHandler) w2 := makeRequest(bookingHandler, "POST", "/api/bookings", req, "") if w2.Code != http.StatusCreated { t.Errorf("expected status 201 for guest booking, got %d. body: %s", w2.Code, w2.Body.String()) } // Verify booking in DB var count int db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1`, guestID).Scan(&count) if count != 1 { t.Errorf("expected 1 booking for guest, got %d", count) } } func TestGuestBooking_Create_WithoutUserID(t *testing.T) { testutils.SetupTestDB(t) // Attempt booking without auth AND without user_id serviceID, _ := fixtures.CreateTestService(db.DB) futureTime := time.Now().Add(72 * time.Hour) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, "") if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401 for missing user_id, got %d. body: %s", w.Code, w.Body.String()) } } func TestGuestBooking_Create_NonGuestUserID(t *testing.T) { testutils.SetupTestDB(t) // Create a registered (non-guest) user userID, _ := fixtures.CreateTestUser(db.DB) // Try to book using their user_id but without auth token serviceID, _ := fixtures.CreateTestService(db.DB) futureTime := time.Now().Add(72 * time.Hour) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, UserID: &userID, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, "") if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for non-guest user_id, got %d. body: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "guest") { t.Errorf("expected error to mention 'guest', got: %s", w.Body.String()) } } func TestGuestBooking_SkipsDepositCheck(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) // Create guest user guestReq := map[string]string{ "firstName": "Guest", "lastName": "Skipper", "email": "skip@test.com", "phone": "07123456788", } handler := http.HandlerFunc(user.CreateGuestUserHandler) w := makeRequest(handler, "POST", "/api/users/guest", guestReq, "") var guestResp map[string]string json.Unmarshal(w.Body.Bytes(), &guestResp) guestID := guestResp["id"] // Give them an active booking with deposit required serviceID, _ := fixtures.CreateTestService(db.DB) pastTime := time.Now().Add(72 * time.Hour) db.DB.Exec(context.Background(), ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'confirmed'::booking_status, false) `, guestID, pastTime) // Guest should still be able to create a second booking (deposit check skipped) futureTime := time.Now().Add(96 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 14, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, UserID: &guestID, } bookingHandler := http.HandlerFunc(CreateBookingHandler) w2 := makeRequest(bookingHandler, "POST", "/api/bookings", req, "") if w2.Code != http.StatusCreated { t.Errorf("expected guest to bypass deposit check, got %d. body: %s", w2.Code, w2.Body.String()) } } func TestGuestBooking_BypassesAdvanceWindow(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) // Create guest user guestReq := map[string]string{ "firstName": "Guest", "lastName": "Walker", "email": "walker-guest@test.com", "phone": "07123456789", } handler := http.HandlerFunc(user.CreateGuestUserHandler) w := makeRequest(handler, "POST", "/api/users/guest", guestReq, "") var guestResp map[string]string json.Unmarshal(w.Body.Bytes(), &guestResp) guestID := guestResp["id"] // Set deposits_required on the guest user. if _, err := db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", guestID); err != nil { t.Fatalf("failed to set deposits_required on guest: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Guest should be able to book within the 36h advance window (bypasses deposit check). midday := time.Now().Truncate(24 * time.Hour).Add(29 * time.Hour).In(londonLocation) if midday.Hour() < 8 || midday.Hour() >= 20 { midday = nextWeekday(time.Now().Weekday(), londonLocation).Add(12 * time.Hour) } nearTime := midday.Truncate(time.Second) seedDefaultWorkingHours(t) req := CreateBookingRequest{ StartTime: nearTime, ServiceIDs: []string{serviceID}, UserID: &guestID, } bookingHandler := http.HandlerFunc(CreateBookingHandler) w2 := makeRequest(bookingHandler, "POST", "/api/bookings", req, "") if w2.Code != http.StatusCreated { t.Errorf("expected guest to bypass 36h advance window, got %d. body: %s", w2.Code, w2.Body.String()) } } // ============================================================================= // Booking Notification Creation Tests // ============================================================================= func TestCreateBooking_Notifications_NewBookingAlwaysCreated(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var bookingID string err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID) if err != nil { t.Fatalf("failed to get booking ID: %v", err) } var notifCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'new_booking'", bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query notifications: %v", err) } if notifCount != 1 { t.Errorf("expected 1 new_booking notification, got %d", notifCount) } } func TestCreateBooking_Notifications_PendingBookingWithNotes(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) notes := "Please do French tips with gold foil" req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, Notes: ¬es, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var bookingID string err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID) if err != nil { t.Fatalf("failed to get booking ID: %v", err) } var newBookingCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'new_booking'", bookingID).Scan(&newBookingCount) if err != nil { t.Fatalf("failed to query new_booking notifications: %v", err) } if newBookingCount != 1 { t.Errorf("expected 1 new_booking notification, got %d", newBookingCount) } var pendingCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'pending_booking'", bookingID).Scan(&pendingCount) if err != nil { t.Fatalf("failed to query pending_booking notifications: %v", err) } if pendingCount != 1 { t.Errorf("expected 1 pending_booking notification for booking with notes, got %d", pendingCount) } } func TestCreateBooking_Notifications_NoPendingBookingWithoutNotes(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var bookingID string err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID) if err != nil { t.Fatalf("failed to get booking ID: %v", err) } var pendingCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'pending_booking'", bookingID).Scan(&pendingCount) if err != nil { t.Fatalf("failed to query pending_booking notifications: %v", err) } if pendingCount != 0 { t.Errorf("expected 0 pending_booking notifications for booking without notes, got %d", pendingCount) } } // ============================================================================= // Closing Hours, Advance Check, and Active Booking Limit Tests // ============================================================================= func TestCreateBooking_ClosingHoursValidation(t *testing.T) { testutils.SetupTestDB(t) london, err := time.LoadLocation("Europe/London") if err != nil { t.Fatalf("failed to load London timezone: %v", err) } hours := []struct { weekday int startTime string endTime string isOpen bool }{ {0, "08:00", "17:00", true}, {1, "08:00", "20:00", true}, {2, "08:00", "20:00", true}, {3, "08:00", "20:00", true}, {4, "08:00", "20:00", true}, {5, "08:00", "20:00", true}, {6, "08:00", "20:00", true}, } seedCustomWorkingHours(t, hours) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) _, err = db.DB.Exec(context.Background(), "UPDATE services SET duration_minutes = 60 WHERE id = $1", serviceID) if err != nil { t.Fatalf("failed to set service duration: %v", err) } token := jwt.GenerateUserToken(userID) thursday := nextWeekday(time.Thursday, london) thursdayStart := time.Date(thursday.Year(), thursday.Month(), thursday.Day(), 17, 30, 0, 0, london) req1 := CreateBookingRequest{ StartTime: thursdayStart, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w1 := makeRequest(handler, "POST", "/api/bookings", req1, token) if w1.Code != http.StatusCreated { t.Errorf("Thursday 17:30+60min should succeed (ends 18:30 < 20:00), got %d. body: %s", w1.Code, w1.Body.String()) } monday := nextWeekday(time.Monday, london) mondayStart := time.Date(monday.Year(), monday.Month(), monday.Day(), 16, 30, 0, 0, london) req2 := CreateBookingRequest{ StartTime: mondayStart, ServiceIDs: []string{serviceID}, } w2 := makeRequest(handler, "POST", "/api/bookings", req2, token) if w2.Code != http.StatusBadRequest { t.Errorf("Monday 16:30+60min should fail (ends 17:30 > 17:00), got %d. body: %s", w2.Code, w2.Body.String()) } if !bytes.Contains(w2.Body.Bytes(), []byte("closing")) { t.Errorf("expected error about closing hours, got: %s", w2.Body.String()) } } func TestCreateBooking_OneHourAdvanceCheck(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(CreateBookingHandler) soonTime := time.Now().Add(30 * time.Minute).Truncate(time.Second) req1 := CreateBookingRequest{ StartTime: soonTime, ServiceIDs: []string{serviceID}, } w1 := makeRequest(handler, "POST", "/api/bookings", req1, token) if w1.Code != http.StatusBadRequest { t.Errorf("expected status 400 for 30-min advance booking, got %d. body: %s", w1.Code, w1.Body.String()) } if !bytes.Contains(w1.Body.Bytes(), []byte("at least 1 hour")) { t.Errorf("expected error about 1 hour advance, got: %s", w1.Body.String()) } aheadTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) aheadTime = time.Date(aheadTime.Year(), aheadTime.Month(), aheadTime.Day(), 10, 0, 0, 0, aheadTime.Location()) req2 := CreateBookingRequest{ StartTime: aheadTime, ServiceIDs: []string{serviceID}, } w2 := makeRequest(handler, "POST", "/api/bookings", req2, token) if w2.Code != http.StatusCreated { t.Errorf("expected status 201 for 2h+ advance booking, got %d. body: %s", w2.Code, w2.Body.String()) } } func TestCreateBooking_ActiveBookingLimit(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(CreateBookingHandler) firstTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) firstTime = time.Date(firstTime.Year(), firstTime.Month(), firstTime.Day(), 10, 0, 0, 0, firstTime.Location()) req1 := CreateBookingRequest{ StartTime: firstTime, ServiceIDs: []string{serviceID}, } w1 := makeRequest(handler, "POST", "/api/bookings", req1, token) if w1.Code != http.StatusCreated { t.Fatalf("expected first booking to succeed, got %d. body: %s", w1.Code, w1.Body.String()) } var booking1 Booking if err := parseResponseBody(w1, &booking1); err != nil { t.Fatalf("failed to parse first booking: %v", err) } secondTime := time.Now().Add(96 * time.Hour).Truncate(time.Second) secondTime = time.Date(secondTime.Year(), secondTime.Month(), secondTime.Day(), 14, 0, 0, 0, secondTime.Location()) req2 := CreateBookingRequest{ StartTime: secondTime, ServiceIDs: []string{serviceID}, } w2 := makeRequest(handler, "POST", "/api/bookings", req2, token) if w2.Code != http.StatusConflict { t.Errorf("expected status 409 for second booking with active booking, got %d. body: %s", w2.Code, w2.Body.String()) } if !bytes.Contains(w2.Body.Bytes(), []byte("active booking")) { t.Errorf("expected error about active booking, got: %s", w2.Body.String()) } _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", booking1.ID) if err != nil { t.Fatalf("failed to cancel first booking: %v", err) } w3 := makeRequest(handler, "POST", "/api/bookings", req2, token) if w3.Code != http.StatusCreated { t.Errorf("expected status 201 after cancelling active booking, got %d. body: %s", w3.Code, w3.Body.String()) } } func TestNextWeekdayHelper(t *testing.T) { london, err := time.LoadLocation("Europe/London") if err != nil { t.Fatalf("Europe/London not available: %v", err) } tests := []struct { name string weekday time.Weekday }{ {"Monday", time.Monday}, {"Tuesday", time.Tuesday}, {"Wednesday", time.Wednesday}, {"Thursday", time.Thursday}, {"Friday", time.Friday}, {"Saturday", time.Saturday}, {"Sunday", time.Sunday}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := nextWeekday(tt.weekday, london) if result.Weekday() != tt.weekday { t.Errorf("expected weekday %s, got %s", tt.weekday, result.Weekday()) } now := time.Now().In(london) today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, london) resultDay := time.Date(result.Year(), result.Month(), result.Day(), 0, 0, 0, 0, london) daysDiff := int(resultDay.Sub(today).Hours() / 24) if daysDiff < 2 { t.Errorf("expected result to be at least 2 calendar days ahead, got %d", daysDiff) } }) } } func TestCreateBooking_DepositSnapshot(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) 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) _, 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) } token := jwt.GenerateUserToken(userID) london, err := time.LoadLocation("Europe/London") if err != nil { t.Fatalf("Europe/London not available: %v", err) } bookingTime := nextWeekday(time.Monday, london).Add(10 * time.Hour) req := CreateBookingRequest{ StartTime: bookingTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Fatalf("failed to parse booking response: %v", err) } if !booking.DepositRequired { t.Error("expected deposit_required=true on first booking") } var depositRequired bool err = db.DB.QueryRow(context.Background(), "SELECT deposit_required FROM bookings WHERE id = $1", booking.ID).Scan(&depositRequired) if err != nil { t.Fatalf("failed to query booking: %v", err) } if !depositRequired { t.Error("expected deposit_required=true in DB for first booking") } _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to update deposits_required: %v", err) } err = db.DB.QueryRow(context.Background(), "SELECT deposit_required FROM bookings WHERE id = $1", booking.ID).Scan(&depositRequired) if err != nil { t.Fatalf("failed to query booking after user update: %v", err) } if !depositRequired { t.Error("expected booking deposit_required to remain true after user change") } bookingTime2 := nextWeekday(time.Tuesday, london).Add(10 * time.Hour) req2 := CreateBookingRequest{ StartTime: bookingTime2, ServiceIDs: []string{serviceID}, } w2 := makeRequest(handler, "POST", "/api/bookings", req2, token) if w2.Code != http.StatusCreated { t.Fatalf("expected status 201 for second booking, got %d. body: %s", w2.Code, w2.Body.String()) } var booking2 Booking if err := parseResponseBody(w2, &booking2); err != nil { t.Fatalf("failed to parse second booking response: %v", err) } if booking2.DepositRequired { t.Error("expected deposit_required=false on second booking after user deposits_required=0") } var depositRequired2 bool err = db.DB.QueryRow(context.Background(), "SELECT deposit_required FROM bookings WHERE id = $1", booking2.ID).Scan(&depositRequired2) if err != nil { t.Fatalf("failed to query second booking: %v", err) } if depositRequired2 { t.Error("expected second booking deposit_required=false in DB") } } func TestGetBooking_WithDiscounts(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) 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) token := jwt.GenerateUserToken(userID) // Create completed booking london, _ := time.LoadLocation("Europe/London") bookingTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour) bookingID := createCompletedBookingWithTime(t, userID, serviceID, bookingTime, 50.00) // Create a discount campaign and apply it var campaignID string err = db.DB.QueryRow(context.Background(), ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '1 day') RETURNING id `, "Test Campaign").Scan(&campaignID) if err != nil { t.Fatalf("failed to create campaign: %v", err) } _, err = db.DB.Exec(context.Background(), ` 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 GetBookingHandler handler := http.HandlerFunc(GetBookingHandler) w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var booking 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 != "Test Campaign" { t.Errorf("expected campaign name 'Test Campaign', got %v", d.CampaignName) } if d.DiscountAmount != 5.00 { t.Errorf("expected discount amount 5.00, got %.2f", d.DiscountAmount) } } func createCompletedBookingWithTime(t *testing.T, userID, serviceID string, startTime time.Time, price float64) string { t.Helper() var bookingID string err := db.DB.QueryRow(context.Background(), ` 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 = db.DB.Exec(context.Background(), ` 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 } func TestBookings_Confirm_WithCustomServiceOverrides(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) csID, err := fixtures.CreateTestCustomService(db.DB) if err != nil { t.Fatalf("failed to create custom service: %v", err) } defer fixtures.DeleteCustomService(db.DB, csID) london, _ := time.LoadLocation("Europe/London") startTime := nextWeekday(time.Monday, london).Add(10 * time.Hour) var bookingID string err = db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'pending') RETURNING id `, userID, startTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) _, 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) } _, err = db.DB.Exec(context.Background(), ` INSERT INTO booking_custom_services (booking_id, custom_service_id) VALUES ($1, $2) `, bookingID, csID) if err != nil { t.Fatalf("failed to link custom service: %v", err) } overridePrice := 100.0 overrideDuration := 60 svcOverridePrice := 55.0 svcOverrideDuration := 45 req := ConfirmBookingRequest{ ServiceOverrides: []ServiceOverride{ {ServiceID: serviceID, OverridePrice: &svcOverridePrice, OverrideDurationMinutes: &svcOverrideDuration}, }, CustomServiceOverrides: []ServiceOverride{ {ServiceID: csID, OverridePrice: &overridePrice, OverrideDurationMinutes: &overrideDuration}, }, } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(ConfirmBookingHandler) w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "") if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var booking 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 storedOverridePrice *float64 var storedOverrideDuration *int err = db.DB.QueryRow(context.Background(), ` SELECT override_price, override_duration_minutes FROM booking_custom_services WHERE booking_id = $1 AND custom_service_id = $2 `, bookingID, csID).Scan(&storedOverridePrice, &storedOverrideDuration) if err != nil { t.Fatalf("failed to query custom service override: %v", err) } if storedOverridePrice == nil || *storedOverridePrice != overridePrice { t.Errorf("expected override_price %.2f, got %v", overridePrice, storedOverridePrice) } if storedOverrideDuration == nil || *storedOverrideDuration != overrideDuration { t.Errorf("expected override_duration_minutes %d, got %v", overrideDuration, storedOverrideDuration) } var svcStoredPrice *float64 var svcStoredDuration *int err = db.DB.QueryRow(context.Background(), ` SELECT override_price, override_duration_minutes FROM booking_services WHERE booking_id = $1 AND service_id = $2 `, bookingID, serviceID).Scan(&svcStoredPrice, &svcStoredDuration) if err != nil { t.Fatalf("failed to query service override: %v", err) } if svcStoredPrice == nil || *svcStoredPrice != svcOverridePrice { t.Errorf("expected service override_price %.2f, got %v", svcOverridePrice, svcStoredPrice) } if svcStoredDuration == nil || *svcStoredDuration != svcOverrideDuration { t.Errorf("expected service override_duration_minutes %d, got %v", svcOverrideDuration, svcStoredDuration) } } func TestBookings_GetBooking_WithCustomServices(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } csID, err := fixtures.CreateTestCustomService(db.DB) if err != nil { t.Fatalf("failed to create custom service: %v", err) } defer fixtures.DeleteCustomService(db.DB, csID) london, _ := time.LoadLocation("Europe/London") startTime := nextWeekday(time.Tuesday, london).Add(10 * time.Hour) var bookingID string err = db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'pending') RETURNING id `, userID, startTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) _, err = db.DB.Exec(context.Background(), ` INSERT INTO booking_custom_services (booking_id, custom_service_id) VALUES ($1, $2) `, bookingID, csID) if err != nil { t.Fatalf("failed to link custom service: %v", err) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingHandler) w := makeAuthRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token, "") if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Fatalf("failed to parse booking: %v", err) } if len(booking.Services) == 0 { t.Fatal("expected at least one service in response") } found := false for _, s := range booking.Services { if s.ServiceID == csID { found = true if s.ServiceName == nil || *s.ServiceName != "Test Custom Service" { t.Errorf("expected service name 'Test Custom Service', got %v", s.ServiceName) } if s.Price == nil || *s.Price != 75.00 { t.Errorf("expected price 75.00, got %v", s.Price) } if s.DurationMinutes == nil || *s.DurationMinutes != 45 { t.Errorf("expected duration 45, got %v", s.DurationMinutes) } break } } if !found { t.Errorf("custom service %s not found in booking services", csID) } } func TestBookings_Confirm_CustomOverrideValidation(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } csID, err := fixtures.CreateTestCustomService(db.DB) if err != nil { t.Fatalf("failed to create custom service: %v", err) } defer fixtures.DeleteCustomService(db.DB, csID) london, _ := time.LoadLocation("Europe/London") startTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour) var bookingID string err = db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'pending') RETURNING id `, userID, startTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) _, err = db.DB.Exec(context.Background(), ` INSERT INTO booking_custom_services (booking_id, custom_service_id) VALUES ($1, $2) `, bookingID, csID) if err != nil { t.Fatalf("failed to link custom service: %v", err) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(ConfirmBookingHandler) t.Run("negative override price", func(t *testing.T) { negPrice := -10.0 validDur := 30 req := ConfirmBookingRequest{ CustomServiceOverrides: []ServiceOverride{ {ServiceID: csID, OverridePrice: &negPrice, OverrideDurationMinutes: &validDur}, }, } w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "") if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for negative price, got %d. body: %s", w.Code, w.Body.String()) } }) t.Run("non-positive override duration", func(t *testing.T) { validPrice := 50.0 zeroDur := 0 req := ConfirmBookingRequest{ CustomServiceOverrides: []ServiceOverride{ {ServiceID: csID, OverridePrice: &validPrice, OverrideDurationMinutes: &zeroDur}, }, } w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "") if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for zero duration, got %d. body: %s", w.Code, w.Body.String()) } }) t.Run("negative override duration", func(t *testing.T) { validPrice := 50.0 negDur := -10 req := ConfirmBookingRequest{ CustomServiceOverrides: []ServiceOverride{ {ServiceID: csID, OverridePrice: &validPrice, OverrideDurationMinutes: &negDur}, }, } w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "") if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for negative duration, got %d. body: %s", w.Code, w.Body.String()) } }) } func TestBookings_Confirm_CustomServiceNotInBooking(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) csID, err := fixtures.CreateTestCustomService(db.DB) if err != nil { t.Fatalf("failed to create custom service: %v", err) } defer fixtures.DeleteCustomService(db.DB, csID) london, _ := time.LoadLocation("Europe/London") startTime := nextWeekday(time.Thursday, london).Add(10 * time.Hour) var bookingID string err = db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'pending') RETURNING id `, userID, startTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) _, 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) } overridePrice := 80.0 overrideDuration := 50 req := ConfirmBookingRequest{ CustomServiceOverrides: []ServiceOverride{ {ServiceID: csID, OverridePrice: &overridePrice, OverrideDurationMinutes: &overrideDuration}, }, } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(ConfirmBookingHandler) w := makeAuthRequest(handler, "POST", "/api/bookings/"+bookingID+"/confirm", req, token, "") if w.Code != http.StatusBadRequest { t.Fatalf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } if !bytes.Contains(w.Body.Bytes(), []byte("do not belong to this booking")) { t.Errorf("expected error about custom service not belonging to booking, got: %s", w.Body.String()) } var status string err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking status: %v", err) } if status != "pending" { t.Errorf("expected booking to remain 'pending' after failed confirm, got '%s'", status) } } func TestBookings_Progress_WithCustomServices(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } csID, err := fixtures.CreateTestCustomService(db.DB) if err != nil { t.Fatalf("failed to create custom service: %v", err) } defer fixtures.DeleteCustomService(db.DB, csID) london, _ := time.LoadLocation("Europe/London") startTime := nextWeekday(time.Friday, london).Add(10 * time.Hour) var bookingID string err = db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'pending') RETURNING id `, userID, startTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) _, err = db.DB.Exec(context.Background(), ` INSERT INTO booking_custom_services (booking_id, custom_service_id) VALUES ($1, $2) `, bookingID, csID) if err != nil { t.Fatalf("failed to link custom service: %v", err) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(ProgressBookingHandler) req := ProgressBookingRequest{Status: "completed"} w := makeAuthRequest(handler, "PUT", "/api/bookings/"+bookingID+"/progress", req, token, "") if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Fatalf("failed to parse response: %v", err) } if booking.Status != "completed" { t.Errorf("expected booking status 'completed', got '%s'", booking.Status) } var status string err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking status: %v", err) } if status != "completed" { t.Errorf("expected DB status 'completed', got '%s'", status) } } // ============================================================================= // Refund Processing Tests (DeleteBookingHandler with payments) // ============================================================================= // TestDeleteBooking_WithPayments_ProcessesRefund verifies that when a user // cancels a booking that has completed payments, the refund is processed // BEFORE the cancellation, and refund records are created in the DB. func TestDeleteBooking_WithPayments_ProcessesRefund(t *testing.T) { testutils.SetupTestDB(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Use a booking far enough in the future that >72h notice applies (full refund) 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 status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } paymentID := bookingID[:8] + "pmt" _, err = db.DB.Exec(context.Background(), ` INSERT INTO payments (id, booking_id, payment_type, payment_method, amount, status, created_at) VALUES ($1, $2, 'full', 'in_person_card', 50.00, 'completed', NOW()) `, paymentID, bookingID) if err != nil { t.Fatalf("failed to add payment: %v", err) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(DeleteBookingHandler) reqBody := map[string]string{"reason": "client_cancelled"} w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, reqBody, token) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify booking was cancelled var status string err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking status: %v", err) } if status != "client_cancelled" { t.Errorf("expected status 'client_cancelled', got '%s'", status) } // Verify refund records were created var refundCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount) if err != nil { t.Fatalf("failed to query refunds: %v", err) } if refundCount == 0 { t.Error("expected at least 1 refund record to be created") } // Verify refund_calculation in response body var respBody map[string]interface{} if err := parseResponseBody(w, &respBody); err != nil { t.Fatalf("failed to parse response: %v", err) } if _, ok := respBody["refund_calculation"]; !ok { t.Error("expected refund_calculation in response body") } // Verify admin notification was created var notifCount int err = db.DB.QueryRow(context.Background(), `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, got %d", notifCount) } } // TestDeleteBooking_NoPayments_HardDelete verifies that when a booking has no // payments, cancelling performs a hard delete (removes the row entirely). func TestDeleteBooking_NoPayments_HardDelete(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Use next available working hour for booking creation soonTime := nextWorkingHour() bookingReq := CreateBookingRequest{ StartTime: soonTime, ServiceIDs: []string{serviceID}, } createHandler := http.HandlerFunc(CreateBookingHandler) token := jwt.GenerateUserToken(userID) w := makeRequest(createHandler, "POST", "/api/bookings", bookingReq, token) var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Fatalf("failed to parse booking: %v", err) } // Cancel the pending booking (no payments → hard delete) delHandler := http.HandlerFunc(DeleteBookingHandler) w = makeRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, nil, token) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify booking row was hard-deleted var rowCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM bookings WHERE id = $1", booking.ID).Scan(&rowCount) if err != nil { t.Fatalf("failed to query booking: %v", err) } if rowCount != 0 { t.Error("expected booking row to be hard-deleted") } } // ============================================================================= // Auto-Approval Tests (RequestEditHandler) // ============================================================================= // TestRequestEditHandler_AutoApproves_NoPayments_FarFuture verifies that when a // user requests an edit on a booking with no payments and >48h until the // appointment, the edit request is auto-approved without admin intervention. func TestRequestEditHandler_AutoApproves_NoPayments_FarFuture(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // >48h from now + no payments → triggers auto-approval 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 status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } token := jwt.GenerateUserToken(userID) // Request a notes-only edit handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "notes": "Please add gel polish", } w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) // Auto-approval returns 200 OK with auto_approved flag if w.Code != http.StatusOK { t.Fatalf("expected status 200 (auto-approved), got %d. body: %s", w.Code, w.Body.String()) } // Verify auto_approved flag in response var respBody map[string]interface{} if err := parseResponseBody(w, &respBody); err != nil { t.Fatalf("failed to parse response: %v", err) } autoApproved, ok := respBody["auto_approved"].(bool) if !ok || !autoApproved { t.Error("expected auto_approved=true in response") } // Verify no edit request row exists (was auto-approved and deleted) var erCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) } if erCount != 0 { t.Errorf("expected 0 edit request rows (auto-approved), got %d", erCount) } // Verify no 'edit_requested' notification was created (acknowledged on auto-approve) var notifCount int err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL`, bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query notifications: %v", err) } if notifCount != 0 { t.Errorf("expected 0 unacknowledged edit_requested notifications (auto-approved), got %d", notifCount) } // Verify notes were applied to the booking var dbNotes string err = db.DB.QueryRow(context.Background(), "SELECT COALESCE(notes, '') FROM bookings WHERE id = $1", bookingID).Scan(&dbNotes) if err != nil { t.Fatalf("failed to query booking notes: %v", err) } if dbNotes != "Please add gel polish" { t.Errorf("expected booking notes to be updated to 'Please add gel polish', got '%s'", dbNotes) } } // TestRequestEditHandler_AutoApproves_WithTimeChange verifies that auto-approval // correctly updates the start_time when the edit request includes a time change. func TestRequestEditHandler_AutoApproves_WithTimeChange(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // >48h from now, no payments → auto-approval 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 status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } token := jwt.GenerateUserToken(userID) // Request a time change to 48h from now newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 10, 0, 0, 0, newStartTime.Location()) handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) if w.Code != http.StatusOK { t.Fatalf("expected status 200 (auto-approved), got %d. body: %s", w.Code, w.Body.String()) } // Verify booking start_time was updated 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.Truncate(time.Second).Equal(newStartTime) { t.Errorf("expected start_time %v, got %v", newStartTime, updatedStartTime) } // Verify no time_blocker reservation remains (cleaned up on auto-approve) var blockerCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCount) if err != nil { t.Fatalf("failed to query time_blockers: %v", err) } if blockerCount != 0 { t.Errorf("expected 0 time_blocker reservations (cleaned up on auto-approve), got %d", blockerCount) } } // TestRequestEditHandler_NoAutoApproval_WithPayments verifies that when a // booking has completed payments, the edit request stays pending for admin // approval regardless of how far in the future the booking is. func TestRequestEditHandler_NoAutoApproval_WithPayments(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } 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 status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Add a payment to the booking _, err = db.DB.Exec(context.Background(), ` INSERT INTO payments (id, booking_id, payment_type, payment_method, amount, status, created_at) VALUES ($1, $2, 'deposit', 'in_person_card', 25.00, 'completed', NOW()) `, bookingID[:8]+"pay", bookingID) if err != nil { t.Fatalf("failed to add payment: %v", err) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "notes": "Please change the time", } w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) // Should NOT auto-approve (has payments), so expect 201 Created if w.Code != http.StatusCreated { t.Fatalf("expected status 201 (pending), got %d. body: %s", w.Code, w.Body.String()) } // Verify edit request row exists var erCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) } if erCount != 1 { t.Errorf("expected 1 edit request row (pending admin approval), got %d", erCount) } } // TestRequestEditHandler_NoAutoApproval_Within48h verifies that when a booking // has no payments but is within 48h of the appointment, the edit request stays // pending for admin approval. func TestRequestEditHandler_NoAutoApproval_Within48h(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Use a start time within 24h so auto-approval (>=48h) does not fire // Also >24h so the time-change block (>=24h for no-payment bookings) does not fire bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) 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 status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "notes": "Please change the service", } w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) if w.Code != http.StatusCreated { t.Fatalf("expected status 201 (pending), got %d. body: %s", w.Code, w.Body.String()) } var erCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) } if erCount != 1 { t.Errorf("expected 1 edit request row (pending admin approval), got %d", erCount) } } func TestCreateEditRequest_DiscountsBlockAutoApprove(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) 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 status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } _, err = db.DB.Exec(context.Background(), ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'loyalty', 10, 5000, 500) `, bookingID, userID) if err != nil { t.Fatalf("failed to add discount: %v", err) } token := jwt.GenerateUserToken(userID) newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } // Edit request should still exist (not auto-approved) since discounts block it. var erCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit request: %v", err) } if erCount != 1 { t.Errorf("expected 1 edit request row (auto-approve blocked by discounts), got %d", erCount) } } func TestCreateEditRequest_NoDiscountsStillAutoApproves(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, bookingTime) 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 status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } token := jwt.GenerateUserToken(userID) newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) if w.Code != http.StatusOK { t.Fatalf("expected status 200 (auto-approved), got %d. body: %s", w.Code, w.Body.String()) } // With no discounts, auto-approve deletes the edit request row. var erCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit request: %v", err) } if erCount != 0 { t.Errorf("expected 0 edit request rows (auto-approved and cleaned up), got %d", erCount) } } // ============================================================================= // Pagination — nextCursor is only set when there are more pages // ============================================================================= func TestGetAllUserBookings_CursorNotSetOnLastPage(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Disable deposit requirement for this user so bookings auto-confirm. _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Create 5 bookings with staggered created_at values so cursor ordering // is deterministic. Direct UPDATE ensures each booking has a unique // timestamp — otherwise all 5 batch-inserted rows share the same NOW(). now := time.Now().In(time.UTC).Truncate(time.Second) bookingIDs := make([]string, 5) for i := 0; i < 5; i++ { start := now.Add(time.Duration(48+i*24) * time.Hour) id, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, start) if err != nil { t.Fatalf("failed to create booking %d: %v", i, err) } bookingIDs[i] = id defer fixtures.DeleteBooking(db.DB, id) // Stagger created_at so cursor (created_at DESC, id DESC) is predictable. _, _ = db.DB.Exec(context.Background(), "UPDATE bookings SET created_at = $1 WHERE id = $2", now.Add(time.Duration(i)*time.Second), id) } handler := http.HandlerFunc(GetAllUserBookingsHandler) // Page 1: fetch 2 items, expect nextCursor w1 := makeRequest(handler, "GET", "/api/bookings?per_page=2", nil, token) if w1.Code != http.StatusOK { t.Fatalf("page 1 expected 200, got %d: %s", w1.Code, w1.Body.String()) } var page1 BookingListResponse if err := json.Unmarshal(w1.Body.Bytes(), &page1); err != nil { t.Fatalf("failed to parse page 1: %v", err) } if len(page1.Bookings) != 2 { t.Errorf("page 1 expected 2 bookings, got %d", len(page1.Bookings)) } if page1.NextCursor == nil { t.Error("page 1 expected nextCursor (2 more pages remain), got nil") } // Page 2: use cursor from page 1 (URL-encode to protect '+' in timezone offset). page2URL := "/api/bookings?per_page=2&cursor=" + url.QueryEscape(*page1.NextCursor) w2 := makeRequest(handler, "GET", page2URL, nil, token) if w2.Code != http.StatusOK { t.Fatalf("page 2 expected 200, got %d: %s", w2.Code, w2.Body.String()) } var page2 BookingListResponse if err := json.Unmarshal(w2.Body.Bytes(), &page2); err != nil { t.Fatalf("failed to parse page 2: %v", err) } if len(page2.Bookings) != 2 { t.Errorf("page 2 expected 2 bookings, got %d", len(page2.Bookings)) } if page2.NextCursor == nil { t.Error("page 2 expected nextCursor (1 more page remains), got nil") } // Page 3: use cursor from page 2 — this is the last page page3URL := "/api/bookings?per_page=2&cursor=" + url.QueryEscape(*page2.NextCursor) w3 := makeRequest(handler, "GET", page3URL, nil, token) if w3.Code != http.StatusOK { t.Fatalf("page 3 expected 200, got %d: %s", w3.Code, w3.Body.String()) } var page3 BookingListResponse if err := json.Unmarshal(w3.Body.Bytes(), &page3); err != nil { t.Fatalf("failed to parse page 3: %v", err) } if len(page3.Bookings) != 1 { t.Errorf("last page expected 1 booking, got %d", len(page3.Bookings)) } if page3.NextCursor != nil { t.Errorf("last page expected nil nextCursor, got %q", *page3.NextCursor) } // Verify no duplicate bookings across pages seen := make(map[string]bool) for _, b := range page1.Bookings { seen[b.ID] = true } for _, b := range page2.Bookings { if seen[b.ID] { t.Errorf("duplicate booking %s on page 2", b.ID) } seen[b.ID] = true } for _, b := range page3.Bookings { if seen[b.ID] { t.Errorf("duplicate booking %s on page 3", b.ID) } seen[b.ID] = true } if len(seen) != 5 { t.Errorf("expected 5 unique bookings across all pages, got %d", len(seen)) } } // ============================================================================= // Count query accuracy — total field in paginated responses // ============================================================================= func TestGetAllUserBookings_TotalCountMatches(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Disable deposit requirement so bookings auto-confirm. _, 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) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Create 3 bookings with staggered created_at for deterministic cursor ordering. now := time.Now().In(time.UTC).Truncate(time.Second) for i := 0; i < 3; i++ { start := now.Add(time.Duration(72+i*24) * time.Hour) id, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, start) if err != nil { t.Fatalf("failed to create booking %d: %v", i, err) } defer fixtures.DeleteBooking(db.DB, id) // Stagger created_at so cursor-ordering is deterministic. _, _ = db.DB.Exec(context.Background(), "UPDATE bookings SET created_at = $1 WHERE id = $2", now.Add(time.Duration(i)*time.Second), id) } handler := http.HandlerFunc(GetAllUserBookingsHandler) // Fetch all bookings with per_page=10 (fits all). w := makeRequest(handler, "GET", "/api/bookings?per_page=10", nil, token) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp BookingListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp.Total != 3 { t.Errorf("expected total 3, got %d", resp.Total) } if len(resp.Bookings) != 3 { t.Errorf("expected 3 bookings, got %d", len(resp.Bookings)) } if resp.NextCursor != nil { t.Errorf("expected no nextCursor (all items fit), got %q", *resp.NextCursor) } // Fetch with per_page=2 — should still report total=3. w2 := makeRequest(handler, "GET", "/api/bookings?per_page=2", nil, token) if w2.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w2.Code, w2.Body.String()) } var resp2 BookingListResponse if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp2.Total != 3 { t.Errorf("expected total 3 on page 1, got %d", resp2.Total) } if len(resp2.Bookings) != 2 { t.Errorf("expected 2 bookings on page 1, got %d", len(resp2.Bookings)) } if resp2.NextCursor == nil { t.Fatal("expected nextCursor on page 1 (more items remain)") } // Page 2: still total=3 but only 1 booking. w3 := makeRequest(handler, "GET", "/api/bookings?per_page=2&cursor="+url.QueryEscape(*resp2.NextCursor), nil, token) if w3.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w3.Code, w3.Body.String()) } var resp3 BookingListResponse if err := json.Unmarshal(w3.Body.Bytes(), &resp3); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp3.Total != 3 { t.Errorf("expected total 3 on page 2, got %d", resp3.Total) } if len(resp3.Bookings) != 1 { t.Errorf("expected 1 booking on page 2, got %d", len(resp3.Bookings)) } if resp3.NextCursor != nil { t.Errorf("expected no nextCursor on last page, got %q", *resp3.NextCursor) } }