//go:build test // +build test 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" "net/http" "net/http/httptest" "strings" "testing" "time" "crussell/db" "crussell/mw" "crussell/testutils/fixtures" "crussell/testutils/jwt" "crussell/testutils/testdb" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/lib/pq" ) // setupTestDB replaces the global db.DB with a test pool and returns a cleanup function func setupTestDB(t *testing.T) func() { t.Helper() pool := testdb.Pool(t) testdb.Migrate(t, pool) testdb.TruncateTables(t, pool) // Clear data between tests // Replace global db.DB with test pool originalDB := db.DB db.DB = pool // Initialize JWT for tests jwt.Init() return func() { db.DB = originalDB pool.Close() } } // 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) } } } // 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) { cleanup := setupTestDB(t) defer cleanup() // 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) // 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) // Generate token for user token := jwt.GenerateUserToken(userID) // Create booking request - use future time to avoid 48h deposit requirement // 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) { cleanup := setupTestDB(t) defer cleanup() // 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 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) } 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) { cleanup := setupTestDB(t) defer cleanup() // 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) // 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 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) { cleanup := setupTestDB(t) defer cleanup() // 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) // 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 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) { cleanup := setupTestDB(t) defer cleanup() // 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) // 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 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) { cleanup := setupTestDB(t) defer cleanup() // 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 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) } 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) { cleanup := setupTestDB(t) defer cleanup() // 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) { cleanup := setupTestDB(t) defer cleanup() // 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) // 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 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) { cleanup := setupTestDB(t) defer cleanup() // 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 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) } 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) { cleanup := setupTestDB(t) defer cleanup() // 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) // 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 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) { cleanup := setupTestDB(t) defer cleanup() // 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) // 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 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) { cleanup := setupTestDB(t) defer cleanup() // 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 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) } 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) { cleanup := setupTestDB(t) defer cleanup() // 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) // 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 (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_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) { cleanup := setupTestDB(t) defer cleanup() // 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) // 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 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) { cleanup := setupTestDB(t) defer cleanup() // 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 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) } 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) } } // ============================================================================= // 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) { cleanup := setupTestDB(t) defer cleanup() // 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) // 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) 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, "") // GetCalendar returns 404 when no auth because handler checks booking first expectedStatus := http.StatusUnauthorized if tt.path == "/api/bookings/"+bookingID+"/calendar" { expectedStatus = http.StatusNotFound } if w.Code != expectedStatus { t.Errorf("expected status %d, got %d", expectedStatus, w.Code) } }) } } // ============================================================================= // 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) { cleanup := setupTestDB(t) defer cleanup() // 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) // 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) } 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) { cleanup := setupTestDB(t) defer cleanup() // 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 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) } 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) { cleanup := setupTestDB(t) defer cleanup() 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) 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_Within48HourDepositRequired tests that when a booking // is made within 48 hours and the user has deposits_required > 0, the booking // should have deposit_required=true. With deposits_required=0, no deposit needed. func TestBookings_Create_Within48HourDepositRequired(t *testing.T) { cleanup := setupTestDB(t) defer cleanup() // 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) } 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) // Use 10:00 to ensure service fits within working hours (08:00-20:00) 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, 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_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) { cleanup := setupTestDB(t) defer cleanup() // 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)) } } // 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) { cleanup := setupTestDB(t) defer cleanup() 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) { cleanup := setupTestDB(t) defer cleanup() 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) { cleanup := setupTestDB(t) defer cleanup() 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) { cleanup := setupTestDB(t) defer cleanup() 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) { cleanup := setupTestDB(t) defer cleanup() 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) { cleanup := setupTestDB(t) defer cleanup() 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) } 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 && w.Code != http.StatusOK { t.Errorf("expected status 200/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_request' 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) } } // TestDeleteEditRequest tests that user deleting their edit request deletes the admin notification func TestDeleteEditRequest(t *testing.T) { cleanup := setupTestDB(t) defer cleanup() 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 (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_request', $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_request'`, 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) { cleanup := setupTestDB(t) defer cleanup() 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 (need pq.Array for PostgreSQL array) 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, pq.Array(&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_request', $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_request'`, 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_request'`, 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) { cleanup := setupTestDB(t) defer cleanup() 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_request', $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_request'`, 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_request'`, 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") } } // TestBookings_RequestEdit_BookingNotFound tests that requesting an edit for a non-existent booking returns 404 func TestBookings_RequestEdit_BookingNotFound(t *testing.T) { cleanup := setupTestDB(t) defer cleanup() // 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) { cleanup := setupTestDB(t) defer cleanup() 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) } // 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_request', $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) { cleanup := setupTestDB(t) defer cleanup() 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) { cleanup := setupTestDB(t) defer cleanup() 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) futureTime := time.Now().Add(12 * 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 within notice period, got %d. body: %s", w.Code, w.Body.String()) } if !bytes.Contains(w.Body.Bytes(), []byte("wait")) { t.Errorf("expected error message about waiting, 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) { cleanup := setupTestDB(t) defer cleanup() 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) { cleanup := setupTestDB(t) defer cleanup() 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_Within48Hours verifies that a user with deposits_required > 0 // cannot book within 48 hours notice. They must complete more appointments to remove this restriction. func TestBookings_Create_DepositRequired_Within48Hours(t *testing.T) { cleanup := setupTestDB(t) defer cleanup() 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) // Try to book within 48 hours (should be blocked) 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.StatusBadRequest { t.Errorf("expected status 400 for within 48h booking with deposit requirement, got %d. body: %s", w.Code, w.Body.String()) } if !bytes.Contains(w.Body.Bytes(), []byte("48 hours")) { t.Errorf("expected error message about 48 hours, 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) { cleanup := setupTestDB(t) defer cleanup() 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) { cleanup := setupTestDB(t) defer cleanup() 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) { cleanup := setupTestDB(t) defer cleanup() 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) { cleanup := setupTestDB(t) defer cleanup() 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) { cleanup := setupTestDB(t) defer cleanup() 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") } } // 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) { cleanup := setupTestDB(t) defer cleanup() 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) weekday := int(targetDate.Weekday()) daysToMonday := weekday if daysToMonday == 0 { daysToMonday = 7 } weekStart := targetDate.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour) // 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, weekday) 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) { cleanup := setupTestDB(t) defer cleanup() 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 } weekStart := targetDate.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour) // 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()) } }