//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" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "crussell/db" "crussell/handlers/user" "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" ) func resetTestData(t *testing.T) { t.Helper() testdb.TruncateTables(t, db.DB) } // 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) { resetTestData(t) // Seed working hours for booking tests seedDefaultWorkingHours(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now (was 3) _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Generate token for user token := jwt.GenerateUserToken(userID) // Create booking request - use future time (1h+ advance is enforced for USERS only) // Use 10:00 to ensure service fits within working hours (08:00-20:00) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } // Verify booking was created in DB var count int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM bookings WHERE user_id = $1", userID).Scan(&count) if err != nil { t.Errorf("failed to query bookings: %v", err) } if count != 1 { t.Errorf("expected 1 booking, got %d", count) } // Verify booking_services was created err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM booking_services WHERE booking_id IN (SELECT id FROM bookings WHERE user_id = $1)", userID).Scan(&count) if err != nil { t.Errorf("failed to query booking_services: %v", err) } if count != 1 { t.Errorf("expected 1 booking_service, got %d", count) } } // TestBookings_Create_InvalidInput verifies that booking creation fails // with HTTP 400 when required fields are missing: start time or service IDs. func TestBookings_Create_InvalidInput(t *testing.T) { resetTestData(t) // Create test user userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } token := jwt.GenerateUserToken(userID) tests := []struct { name string req CreateBookingRequest }{ { name: "missing start time", req: CreateBookingRequest{ ServiceIDs: []string{"some-service-id"}, }, }, { name: "missing service IDs", req: CreateBookingRequest{ StartTime: time.Now().Add(72 * time.Hour), }, }, { name: "empty service IDs", req: CreateBookingRequest{ StartTime: time.Now().Add(72 * time.Hour), ServiceIDs: []string{}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", tt.req, token) // Both missing start time and missing/empty service IDs should return 400 if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d", w.Code) } }) } } // ============================================================================= // List Bookings Tests // ============================================================================= // TestBookings_List tests that a user can retrieve their list of bookings. // The test verifies the response includes the correct total count and that // bookings are properly returned. func TestBookings_List(t *testing.T) { resetTestData(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create a booking bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetAllUserBookingsHandler) w := makeRequest(handler, "GET", "/api/bookings", nil, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp BookingListResponse if err := parseResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if len(resp.Bookings) != 1 { t.Errorf("expected 1 booking, got %d", len(resp.Bookings)) } if resp.Total != 1 { t.Errorf("expected total 1, got %d", resp.Total) } } // TestBookings_List_FilterByStatus tests that booking list can be filtered // by status (e.g., pending, completed). It verifies that non-matching statuses // return empty results. func TestBookings_List_FilterByStatus(t *testing.T) { resetTestData(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create a pending booking bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) token := jwt.GenerateUserToken(userID) // Test filtering by status handler := http.HandlerFunc(GetAllUserBookingsHandler) w := makeRequest(handler, "GET", "/api/bookings?status=pending", nil, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) } var resp BookingListResponse if err := parseResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if len(resp.Bookings) != 1 { t.Errorf("expected 1 booking, got %d", len(resp.Bookings)) } // Test filtering by non-matching status w = makeRequest(handler, "GET", "/api/bookings?status=completed", nil, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) } if err := parseResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if len(resp.Bookings) != 0 { t.Errorf("expected 0 bookings for completed status, got %d", len(resp.Bookings)) } } // ============================================================================= // Get Single Booking Tests // ============================================================================= // TestBookings_Get tests that a user can retrieve a single booking by its ID. // The test verifies the booking details including services are returned. func TestBookings_Get(t *testing.T) { resetTestData(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create a booking bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingHandler) w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Errorf("failed to parse response: %v", err) } if booking.ID != bookingID { t.Errorf("expected booking ID %s, got %s", bookingID, booking.ID) } if len(booking.Services) != 1 { t.Errorf("expected 1 service, got %d", len(booking.Services)) } } // TestBookings_Get_NotFound verifies that requesting a non-existent booking // returns HTTP 404 Not Found. func TestBookings_Get_NotFound(t *testing.T) { resetTestData(t) // Create test user userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingHandler) w := makeRequest(handler, "GET", "/api/bookings/nonexistent-id", nil, token) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) } } // TestBookings_Get_AccessDenied tests that a user cannot access another user's // booking. The test creates two users, one creates a booking, and the other // attempts to access it - expecting HTTP 404 (not found/access denied). func TestBookings_Get_AccessDenied(t *testing.T) { resetTestData(t) // Create two test users userID1, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user 1: %v", err) } defer fixtures.DeleteUser(db.DB, userID1) userID2, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user 2: %v", err) } defer fixtures.DeleteUser(db.DB, userID2) serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create a booking for user1 bookingID, err := fixtures.CreateTestBooking(db.DB, userID1, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) // Try to access with user2's token token := jwt.GenerateUserToken(userID2) handler := http.HandlerFunc(GetBookingHandler) w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token) // Should return not found (or access denied) since user2 doesn't own the booking if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) } } // ============================================================================= // Get Calendar Tests // ============================================================================= // TestBookings_GetCalendar tests that a user can export their booking // as an ICS calendar file. It verifies the response has the correct // text/calendar Content-Type and contains ICS-formatted data. func TestBookings_GetCalendar(t *testing.T) { resetTestData(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create a booking bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingCalendarHandler) w := makeRequest(handler, "GET", "/api/bookings/"+bookingID+"/calendar", nil, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Check that Content-Type is text/calendar contentType := w.Header().Get("Content-Type") if contentType != "text/calendar; charset=utf-8" { t.Errorf("expected Content-Type text/calendar, got %s", contentType) } // Check that response contains ICS data body := w.Body.String() if len(body) == 0 { t.Error("expected non-empty ICS response") } // Basic ICS validation if !bytes.Contains([]byte(body), []byte("BEGIN:VCALENDAR")) { t.Error("expected ICS to contain BEGIN:VCALENDAR") } if !bytes.Contains([]byte(body), []byte("BEGIN:VEVENT")) { t.Error("expected ICS to contain BEGIN:VEVENT") } } // TestBookings_GetCalendar_NotFound verifies that attempting to export // a non-existent booking to calendar returns HTTP 404. func TestBookings_GetCalendar_NotFound(t *testing.T) { resetTestData(t) // Create test user userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingCalendarHandler) w := makeRequest(handler, "GET", "/api/bookings/nonexistent-id/calendar", nil, token) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) } } // ============================================================================= // Edit Booking Tests // ============================================================================= // TestBookings_Edit tests that a user can modify the start time of // their existing booking. The test verifies the time is updated in the DB. func TestBookings_Edit(t *testing.T) { resetTestData(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create a booking bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) token := jwt.GenerateUserToken(userID) // Update to a future time newStartTime := time.Now().Add(96 * time.Hour).Truncate(time.Second) req := EditBookingRequest{ StartTime: newStartTime, } handler := http.HandlerFunc(EditBookingHandler) w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Errorf("failed to parse response: %v", err) } // Verify the start time was updated in DB var dbStartTime time.Time err = db.DB.QueryRow(context.Background(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&dbStartTime) if err != nil { t.Errorf("failed to query booking: %v", err) } // Times should match (truncated to seconds) if !dbStartTime.Truncate(time.Second).Equal(newStartTime) { t.Errorf("expected start_time %v, got %v", newStartTime, dbStartTime) } } // TestBookings_Edit_InvalidInput verifies that editing fails with HTTP 400 // when the start time is missing or is in the past. func TestBookings_Edit_InvalidInput(t *testing.T) { resetTestData(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create a booking bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) token := jwt.GenerateUserToken(userID) tests := []struct { name string req EditBookingRequest }{ { name: "missing start time", req: EditBookingRequest{}, }, { name: "past start time", req: EditBookingRequest{ StartTime: time.Now().Add(-1 * time.Hour), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler := http.HandlerFunc(EditBookingHandler) w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, tt.req, token) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d", w.Code) } }) } } // TestBookings_Edit_NotFound verifies that editing a non-existent // booking returns HTTP 404. func TestBookings_Edit_NotFound(t *testing.T) { resetTestData(t) // Create test user userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } token := jwt.GenerateUserToken(userID) req := EditBookingRequest{ StartTime: time.Now().Add(96 * time.Hour), } handler := http.HandlerFunc(EditBookingHandler) w := makeRequest(handler, "PUT", "/api/bookings/nonexistent-id", req, token) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) } } // ============================================================================= // Delete Booking Tests // ============================================================================= // TestBookings_Delete tests that a user can delete (cancel) their booking. // For bookings without payments, it performs a hard delete. The test verifies // the booking is removed from the database. func TestBookings_Delete(t *testing.T) { resetTestData(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create a booking (without payments) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } token := jwt.GenerateUserToken(userID) // Delete the booking handler := http.HandlerFunc(DeleteBookingHandler) w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify booking was deleted from DB var count int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM bookings WHERE id = $1", bookingID).Scan(&count) if err != nil { t.Errorf("failed to query bookings: %v", err) } if count != 0 { t.Errorf("expected booking to be deleted, but found %d", count) } } // TestBookings_Delete_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) { resetTestData(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create a booking bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) // Add a payment to the booking (so it requires a reason) _, err = db.DB.Exec(context.Background(), "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", bookingID) if err != nil { t.Fatalf("failed to create payment: %v", err) } token := jwt.GenerateUserToken(userID) // Try to delete without reason - should fail handler := http.HandlerFunc(DeleteBookingHandler) w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for missing reason, got %d", w.Code) } // Delete with reason req := map[string]string{"reason": "client_cancelled"} w = makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, req, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify booking status was updated (not hard deleted) var status string err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Errorf("failed to query booking: %v", err) } if status != "client_cancelled" { t.Errorf("expected status client_cancelled, got %s", status) } } // TestBookings_Delete_NotFound verifies that deleting a non-existent // booking returns HTTP 404. func TestBookings_Delete_NotFound(t *testing.T) { resetTestData(t) // Create test user userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(DeleteBookingHandler) w := makeRequest(handler, "DELETE", "/api/bookings/nonexistent-id", nil, token) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) } } // TestBookings_Delete_NoShow24hThreshold tests the 24-hour no-show rule: // - Cancellation < 24 hours before appointment: treated as no-show (deposits = 3) // - Cancellation >= 24 hours before appointment: treated as late_cancellation func TestBookings_Delete_NoShow24hThreshold(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Create booking 23 hours from now (will be < 24h when deleted) soonTime := time.Now().Add(23 * time.Hour).Truncate(time.Second) soonTime = time.Date(soonTime.Year(), soonTime.Month(), soonTime.Day(), 10, 0, 0, 0, soonTime.Location()) bookingReq := CreateBookingRequest{ StartTime: soonTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token) var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Fatalf("failed to parse booking response: %v", err) } // Add payment so deletion works _, _ = db.DB.Exec(context.Background(), "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking.ID) // Delete within 24 hours (no forgiveness) - should result in no-show + deposits penalty delHandler := http.HandlerFunc(DeleteBookingHandler) delReq := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": false} w = makeRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token) if w.Code != http.StatusOK && w.Code != http.StatusNoContent { t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String()) } // Verify deposits were applied var deposits int err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } if deposits != 0 { t.Errorf("expected deposits=0 (admin flexibility, not auto-applied), got %d", deposits) } } // TestBookings_Delete_NoShow_WithForgiveness tests that admin can forgive a no-show func TestBookings_Delete_NoShow_WithForgiveness(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Create booking 20 hours from now (will be < 24h) soonTime := time.Now().Add(20 * time.Hour).Truncate(time.Second) soonTime = time.Date(soonTime.Year(), soonTime.Month(), soonTime.Day(), 10, 0, 0, 0, soonTime.Location()) bookingReq := CreateBookingRequest{ StartTime: soonTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token) var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Fatalf("failed to parse booking response: %v", err) } // Add payment _, _ = db.DB.Exec(context.Background(), "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking.ID) // Delete within 24 hours WITH forgiveness trueVal := true delHandler := http.HandlerFunc(DeleteBookingHandler) delReq := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": trueVal} w = makeRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token) if w.Code != http.StatusOK && w.Code != http.StatusNoContent { t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String()) } // Verify NO deposits were applied (forgiveness worked) var deposits int err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } if deposits != 0 { t.Errorf("expected deposits=0 after forgiveness, got %d", deposits) } } // ============================================================================= // Unauthorized Tests // ============================================================================= // TestBookings_Unauthorized tests that all booking endpoints require // authentication. It verifies that requests without a token are rejected with // HTTP 401 for protected endpoints. func TestBookings_Unauthorized(t *testing.T) { resetTestData(t) // Create test user and service userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) tests := []struct { name string method string path string body interface{} }{ { name: "Create without token", method: "POST", path: "/api/bookings", body: CreateBookingRequest{ServiceIDs: []string{serviceID}}, }, { name: "List without token", method: "GET", path: "/api/bookings", body: nil, }, { name: "Get without token", method: "GET", path: "/api/bookings/" + bookingID, body: nil, }, { name: "GetCalendar without token", method: "GET", path: "/api/bookings/" + bookingID + "/calendar", body: nil, }, { name: "Edit without token", method: "PUT", path: "/api/bookings/" + bookingID, body: EditBookingRequest{StartTime: time.Now().Add(96 * time.Hour)}, }, { name: "Delete without token", method: "DELETE", path: "/api/bookings/" + bookingID, body: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Get the appropriate handler var handler http.HandlerFunc switch tt.path { case "/api/bookings": if tt.method == "POST" { handler = CreateBookingHandler } else { handler = GetAllUserBookingsHandler } case "/api/bookings/" + bookingID: if tt.method == "GET" { handler = GetBookingHandler } else if tt.method == "PUT" { handler = EditBookingHandler } else { handler = DeleteBookingHandler } case "/api/bookings/" + bookingID + "/calendar": handler = GetBookingCalendarHandler } w := makeRequest(http.HandlerFunc(handler), tt.method, tt.path, tt.body, "") if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String()) } }) } } // ============================================================================= // Additional Edge Case Tests // ============================================================================= // TestBookings_List_Empty tests that listing bookings for a user with no // bookings returns an empty list with total 0. func TestBookings_List_Empty(t *testing.T) { resetTestData(t) // Create test user (with no bookings) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetAllUserBookingsHandler) w := makeRequest(handler, "GET", "/api/bookings", nil, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) } var resp BookingListResponse if err := parseResponseBody(w, &resp); err != nil { t.Errorf("failed to parse response: %v", err) } if len(resp.Bookings) != 0 { t.Errorf("expected 0 bookings, got %d", len(resp.Bookings)) } if resp.Total != 0 { t.Errorf("expected total 0, got %d", resp.Total) } } // TestBookings_Get_InvalidBookingID verifies that using an invalid // booking ID format returns HTTP 404 or 400. func TestBookings_Get_InvalidBookingID(t *testing.T) { resetTestData(t) // Create test user userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingHandler) w := makeRequest(handler, "GET", "/api/bookings/", nil, token) // trailing slash // Should return 404 or 400 depending on routing if w.Code != http.StatusNotFound && w.Code != http.StatusBadRequest { t.Errorf("expected status 404 or 400, got %d", w.Code) } } // TestBookings_Create_PastDate verifies that creating a booking with a // past start time fails with HTTP 400 Bad Request. func TestBookings_Create_PastDate(t *testing.T) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) pastTime := time.Now().Add(-24 * time.Hour).Truncate(time.Second) req := CreateBookingRequest{ StartTime: pastTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for past date, got %d. body: %s", w.Code, w.Body.String()) } } // TestBookings_Create_MinimumAdvance tests that bookings must be made at least // 1 hour in advance (changed from 48h deposit requirement to universal 1h rule). func TestBookings_Create_MinimumAdvance(t *testing.T) { resetTestData(t) // Seed working hours for booking tests seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Note: deposits_required=0 by default now _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Test booking 1+ hour in advance - should succeed aheadTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) aheadTime = time.Date(aheadTime.Year(), aheadTime.Month(), aheadTime.Day(), 10, 0, 0, 0, aheadTime.Location()) req := CreateBookingRequest{ StartTime: aheadTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Errorf("failed to parse response: %v", err) } if booking.DepositRequired { t.Error("expected deposit_required=false when user has deposits_required=0") } } // TestBookings_Create_WithNotes_StatusPending tests that when a booking is created with notes, // the booking status is automatically set to 'pending' (requires admin approval). func TestBookings_Create_WithNotes_StatusPending(t *testing.T) { resetTestData(t) // Seed working hours seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Booking 2+ hours ahead with notes futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) notes := "Special treatment needed" req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, Notes: ¬es, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Errorf("failed to parse response: %v", err) } if booking.Status != "pending" { t.Errorf("expected status 'pending' when notes provided, got %q", booking.Status) } if booking.Notes == nil || *booking.Notes != notes { t.Error("notes not preserved in booking") } } // TestBookings_Create_WithoutNotes_StatusConfirmed tests that when a booking is created without notes, // the booking status is automatically set to 'confirmed' (auto-approved). func TestBookings_Create_WithoutNotes_StatusConfirmed(t *testing.T) { resetTestData(t) // Seed working hours seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Booking 2+ hours ahead without notes futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, Notes: nil, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Errorf("failed to parse response: %v", err) } if booking.Status != "confirmed" { t.Errorf("expected status 'confirmed' without notes, got %q", booking.Status) } } // TestBookings_Create_Within1Hour_ShouldFail tests that bookings less than 1 hour in advance are rejected. func TestBookings_Create_Within1Hour_ShouldFail(t *testing.T) { resetTestData(t) // Seed working hours seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Booking less than 1 hour in advance (30 minutes) sooonTime := time.Now().Add(30 * time.Minute).Truncate(time.Second) req := CreateBookingRequest{ StartTime: sooonTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } // TestBookings_Create_MultipleServices verifies that a booking can include // multiple services at once, and all services are properly associated with // the booking in the database. func TestBookings_Create_MultipleServices(t *testing.T) { resetTestData(t) // Seed working hours for booking tests seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=0 to avoid 48h advance booking requirement _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID1, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create service 1: %v", err) } defer fixtures.DeleteService(db.DB, serviceID1) serviceID2, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create service 2: %v", err) } defer fixtures.DeleteService(db.DB, serviceID2) token := jwt.GenerateUserToken(userID) // Use 10:00 to ensure services fit within working hours (08:00-20:00) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID1, serviceID2}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Errorf("failed to parse response: %v", err) } if len(booking.Services) != 2 { t.Errorf("expected 2 services in booking, got %d", len(booking.Services)) } } // ============================================================================= // No-Show Deposit Logic Tests (Task 5) // ============================================================================= // TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3 tests that when a booking is // deleted as no-show with less than 24 hours notice (and no forgiveness), the // user's deposits_required is set to 3 and the booking status becomes "no_show". func TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) // Create test user with deposits_required = 0 userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Ensure deposits_required = 0 _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Create booking with start_time = now + 23 hours (< 24h notice, > 1h advance) soonTime := time.Now().Add(23 * time.Hour).Truncate(time.Second) bookingReq := CreateBookingRequest{ StartTime: soonTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token) var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Fatalf("failed to parse booking response: %v", err) } // Add a payment to the booking _, err = db.DB.Exec(context.Background(), "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking.ID) if err != nil { t.Fatalf("failed to create payment: %v", err) } // Delete booking with reason "no_show" and no forgiveness delHandler := http.HandlerFunc(DeleteBookingHandler) delReq := map[string]interface{}{"reason": "no_show", "forgive_no_show": false} w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "") if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var deposits int err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } if deposits != 0 { t.Errorf("expected deposits_required=0, got %d", deposits) } // Verify booking status = "no_show" var status string err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status) if err != nil { t.Errorf("failed to query booking status: %v", err) } if status != "no_show" { t.Errorf("expected status 'no_show', got %s", status) } } // TestDeleteBooking_CancelOver24h_NoDepositPenalty tests that when a booking is // cancelled with more than 24 hours notice (client_cancelled), no deposit penalty // is applied and deposits_required remains 0. func TestDeleteBooking_CancelOver24h_NoDepositPenalty(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) // Create test user with deposits_required = 0 userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Ensure deposits_required = 0 _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Create booking with start_time = now + 48 hours (> 24h notice) laterTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) laterTime = time.Date(laterTime.Year(), laterTime.Month(), laterTime.Day(), 10, 0, 0, 0, laterTime.Location()) bookingReq := CreateBookingRequest{ StartTime: laterTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token) var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Fatalf("failed to parse booking response: %v", err) } // Add a payment to the booking _, err = db.DB.Exec(context.Background(), "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking.ID) if err != nil { t.Fatalf("failed to create payment: %v", err) } // Delete booking with reason "client_cancelled" (>= 24h notice) delHandler := http.HandlerFunc(DeleteBookingHandler) delReq := map[string]interface{}{"reason": "client_cancelled"} w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "") if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify deposits_required = 0 (no penalty) var deposits int err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } if deposits != 0 { t.Errorf("expected deposits_required=0, got %d", deposits) } // Verify booking status = "client_cancelled" var status string err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status) if err != nil { t.Errorf("failed to query booking status: %v", err) } if status != "client_cancelled" { t.Errorf("expected status 'client_cancelled', got %s", status) } } // TestDeleteBooking_NoShowWithForgiveness_NoPenalty tests that when a booking is // deleted as no-show with forgiveness (forgive_no_show: true), no deposit penalty // is applied and the booking status becomes "client_cancelled". func TestDeleteBooking_NoShowWithForgiveness_NoPenalty(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) // Create test user with deposits_required = 0 userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Ensure deposits_required = 0 _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Create booking with start_time = now + 23 hours (< 24h notice, > 1h advance) soonTime := time.Now().Add(23 * time.Hour).Truncate(time.Second) bookingReq := CreateBookingRequest{ StartTime: soonTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token) var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Fatalf("failed to parse booking response: %v", err) } // Add a payment to the booking _, err = db.DB.Exec(context.Background(), "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking.ID) if err != nil { t.Fatalf("failed to create payment: %v", err) } // Delete booking with reason "no_show" AND forgiveness delHandler := http.HandlerFunc(DeleteBookingHandler) delReq := map[string]interface{}{"reason": "no_show", "forgive_no_show": true} w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "") if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify deposits_required = 0 (no penalty due to forgiveness) var deposits int err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } if deposits != 0 { t.Errorf("expected deposits_required=0 after forgiveness, got %d", deposits) } // Verify booking status = "client_cancelled" (not "no_show") var status string err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status) if err != nil { t.Errorf("failed to query booking status: %v", err) } if status != "client_cancelled" { t.Errorf("expected status 'client_cancelled' with forgiveness, got %s", status) } } // TestDeleteBooking_SecondNoShow_StaysAt3 tests that when a user has their second // no-show, the deposits_required stays at 3 (not 6). The handler sets deposits to 3 // on the first no-show and doesn't increment on subsequent no-shows. func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) // Create test user with deposits_required = 0 userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Ensure deposits_required = 0 _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // === First booking: no-show === soonTime1 := time.Now().Add(23 * time.Hour).Truncate(time.Second) bookingReq1 := CreateBookingRequest{ StartTime: soonTime1, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", bookingReq1, token) var booking1 Booking if err := parseResponseBody(w, &booking1); err != nil { t.Fatalf("failed to parse booking response: %v", err) } // Add payment _, _ = db.DB.Exec(context.Background(), "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking1.ID) // Delete as no-show delHandler := http.HandlerFunc(DeleteBookingHandler) delReq1 := map[string]interface{}{"reason": "no_show", "forgive_no_show": false} w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking1.ID, delReq1, token, "") if w.Code != http.StatusOK { t.Fatalf("first delete failed: %d body: %s", w.Code, w.Body.String()) } // Verify deposits = 3 after first no-show var deposits1 int err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits1) if err != nil { t.Errorf("failed to query deposits: %v", err) } if deposits1 != 0 { t.Errorf("expected deposits=0 after first no-show, got %d", deposits1) } // === Second booking: no-show === soonTime2 := time.Now().Add(47 * time.Hour).Truncate(time.Second) bookingReq2 := CreateBookingRequest{ StartTime: soonTime2, ServiceIDs: []string{serviceID}, } w = makeRequest(handler, "POST", "/api/bookings", bookingReq2, token) var booking2 Booking if err := parseResponseBody(w, &booking2); err != nil { t.Fatalf("failed to parse second booking response: %v", err) } // Add payment _, _ = db.DB.Exec(context.Background(), "INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)", booking2.ID) // Delete as no-show delReq2 := map[string]interface{}{"reason": "no_show", "forgive_no_show": false} w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking2.ID, delReq2, token, "") if w.Code != http.StatusOK { t.Fatalf("second delete failed: %d body: %s", w.Code, w.Body.String()) } var deposits2 int err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits2) if err != nil { t.Errorf("failed to query deposits: %v", err) } if deposits2 != 0 { t.Errorf("expected deposits=0, got %d", deposits2) } } // TestCountUnforgivenNoShows_ExcludesForgiven tests that CountUnforgivenNoShows // excludes bookings that have been forgiven (in forgiven_no_shows table). func TestCountUnforgivenNoShows_ExcludesForgiven(t *testing.T) { resetTestData(t) // Create test user userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create 3 bookings with status "no_show" within last 6 months now := time.Now() for i := 0; i < 3; i++ { startTime := now.Add(time.Duration(i*30) * 24 * time.Hour) // 0, 30, 60 days ago var bookingID string err := db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status, notes) VALUES ($1, $2, 'no_show', 'test no-show') RETURNING id `, userID, startTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking %d: %v", i, err) } // Link service _, err = db.DB.Exec(context.Background(), "INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)", bookingID, serviceID) if err != nil { t.Fatalf("failed to link service: %v", err) } // Forgive the first one (i == 0) if i == 0 { _, err = db.DB.Exec(context.Background(), "INSERT INTO forgiven_no_shows (booking_id) VALUES ($1)", bookingID) if err != nil { t.Fatalf("failed to forgive no-show: %v", err) } } } // Call CountUnforgivenNoShows directly (unexported but in same package) count, err := CountUnforgivenNoShows(context.Background(), userID) if err != nil { t.Fatalf("CountUnforgivenNoShows failed: %v", err) } // Should be 2 (3 total - 1 forgiven = 2) if count != 2 { t.Errorf("expected count=2 (3 total - 1 forgiven), got %d", count) } } // TestCountUnforgivenNoShows_ExcludesOld tests that CountUnforgivenNoShows // excludes no-shows older than 6 months. func TestCountUnforgivenNoShows_ExcludesOld(t *testing.T) { resetTestData(t) // Create test user userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) now := time.Now() // Create 1 booking with status "no_show" from 7 months ago (should be excluded) oldStartTime := now.Add(-7 * 30 * 24 * time.Hour) var oldBookingID string err = db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status, notes) VALUES ($1, $2, 'no_show', 'old no-show') RETURNING id `, userID, oldStartTime).Scan(&oldBookingID) if err != nil { t.Fatalf("failed to create old booking: %v", err) } _, _ = db.DB.Exec(context.Background(), "INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)", oldBookingID, serviceID) // Create 1 booking with status "no_show" from 1 month ago (should be included) recentStartTime := now.Add(-30 * 24 * time.Hour) var recentBookingID string err = db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status, notes) VALUES ($1, $2, 'no_show', 'recent no-show') RETURNING id `, userID, recentStartTime).Scan(&recentBookingID) if err != nil { t.Fatalf("failed to create recent booking: %v", err) } _, _ = db.DB.Exec(context.Background(), "INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)", recentBookingID, serviceID) // Call CountUnforgivenNoShows directly count, err := CountUnforgivenNoShows(context.Background(), userID) if err != nil { t.Fatalf("CountUnforgivenNoShows failed: %v", err) } // Should be 1 (only the recent one within 6 months) if count != 1 { t.Errorf("expected count=1 (only recent within 6 months), got %d", count) } } // TestApplyDepositsIfNeeded_AppliesAt2Plus tests that ApplyDepositsIfNeeded // applies 3 deposits when user has 2 or more unforgiven no-shows in last 6 months. func TestApplyDepositsIfNeeded_AppliesAt2Plus(t *testing.T) { resetTestData(t) // Create test user with deposits_required = 0 userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Ensure deposits_required = 0 _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create 2 bookings with status "no_show" within last 6 months now := time.Now() for i := 0; i < 2; i++ { startTime := now.Add(time.Duration(i*30) * 24 * time.Hour) // 0, 30 days ago var bookingID string err := db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status, notes) VALUES ($1, $2, 'no_show', 'test no-show') RETURNING id `, userID, startTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking %d: %v", i, err) } // Link service _, err = db.DB.Exec(context.Background(), "INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)", bookingID, serviceID) if err != nil { t.Fatalf("failed to link service: %v", err) } } // Call ApplyDepositsIfNeeded directly applied, err := ApplyDepositsIfNeeded(context.Background(), userID) if err != nil { t.Fatalf("ApplyDepositsIfNeeded failed: %v", err) } // Should return true (deposits were applied) if !applied { t.Error("expected ApplyDepositsIfNeeded to return true when 2+ no-shows") } // Verify deposits_required = 3 var deposits int err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } if deposits != 3 { t.Errorf("expected deposits_required=3, got %d", deposits) } } // TestApplyDepositsIfNeeded_DoesNotApplyAt1 tests that ApplyDepositsIfNeeded // does NOT apply deposits when user has only 1 unforgiven no-show. func TestApplyDepositsIfNeeded_DoesNotApplyAt1(t *testing.T) { resetTestData(t) // Create test user with deposits_required = 0 userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Ensure deposits_required = 0 _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create 1 booking with status "no_show" within last 6 months startTime := time.Now().Add(-30 * 24 * time.Hour) // 30 days ago var bookingID string err = db.DB.QueryRow(context.Background(), ` INSERT INTO bookings (user_id, start_time, status, notes) VALUES ($1, $2, 'no_show', 'test no-show') RETURNING id `, userID, startTime).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Link service _, err = db.DB.Exec(context.Background(), "INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)", bookingID, serviceID) if err != nil { t.Fatalf("failed to link service: %v", err) } // Call ApplyDepositsIfNeeded directly applied, err := ApplyDepositsIfNeeded(context.Background(), userID) if err != nil { t.Fatalf("ApplyDepositsIfNeeded failed: %v", err) } // Should return false (deposits were NOT applied) if applied { t.Error("expected ApplyDepositsIfNeeded to return false when only 1 no-show") } // Verify deposits_required = 0 (unchanged) var deposits int err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits) if err != nil { t.Errorf("failed to query deposits: %v", err) } if deposits != 0 { t.Errorf("expected deposits_required=0 (unchanged), got %d", deposits) } } // 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) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=0 to avoid 48h advance booking requirement _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) // Make request WITHOUT token (empty string passed as token parameter) handler := http.HandlerFunc(GetBookingHandler) w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, "") if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401 for missing auth, got %d", w.Code) } } // ============================================================================= // Calendar Export Tests - ICS Format Validation // ============================================================================= // TestBookings_GetCalendar_ValidICS validates that the ICS calendar export // contains all required fields: BEGIN:VCALENDAR, END:VCALENDAR, BEGIN:VEVENT, // END:VEVENT, DTSTART, DTEND, and SUMMARY. func TestBookings_GetCalendar_ValidICS(t *testing.T) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=0 to avoid 48h advance booking requirement _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(GetBookingCalendarHandler) w := makeRequest(handler, "GET", "/api/bookings/"+bookingID+"/calendar", nil, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } body := w.Body.String() // Validate ICS structure is present if !bytes.Contains(w.Body.Bytes(), []byte("BEGIN:VCALENDAR")) { t.Error("ICS response missing BEGIN:VCALENDAR") } if !bytes.Contains(w.Body.Bytes(), []byte("END:VCALENDAR")) { t.Error("ICS response missing END:VCALENDAR") } if !bytes.Contains(w.Body.Bytes(), []byte("BEGIN:VEVENT")) { t.Error("ICS response missing BEGIN:VEVENT") } if !bytes.Contains(w.Body.Bytes(), []byte("END:VEVENT")) { t.Error("ICS response missing END:VEVENT") } // Validate basic ICS properties if !bytes.Contains(w.Body.Bytes(), []byte("DTSTART")) { t.Error("ICS response missing DTSTART") } if !bytes.Contains(w.Body.Bytes(), []byte("DTEND")) { t.Error("ICS response missing DTEND") } if !bytes.Contains(w.Body.Bytes(), []byte("SUMMARY")) { t.Error("ICS response missing SUMMARY") } if len(body) == 0 { t.Error("expected non-empty ICS response") } } // ============================================================================= // Cancellation and Notification Tests // ============================================================================= // TestUserCancelBooking_ConfirmedCreatesNotification verifies that when a // user cancels a confirmed booking (one with payments), an admin notification // is created to alert staff of the cancellation. func TestUserCancelBooking_ConfirmedCreatesNotification(t *testing.T) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=0 to avoid 48h advance booking requirement _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) // First, confirm the booking (so it's not pending) _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Add a payment to trigger soft delete path (bookings with payments use soft delete) _, err = db.DB.Exec(context.Background(), ` INSERT INTO payments (id, booking_id, payment_type, payment_method, amount, status, created_at) VALUES ($1, $2, 'deposit', 'in_person_card', 50.00, 'completed', NOW()) `, bookingID[:8]+"pay", bookingID) if err != nil { t.Fatalf("failed to add payment: %v", err) } token := jwt.GenerateUserToken(userID) // Cancel the confirmed booking with a reason (required for soft delete) handler := http.HandlerFunc(DeleteBookingHandler) reqBody := map[string]string{"reason": "client_cancelled"} w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, reqBody, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify admin notification was created var notifCount int err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'cancelled_booking'`, bookingID).Scan(¬ifCount) if err != nil { t.Errorf("failed to query notifications: %v", err) } if notifCount != 1 { t.Errorf("expected 1 admin notification for confirmed->cancelled, got %d", notifCount) } } // TestUserCancelBooking_PendingNoNotification verifies that cancelling a // pending booking (one without payments) does NOT create an admin notification, // as pending cancellations don't require staff attention. func TestUserCancelBooking_PendingNoNotification(t *testing.T) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=0 to avoid 48h advance booking requirement _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) // Verify booking is in 'pending' status var status string err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if status != "pending" { t.Fatalf("expected booking status 'pending', got %s", status) } token := jwt.GenerateUserToken(userID) // Cancel the pending booking handler := http.HandlerFunc(DeleteBookingHandler) w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify NO admin notification was created (pending cancellations don't notify) var notifCount int err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'cancelled_booking'`, bookingID).Scan(¬ifCount) if err != nil { t.Errorf("failed to query notifications: %v", err) } if notifCount != 0 { t.Errorf("expected NO admin notification for pending->cancelled, got %d", notifCount) } } // ============================================================================= // Transaction and Error Handling Tests // ============================================================================= // TestUserCancelBooking_TransactionIntegrity verifies that if any part of the // cancellation transaction fails, the booking status is NOT changed (rollback behavior) func TestUserCancelBooking_TransactionIntegrity(t *testing.T) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=0 to avoid 48h advance booking requirement _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) // Confirm the booking _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Add a payment to trigger soft delete path (bookings with payments use soft delete) _, err = db.DB.Exec(context.Background(), ` INSERT INTO payments (id, booking_id, payment_type, payment_method, amount, status, created_at) VALUES ($1, $2, 'deposit', 'in_person_card', 50.00, 'completed', NOW()) `, bookingID[:8]+"pay", bookingID) if err != nil { t.Fatalf("failed to add payment: %v", err) } // Verify initial state var statusBefore string err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&statusBefore) if statusBefore != "confirmed" { t.Fatalf("expected status 'confirmed' before cancel, got %s", statusBefore) } token := jwt.GenerateUserToken(userID) // Cancel the booking with a reason (required for soft delete) handler := http.HandlerFunc(DeleteBookingHandler) reqBody := map[string]string{"reason": "client_cancelled"} w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, reqBody, token) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify that status WAS changed (successful transaction commit) var statusAfter string err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&statusAfter) if statusAfter == "confirmed" { t.Error("booking status should have changed after cancellation (transaction should have committed)") } } // TestCreateEditRequest verifies that a user can request an edit to their // confirmed booking (e.g., change time). This creates a booking_edit_request record // and generates an admin notification for staff review. func TestCreateEditRequest(t *testing.T) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=0 to avoid 48h advance booking requirement _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) // Confirm the booking _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } 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_requested' AND acknowledged_at IS NULL`, bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query notifications: %v", err) } if notifCount != 1 { t.Errorf("expected 1 unacknowledged admin notification, got %d", notifCount) } } // TestCreateEditRequest_WithTimeChange verifies that a user can request an edit to // change the booking time, and a time_blocker is created to reserve the new slot. func TestCreateEditRequest_WithTimeChange(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } token := jwt.GenerateUserToken(userID) newStartTime := time.Now().Add(24 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Errorf("expected status 200/201, got %d. body: %s", w.Code, w.Body.String()) } var erNewTime time.Time err = db.DB.QueryRow(context.Background(), "SELECT new_start_time FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erNewTime) if err != nil { t.Fatalf("failed to query edit request: %v", err) } if !erNewTime.Truncate(time.Second).Equal(newStartTime) { t.Errorf("expected new_start_time %v, got %v", newStartTime, erNewTime) } var blockerCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCount) if err != nil { t.Fatalf("failed to query time_blockers: %v", err) } if blockerCount != 1 { t.Errorf("expected 1 time_blocker for edit request, got %d", blockerCount) } var blockerStart time.Time var blockerDuration int err = db.DB.QueryRow(context.Background(), "SELECT start_time, duration_minutes FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerStart, &blockerDuration) if err != nil { t.Fatalf("failed to query time_blocker details: %v", err) } if !blockerStart.Truncate(time.Second).Equal(newStartTime) { t.Errorf("expected blocker start_time %v, got %v", newStartTime, blockerStart) } if blockerDuration < 15 { t.Errorf("expected blocker duration >= 15, got %d", blockerDuration) } } // TestDeleteEditRequest tests that user deleting their edit request deletes the admin notification func TestDeleteEditRequest(t *testing.T) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=0 _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) // Confirm the booking _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Create edit request directly in DB (simulating user request) var editRequestID string err = db.DB.QueryRow(context.Background(), `INSERT INTO booking_edit_requests (booking_id, requested_by, notes) VALUES ($1, $2, 'Please change time') RETURNING id`, bookingID, userID).Scan(&editRequestID) if err != nil { t.Fatalf("failed to create edit request: %v", err) } // Create admin notification _, err = db.DB.Exec(context.Background(), `INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('edit_requested', $1, $2)`, bookingID, userID) if err != nil { t.Fatalf("failed to create admin notification: %v", err) } token := jwt.GenerateUserToken(userID) // Delete edit request (user cancels their request) handler := http.HandlerFunc(DeleteEditRequestHandler) w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, token) if w.Code != http.StatusNoContent { t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String()) } // Verify edit request was deleted var erCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) } if erCount != 0 { t.Errorf("expected 0 edit requests after delete, got %d", erCount) } // Verify admin notification was DELETED (not acknowledged) var notifCount int err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query notifications: %v", err) } if notifCount != 0 { t.Errorf("expected 0 admin notifications after delete, got %d", notifCount) } } // TestAdminApproveEditRequest tests that admin approving acknowledges the notification (not deletes) func TestAdminApproveEditRequest(t *testing.T) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=0 _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) // Confirm the booking _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Create edit request directly in DB (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_requested', $1, $2)`, bookingID, userID) if err != nil { t.Fatalf("failed to create admin notification: %v", err) } // Verify notification starts as unacknowledged var ackTime *time.Time err = db.DB.QueryRow(context.Background(), `SELECT acknowledged_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(&ackTime) if err != nil { t.Fatalf("failed to query notification: %v", err) } if ackTime != nil { t.Fatalf("expected notification to be unacknowledged initially") } // Simulate admin approval adminToken := jwt.GenerateAdminToken() // Create request with chi context req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil) req.Header.Set("Authorization", "Bearer "+adminToken) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("request_id", editRequestID) req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) w := httptest.NewRecorder() AdminApproveEditRequestHandler(w, req) if w.Code != http.StatusNoContent && w.Code != http.StatusOK { t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String()) } // Verify edit request was deleted (approved) var erCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) } if erCount != 0 { t.Errorf("expected 0 edit requests after approve, got %d", erCount) } // Verify admin notification was ACKNOWLEDGED (not deleted) - history preserved var ackTimeAfter *time.Time err = db.DB.QueryRow(context.Background(), `SELECT acknowledged_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(&ackTimeAfter) if err != nil { t.Fatalf("failed to query notification: %v", err) } if ackTimeAfter == nil { t.Errorf("expected notification to be acknowledged after approve, but acknowledged_at is still NULL") } } // TestAdminRejectEditRequest tests that admin rejecting acknowledges the notification (not deletes) func TestAdminRejectEditRequest(t *testing.T) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=0 _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) // Confirm the booking _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Create edit request directly in DB var editRequestID string err = db.DB.QueryRow(context.Background(), `INSERT INTO booking_edit_requests (booking_id, requested_by, notes) VALUES ($1, $2, 'Please change time') RETURNING id`, bookingID, userID).Scan(&editRequestID) if err != nil { t.Fatalf("failed to create edit request: %v", err) } // Create admin notification _, err = db.DB.Exec(context.Background(), `INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('edit_requested', $1, $2)`, bookingID, userID) if err != nil { t.Fatalf("failed to create admin notification: %v", err) } // Verify notification starts as unacknowledged var ackTime *time.Time err = db.DB.QueryRow(context.Background(), `SELECT acknowledged_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(&ackTime) if err != nil { t.Fatalf("failed to query notification: %v", err) } if ackTime != nil { t.Fatalf("expected notification to be unacknowledged initially") } // Simulate admin denial adminToken := jwt.GenerateAdminToken() // Create request with chi context req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/deny", nil) req.Header.Set("Authorization", "Bearer "+adminToken) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("request_id", editRequestID) req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) w := httptest.NewRecorder() AdminRejectEditRequestHandler(w, req) if w.Code != http.StatusNoContent && w.Code != http.StatusOK { t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String()) } // Verify edit request was deleted (rejected) var erCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) } if erCount != 0 { t.Errorf("expected 0 edit requests after reject, got %d", erCount) } // Verify admin notification was ACKNOWLEDGED (not deleted) - history preserved var ackTimeAfter *time.Time err = db.DB.QueryRow(context.Background(), `SELECT acknowledged_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(&ackTimeAfter) if err != nil { t.Fatalf("failed to query notification: %v", err) } if ackTimeAfter == nil { t.Errorf("expected notification to be acknowledged after reject, but acknowledged_at is still NULL") } } // TestAdminApproveEditRequest_DeletesTimeBlocker verifies that when admin approves // an edit request, the associated time_blocker reservation is deleted. func TestAdminApproveEditRequest_DeletesTimeBlocker(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } userToken := jwt.GenerateUserToken(userID) newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) createReq := http.HandlerFunc(RequestEditHandler) createBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken) if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String()) } var blockerCountBefore int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore) if err != nil { t.Fatalf("failed to query blockers: %v", err) } if blockerCountBefore != 1 { t.Fatalf("expected 1 blocker before approval, got %d", blockerCountBefore) } var editRequestID string err = db.DB.QueryRow(context.Background(), "SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID) if err != nil { t.Fatalf("failed to get edit request ID: %v", err) } r := chi.NewRouter() r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/approve", AdminApproveEditRequestHandler) req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil) ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin") rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("request_id", editRequestID) ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) req = req.WithContext(ctx) w = httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusNoContent && w.Code != http.StatusOK { t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String()) } var blockerCountAfter int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter) if err != nil { t.Fatalf("failed to query blockers after approval: %v", err) } if blockerCountAfter != 0 { t.Errorf("expected 0 blockers after approval, got %d", blockerCountAfter) } } // TestAdminRejectEditRequest_DeletesTimeBlocker verifies that when admin rejects // an edit request, the associated time_blocker reservation is deleted. func TestAdminRejectEditRequest_DeletesTimeBlocker(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } userToken := jwt.GenerateUserToken(userID) newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) createReq := http.HandlerFunc(RequestEditHandler) createBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken) if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String()) } var blockerCountBefore int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore) if err != nil { t.Fatalf("failed to query blockers: %v", err) } if blockerCountBefore != 1 { t.Fatalf("expected 1 blocker before rejection, got %d", blockerCountBefore) } var editRequestID string err = db.DB.QueryRow(context.Background(), "SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID) if err != nil { t.Fatalf("failed to get edit request ID: %v", err) } r := chi.NewRouter() r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/deny", AdminRejectEditRequestHandler) req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/deny", nil) ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin") rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("request_id", editRequestID) ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) req = req.WithContext(ctx) w = httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusNoContent && w.Code != http.StatusOK { t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String()) } var blockerCountAfter int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter) if err != nil { t.Fatalf("failed to query blockers after rejection: %v", err) } if blockerCountAfter != 0 { t.Errorf("expected 0 blockers after rejection, got %d", blockerCountAfter) } } // TestDeleteEditRequest_DeletesTimeBlocker verifies that when user cancels their // own edit request, the associated time_blocker reservation is deleted. func TestDeleteEditRequest_DeletesTimeBlocker(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } userToken := jwt.GenerateUserToken(userID) newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) createReq := http.HandlerFunc(RequestEditHandler) createBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken) if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String()) } var blockerCountBefore int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore) if err != nil { t.Fatalf("failed to query blockers: %v", err) } if blockerCountBefore != 1 { t.Fatalf("expected 1 blocker before delete, got %d", blockerCountBefore) } delHandler := http.HandlerFunc(DeleteEditRequestHandler) w = makeRequest(delHandler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, userToken) if w.Code != http.StatusOK && w.Code != http.StatusNoContent { t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String()) } var blockerCountAfter int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description = $1", fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter) if err != nil { t.Fatalf("failed to query blockers after delete: %v", err) } if blockerCountAfter != 0 { t.Errorf("expected 0 blockers after user delete, got %d", blockerCountAfter) } } // TestAdminApproveEditRequest_TimeBlockerOverlap tests that approving an edit // request fails when the new time conflicts with an existing time_blocker. func TestAdminApproveEditRequest_TimeBlockerOverlap(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } userToken := jwt.GenerateUserToken(userID) newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) createReq := http.HandlerFunc(RequestEditHandler) createBody := map[string]interface{}{ "new_start_time": newStartTime.Format(time.RFC3339), } w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken) if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String()) } _, err = db.DB.Exec(context.Background(), ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Existing blocker', $2) `, newStartTime, userID) if err != nil { t.Fatalf("failed to create blocking time_blocker: %v", err) } var editRequestID string err = db.DB.QueryRow(context.Background(), "SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID) if err != nil { t.Fatalf("failed to get edit request ID: %v", err) } r := chi.NewRouter() r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/approve", AdminApproveEditRequestHandler) req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil) ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin") rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) rctx.URLParams.Add("request_id", editRequestID) ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) req = req.WithContext(ctx) w = httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusConflict { t.Errorf("expected status 409 Conflict due to time_blocker overlap, got %d. body: %s", w.Code, w.Body.String()) } } // TestBookings_RequestEdit_BookingNotFound tests that requesting an edit for a non-existent booking returns 404 func TestBookings_RequestEdit_BookingNotFound(t *testing.T) { resetTestData(t) // Create test user userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=0 _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "notes": "Please change the time", } w := makeRequest(handler, "POST", "/api/bookings/nonexistent-booking-id/edit-request", reqBody, token) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d", w.Code) } } // TestBookings_RequestEdit_AlreadyHasPending tests that a user cannot create a second edit request while one already exists func TestBookings_RequestEdit_AlreadyHasPending(t *testing.T) { resetTestData(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=0 to avoid 48h advance booking requirement _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) // Confirm the booking _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // Create a pending edit request directly in DB (pre-condition) _, err = db.DB.Exec(context.Background(), `INSERT INTO booking_edit_requests (booking_id, requested_by, notes) VALUES ($1, $2, 'Please change the time')`, bookingID, userID) if err != nil { t.Fatalf("failed to create initial edit request: %v", err) } // Create admin notification for the initial edit request _, err = db.DB.Exec(context.Background(), `INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('edit_requested', $1, $2)`, bookingID, userID) if err != nil { t.Fatalf("failed to create admin notification: %v", err) } token := jwt.GenerateUserToken(userID) // Try to create another edit request via API // Note: The handler actually replaces (upserts) the existing request, not rejects it handler := http.HandlerFunc(RequestEditHandler) reqBody := map[string]interface{}{ "notes": "Please change to a different day", } w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) // Expect HTTP 201 Created (handler replaces existing request) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } // Verify only 1 edit request exists in DB (the old one was replaced) var erCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount) if err != nil { t.Fatalf("failed to query edit requests: %v", err) } if erCount != 1 { t.Errorf("expected 1 edit request, got %d", erCount) } // Verify the notes were updated var notes string err = db.DB.QueryRow(context.Background(), "SELECT notes FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(¬es) if err != nil { t.Fatalf("failed to query edit request notes: %v", err) } if notes != "Please change to a different day" { t.Errorf("expected notes 'Please change to a different day', got '%s'", notes) } } // ============================================================================= // Patch Test Validation Tests // ============================================================================= // TestBookings_Create_PatchTestRequired_NoRecord verifies that a user without a patch test record // cannot book a service that requires a patch test. The booking should be rejected with 400. func TestBookings_Create_PatchTestRequired_NoRecord(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) // Create user and service with patch test requirement userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=0 to avoid 48h requirement _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(db.DB) if err != nil { t.Fatalf("failed to create test service with patch test: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) _ = patchTestID // We don't delete patch tests, they cascade with service token := jwt.GenerateUserToken(userID) // Try to book service requiring patch test - user has no patch test record futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for missing patch test, got %d. body: %s", w.Code, w.Body.String()) } if !bytes.Contains(w.Body.Bytes(), []byte("patch test")) { t.Errorf("expected error message about patch test, got: %s", w.Body.String()) } } // TestBookings_Create_PatchTestRequired_WithinNoticePeriod verifies that a user // cannot book within the notice period after completing a patch test (e.g., 24h wait). func TestBookings_Create_PatchTestRequired_WithinNoticePeriod(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(db.DB) if err != nil { t.Fatalf("failed to create test service with patch test: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create patch test record with tested_at only 1 hour ago (notice is 24h) testedAt := time.Now().Add(-1 * time.Hour).Format("2006-01-02 15:04:05") err = fixtures.CreateUserPatchTest(db.DB, userID, patchTestID, testedAt) if err != nil { t.Fatalf("failed to create user patch test: %v", err) } token := jwt.GenerateUserToken(userID) // Try to book within notice period (24h required, but only 1h passed) 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 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) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(db.DB) if err != nil { t.Fatalf("failed to create test service with patch test: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create patch test record from 7 months ago (expiry is 6 months) testedAt := time.Now().AddDate(0, -7, 0).Format("2006-01-02 15:04:05") err = fixtures.CreateUserPatchTest(db.DB, userID, patchTestID, testedAt) if err != nil { t.Fatalf("failed to create user patch test: %v", err) } token := jwt.GenerateUserToken(userID) // Try to book with expired patch test futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for expired patch test, got %d. body: %s", w.Code, w.Body.String()) } if !bytes.Contains(w.Body.Bytes(), []byte("expired")) { t.Errorf("expected error message about expiry, got: %s", w.Body.String()) } } // TestBookings_Create_PatchTestRequired_ValidRecord verifies that a user // with a valid patch test record can successfully book services requiring patch test. func TestBookings_Create_PatchTestRequired_ValidRecord(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(db.DB) if err != nil { t.Fatalf("failed to create test service with patch test: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create patch test record from 48 hours ago (notice is 24h, so valid now) testedAt := time.Now().Add(-48 * time.Hour).Format("2006-01-02 15:04:05") err = fixtures.CreateUserPatchTest(db.DB, userID, patchTestID, testedAt) if err != nil { t.Fatalf("failed to create user patch test: %v", err) } token := jwt.GenerateUserToken(userID) // Book with valid patch test record (after notice period) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201 for valid patch test, got %d. body: %s", w.Code, w.Body.String()) } // Verify booking was created var count int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM bookings WHERE user_id = $1", userID).Scan(&count) if err != nil { t.Errorf("failed to query bookings: %v", err) } if count != 1 { t.Errorf("expected 1 booking, got %d", count) } } // ============================================================================= // Deposit Requirement Tests // ============================================================================= // TestBookings_Create_DepositRequired_Within24Hours verifies that a user with deposits_required > 0 // cannot book within 24 hours notice (deposit payment window). They must complete more appointments // to remove this restriction. func TestBookings_Create_DepositRequired_Within24Hours(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=3 to trigger 24h 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 24 hours (should be blocked by deposit advance rule) // 23h advance: passes 1h minimum, fails 24h deposit rule within24h := time.Now().Add(23 * time.Hour) req := CreateBookingRequest{ StartTime: within24h, 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 24h booking with deposit requirement, got %d. body: %s", w.Code, w.Body.String()) } if !bytes.Contains(w.Body.Bytes(), []byte("24 hours")) { t.Errorf("expected error message about 24 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) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=3 to trigger 48h advance booking requirement _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Book more than 48 hours in advance (should succeed) after48h := time.Now().Add(72 * time.Hour).Truncate(time.Second) after48h = time.Date(after48h.Year(), after48h.Month(), after48h.Day(), 10, 0, 0, 0, after48h.Location()) req := CreateBookingRequest{ StartTime: after48h, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201 for booking after 48h, got %d. body: %s", w.Code, w.Body.String()) } // Verify booking was created var count int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM bookings WHERE user_id = $1", userID).Scan(&count) if err != nil { t.Errorf("failed to query bookings: %v", err) } if count != 1 { t.Errorf("expected 1 booking, got %d", count) } } // TestBookings_Create_NoDepositRequired_Within48Hours verifies that a user with deposits_required=0 // can book at any time (no 48h restriction). func TestBookings_Create_NoDepositRequired_Within48Hours(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // deposits_required=0 means no 48h restriction _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Book within 48 hours (should succeed since no deposit required) within48h := time.Now().Add(24 * time.Hour).Truncate(time.Second) within48h = time.Date(within48h.Year(), within48h.Month(), within48h.Day(), 10, 0, 0, 0, within48h.Location()) req := CreateBookingRequest{ StartTime: within48h, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Errorf("expected status 201 for booking within 48h with no deposit required, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // Deposit Snapshot and Field Tests // ============================================================================= // TestBookings_Create_DepositSnapshot verifies that deposit_required is snapshotted // at booking creation time from user's current deposits_required value. func TestBookings_Create_DepositSnapshot(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=3 BEFORE creating booking _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Create booking after deposits_required is set after48h := time.Now().Add(72 * time.Hour).Truncate(time.Second) after48h = time.Date(after48h.Year(), after48h.Month(), after48h.Day(), 10, 0, 0, 0, after48h.Location()) req := CreateBookingRequest{ StartTime: after48h, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } // Verify deposit_required was snapshotted on the booking var depositRequired bool err = db.DB.QueryRow(context.Background(), "SELECT deposit_required FROM bookings WHERE user_id = $1", userID).Scan(&depositRequired) if err != nil { t.Fatalf("failed to query booking: %v", err) } if !depositRequired { t.Error("expected deposit_required=true to be snapshotted on booking") } // Now change user's deposits_required to 0 _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to update deposits_required: %v", err) } // Verify the booking's deposit_required is still true (snapshot is not updated) err = db.DB.QueryRow(context.Background(), "SELECT deposit_required FROM bookings WHERE user_id = $1", userID).Scan(&depositRequired) if err != nil { t.Fatalf("failed to query booking: %v", err) } if !depositRequired { t.Error("expected deposit_required to remain true after user's deposits_required changed") } } // TestBookings_Create_DepositRequired_OneActiveBookingLimit verifies that a user // with deposits_required > 0 can only have ONE active booking at a time. func TestBookings_Create_DepositRequired_OneActiveBookingLimit(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=3 (triggers one-active-booking limit) _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Create first booking (should succeed) after48h := time.Now().Add(72 * time.Hour).Truncate(time.Second) after48h = time.Date(after48h.Year(), after48h.Month(), after48h.Day(), 10, 0, 0, 0, after48h.Location()) req1 := CreateBookingRequest{ StartTime: after48h, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req1, token) if w.Code != http.StatusCreated { t.Fatalf("expected first booking to succeed, got %d. body: %s", w.Code, w.Body.String()) } // Try to create second booking (should fail - one active booking limit) after72h := time.Now().Add(96 * time.Hour).Truncate(time.Second) after72h = time.Date(after72h.Year(), after72h.Month(), after72h.Day(), 10, 0, 0, 0, after72h.Location()) req2 := CreateBookingRequest{ StartTime: after72h, ServiceIDs: []string{serviceID}, } w = makeRequest(handler, "POST", "/api/bookings", req2, token) if w.Code != http.StatusConflict { t.Errorf("expected status 409 for second booking attempt, got %d. body: %s", w.Code, w.Body.String()) } if !bytes.Contains(w.Body.Bytes(), []byte("active booking")) { t.Errorf("expected error about active booking, got: %s", w.Body.String()) } } // TestBookings_Get_DepositFieldsReturned verifies that GET /api/bookings returns // the deposit-related fields (deposit_required, deposit_amount, deposit_paid, deposit_deadline). func TestBookings_Get_DepositFieldsReturned(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=3 and create booking _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) // Create booking with deposit requirement after48h := time.Now().Add(72 * time.Hour).Truncate(time.Second) after48h = time.Date(after48h.Year(), after48h.Month(), after48h.Day(), 10, 0, 0, 0, after48h.Location()) req := CreateBookingRequest{ StartTime: after48h, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } // GET the booking and verify deposit fields w = makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var resp BookingListResponse if err := parseResponseBody(w, &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if len(resp.Bookings) != 1 { t.Fatalf("expected 1 booking, got %d", len(resp.Bookings)) } booking := resp.Bookings[0] // Verify deposit fields exist // Verify deposit fields exist if !booking.DepositRequired { t.Error("expected DepositRequired to be true") } if booking.DepositAmount <= 0 { t.Error("expected DepositAmount to be positive") } // DepositPaid is a bool, check it's set (should be false for new booking) // Just verify the field exists by accessing it _ = booking.DepositPaid if booking.DepositDeadline == nil { t.Error("expected DepositDeadline to be set") } } // 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) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) // Create exceptional hours group for holiday var groupID string err = db.DB.QueryRow(context.Background(), "INSERT INTO exceptional_working_hours_groups (name, description) VALUES ($1, $2) RETURNING id", "Holiday Closure", "Closed for holiday").Scan(&groupID) if err != nil { t.Fatalf("failed to create exceptional hours group: %v", err) } // Calculate week start for the booking target date targetDate := time.Now().Add(96 * time.Hour) // DB convention: 0=Monday..6=Sunday; Go: 0=Sunday..6=Saturday. Convert. dbWeekday := (int(targetDate.Weekday()) + 6) % 7 daysToMonday := int(targetDate.Weekday()) if daysToMonday == 0 { daysToMonday = 7 } mondayDate := targetDate.AddDate(0, 0, -daysToMonday+1) weekStart := time.Date(mondayDate.Year(), mondayDate.Month(), mondayDate.Day(), 0, 0, 0, 0, mondayDate.Location()) // Apply group to this week _, err = db.DB.Exec(context.Background(), "INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2)", groupID, weekStart) if err != nil { t.Fatalf("failed to apply exceptional hours group: %v", err) } // Create closed exceptional hours for that weekday _, err = db.DB.Exec(context.Background(), `INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, $2, '08:00:00', '20:00:00', false)`, groupID, dbWeekday) if err != nil { t.Fatalf("failed to create closed exceptional hours: %v", err) } token := jwt.GenerateUserToken(userID) // Try to edit booking to closed day newStartTime := targetDate.Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 10, 0, 0, 0, newStartTime.Location()) req := EditBookingRequest{ StartTime: newStartTime, } handler := http.HandlerFunc(EditBookingHandler) w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for closed day edit, got %d. body: %s", w.Code, w.Body.String()) } if !bytes.Contains(w.Body.Bytes(), []byte("closed day")) { t.Errorf("expected error message about closed day, got: %s", w.Body.String()) } } // TestBookings_Edit_OpenDay_UserAllowed verifies that a user CAN edit a booking // to a day that is marked as open in exceptional hours. func TestBookings_Edit_OpenDay_UserAllowed(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) // Create exceptional hours group with OPEN hours (is_open=true) var groupID string err = db.DB.QueryRow(context.Background(), "INSERT INTO exceptional_working_hours_groups (name, description) VALUES ($1, $2) RETURNING id", "Special Opening", "Extended hours").Scan(&groupID) if err != nil { t.Fatalf("failed to create exceptional hours group: %v", err) } // Calculate week start for the booking target date targetDate := time.Now().Add(96 * time.Hour) weekday := int(targetDate.Weekday()) daysToMonday := weekday if daysToMonday == 0 { daysToMonday = 7 } mondayDate := targetDate.AddDate(0, 0, -daysToMonday+1) weekStart := time.Date(mondayDate.Year(), mondayDate.Month(), mondayDate.Day(), 0, 0, 0, 0, mondayDate.Location()) // Apply group to this week _, err = db.DB.Exec(context.Background(), "INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2)", groupID, weekStart) if err != nil { t.Fatalf("failed to apply exceptional hours group: %v", err) } // Create OPEN exceptional hours for that weekday _, err = db.DB.Exec(context.Background(), `INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) VALUES ($1, $2, '08:00:00', '20:00:00', true)`, groupID, weekday) if err != nil { t.Fatalf("failed to create open exceptional hours: %v", err) } token := jwt.GenerateUserToken(userID) // Edit booking to open day (should succeed) newStartTime := targetDate.Truncate(time.Second) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 10, 0, 0, 0, newStartTime.Location()) req := EditBookingRequest{ StartTime: newStartTime, } handler := http.HandlerFunc(EditBookingHandler) w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token) if w.Code != http.StatusOK { t.Errorf("expected status 200 for open day edit, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // Time Blocker Tests for User Bookings // ============================================================================= // TestBookings_Create_OverlappingBlocker_UserBlocked verifies that a regular user // CANNOT create a booking that overlaps with a time blocker. They receive 409 Conflict. func TestBookings_Create_OverlappingBlocker_UserBlocked(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=0 to avoid 48h advance booking requirement _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create a time blocker for a specific time ukLocation, _ := time.LoadLocation("Europe/London") blockerTime := time.Date(2099, 12, 31, 10, 0, 0, 0, ukLocation) _, err = db.DB.Exec(context.Background(), ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff meeting', NULL) `, blockerTime) if err != nil { t.Fatalf("failed to create time blocker: %v", err) } token := jwt.GenerateUserToken(userID) // User tries to create booking overlapping the blocker req := CreateBookingRequest{ StartTime: blockerTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) // User should get 409 Conflict (not 201 Created) if w.Code != http.StatusConflict { t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String()) } // Verify error message mentions the blocker if !bytes.Contains(w.Body.Bytes(), []byte("blocked")) { t.Errorf("expected error message to mention 'blocked', got: %s", w.Body.String()) } // Verify NO booking was created var count int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM bookings WHERE user_id = $1", userID).Scan(&count) if err != nil { t.Fatalf("failed to query bookings: %v", err) } if count != 0 { t.Errorf("expected 0 bookings (user should be blocked), got %d", count) } } // TestBookings_Edit_OverlappingBlocker_UserBlocked verifies that a regular user // CANNOT edit a booking to a time that overlaps with a time blocker. func TestBookings_Edit_OverlappingBlocker_UserBlocked(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) // Set deposits_required=0 to avoid 48h advance booking requirement _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) // Create a booking first bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } defer fixtures.DeleteBooking(db.DB, bookingID) // Create a time blocker for a specific time ukLocation, _ := time.LoadLocation("Europe/London") blockerTime := time.Date(2099, 12, 31, 10, 0, 0, 0, ukLocation) _, err = db.DB.Exec(context.Background(), ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff meeting', NULL) `, blockerTime) if err != nil { t.Fatalf("failed to create time blocker: %v", err) } token := jwt.GenerateUserToken(userID) // User tries to edit booking to overlap the blocker req := EditBookingRequest{ StartTime: blockerTime, } handler := http.HandlerFunc(EditBookingHandler) w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token) // User should get 409 Conflict if w.Code != http.StatusConflict { t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String()) } // Verify error message mentions the blocker if !bytes.Contains(w.Body.Bytes(), []byte("blocked")) { t.Errorf("expected error message to mention 'blocked', got: %s", w.Body.String()) } } // --- Guest Booking Tests --- func TestGuestUser_Create_Success(t *testing.T) { resetTestData(t) req := map[string]string{ "firstName": "Jane", "lastName": "Doe", "email": "jane.doe@test.com", "phone": "07123456789", } handler := http.HandlerFunc(user.CreateGuestUserHandler) w := makeRequest(handler, "POST", "/api/users/guest", req, "") if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var resp map[string]string json.Unmarshal(w.Body.Bytes(), &resp) if resp["role"] != "guest" { t.Errorf("expected role 'guest', got '%s'", resp["role"]) } if resp["id"] == "" { t.Error("expected non-empty user ID") } // Verify user exists in DB var role string err := db.DB.QueryRow(context.Background(), `SELECT account_role FROM users WHERE id = $1`, resp["id"]).Scan(&role) if err != nil { t.Fatalf("failed to query user: %v", err) } if role != "guest" { t.Errorf("expected role 'guest' in DB, got '%s'", role) } } func TestGuestUser_Create_DuplicateEmail(t *testing.T) { resetTestData(t) // First guest creation req := map[string]string{ "firstName": "John", "lastName": "Smith", "email": "john@test.com", "phone": "07123456789", } handler := http.HandlerFunc(user.CreateGuestUserHandler) w1 := makeRequest(handler, "POST", "/api/users/guest", req, "") if w1.Code != http.StatusCreated { t.Fatalf("first guest creation failed: %d", w1.Code) } var resp1 map[string]string json.Unmarshal(w1.Body.Bytes(), &resp1) // Second guest with same email — should create a NEW account req2 := map[string]string{ "firstName": "Jane", "lastName": "Smith", "email": "john@test.com", // same email "phone": "07123456780", } w2 := makeRequest(handler, "POST", "/api/users/guest", req2, "") if w2.Code != http.StatusCreated { t.Errorf("expected status 201 for second guest, got %d. body: %s", w2.Code, w2.Body.String()) } var resp2 map[string]string json.Unmarshal(w2.Body.Bytes(), &resp2) if resp1["id"] == resp2["id"] { t.Error("expected different user IDs for duplicate email, got same ID") } // Verify two separate guest accounts exist var count int db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM users WHERE email = $1 AND account_role = 'guest'`, "john@test.com").Scan(&count) if count != 2 { t.Errorf("expected 2 guest accounts with same email, got %d", count) } } func TestGuestUser_Create_RegisteredEmailCollision(t *testing.T) { resetTestData(t) // Create a registered user with a known email registeredEmail := "registered@example.com" db.DB.Exec(context.Background(), ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, account_role) VALUES ('Registered', 'User', $1, '07123456700', '1990-01-01', 'verified_email') `, registeredEmail) // Try to create a guest with same email as the registered user req := map[string]string{ "firstName": "Evil", "lastName": "Guest", "email": registeredEmail, "phone": "07123456799", } handler := http.HandlerFunc(user.CreateGuestUserHandler) w := makeRequest(handler, "POST", "/api/users/guest", req, "") if w.Code != http.StatusConflict { t.Errorf("expected status 409 for registered email collision, got %d. body: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "log in") { t.Errorf("expected error to mention 'log in', got: %s", w.Body.String()) } } func TestGuestBooking_Create_Success(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) // Create guest user guestReq := map[string]string{ "firstName": "Guest", "lastName": "User", "email": "guest@test.com", "phone": "07123456789", } handler := http.HandlerFunc(user.CreateGuestUserHandler) w := makeRequest(handler, "POST", "/api/users/guest", guestReq, "") if w.Code != http.StatusCreated { t.Fatalf("failed to create guest user: %d", w.Code) } var guestResp map[string]string json.Unmarshal(w.Body.Bytes(), &guestResp) guestID := guestResp["id"] // Create booking as guest serviceID, _ := fixtures.CreateTestService(db.DB) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, UserID: &guestID, } bookingHandler := http.HandlerFunc(CreateBookingHandler) w2 := makeRequest(bookingHandler, "POST", "/api/bookings", req, "") if w2.Code != http.StatusCreated { t.Errorf("expected status 201 for guest booking, got %d. body: %s", w2.Code, w2.Body.String()) } // Verify booking in DB var count int db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1`, guestID).Scan(&count) if count != 1 { t.Errorf("expected 1 booking for guest, got %d", count) } } func TestGuestBooking_Create_WithoutUserID(t *testing.T) { resetTestData(t) // Attempt booking without auth AND without user_id serviceID, _ := fixtures.CreateTestService(db.DB) futureTime := time.Now().Add(72 * time.Hour) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, "") if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401 for missing user_id, got %d. body: %s", w.Code, w.Body.String()) } } func TestGuestBooking_Create_NonGuestUserID(t *testing.T) { resetTestData(t) // Create a registered (non-guest) user userID, _ := fixtures.CreateTestUser(db.DB) // Try to book using their user_id but without auth token serviceID, _ := fixtures.CreateTestService(db.DB) futureTime := time.Now().Add(72 * time.Hour) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, UserID: &userID, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, "") if w.Code != http.StatusBadRequest { t.Errorf("expected status 400 for non-guest user_id, got %d. body: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "guest") { t.Errorf("expected error to mention 'guest', got: %s", w.Body.String()) } } func TestGuestBooking_SkipsDepositCheck(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) // Create guest user guestReq := map[string]string{ "firstName": "Guest", "lastName": "Skipper", "email": "skip@test.com", "phone": "07123456788", } handler := http.HandlerFunc(user.CreateGuestUserHandler) w := makeRequest(handler, "POST", "/api/users/guest", guestReq, "") var guestResp map[string]string json.Unmarshal(w.Body.Bytes(), &guestResp) guestID := guestResp["id"] // Give them an active booking with deposit required serviceID, _ := fixtures.CreateTestService(db.DB) pastTime := time.Now().Add(72 * time.Hour) db.DB.Exec(context.Background(), ` INSERT INTO bookings (user_id, start_time, status, deposit_required) VALUES ($1, $2, 'confirmed'::booking_status, false) `, guestID, pastTime) // Guest should still be able to create a second booking (deposit check skipped) futureTime := time.Now().Add(96 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 14, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, UserID: &guestID, } bookingHandler := http.HandlerFunc(CreateBookingHandler) w2 := makeRequest(bookingHandler, "POST", "/api/bookings", req, "") if w2.Code != http.StatusCreated { t.Errorf("expected guest to bypass deposit check, got %d. body: %s", w2.Code, w2.Body.String()) } } // ============================================================================= // Booking Notification Creation Tests // ============================================================================= func TestCreateBooking_Notifications_NewBookingAlwaysCreated(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var bookingID string err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID) if err != nil { t.Fatalf("failed to get booking ID: %v", err) } var notifCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'new_booking'", bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query notifications: %v", err) } if notifCount != 1 { t.Errorf("expected 1 new_booking notification, got %d", notifCount) } } func TestCreateBooking_Notifications_PendingBookingWithNotes(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) notes := "Please do French tips with gold foil" req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, Notes: ¬es, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var bookingID string err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID) if err != nil { t.Fatalf("failed to get booking ID: %v", err) } var newBookingCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'new_booking'", bookingID).Scan(&newBookingCount) if err != nil { t.Fatalf("failed to query new_booking notifications: %v", err) } if newBookingCount != 1 { t.Errorf("expected 1 new_booking notification, got %d", newBookingCount) } var pendingCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'pending_booking'", bookingID).Scan(&pendingCount) if err != nil { t.Fatalf("failed to query pending_booking notifications: %v", err) } if pendingCount != 1 { t.Errorf("expected 1 pending_booking notification for booking with notes, got %d", pendingCount) } } func TestCreateBooking_Notifications_NoPendingBookingWithoutNotes(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) req := CreateBookingRequest{ StartTime: futureTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var bookingID string err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID) if err != nil { t.Fatalf("failed to get booking ID: %v", err) } var pendingCount int err = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'pending_booking'", bookingID).Scan(&pendingCount) if err != nil { t.Fatalf("failed to query pending_booking notifications: %v", err) } if pendingCount != 0 { t.Errorf("expected 0 pending_booking notifications for booking without notes, got %d", pendingCount) } } // ============================================================================= // Closing Hours, Advance Check, and Active Booking Limit Tests // ============================================================================= func TestCreateBooking_ClosingHoursValidation(t *testing.T) { resetTestData(t) london, err := time.LoadLocation("Europe/London") if err != nil { t.Fatalf("failed to load London timezone: %v", err) } hours := []struct { weekday int startTime string endTime string isOpen bool }{ {0, "08:00", "17:00", true}, {1, "08:00", "20:00", true}, {2, "08:00", "20:00", true}, {3, "08:00", "20:00", true}, {4, "08:00", "20:00", true}, {5, "08:00", "20:00", true}, {6, "08:00", "20:00", true}, } seedCustomWorkingHours(t, hours) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) _, err = db.DB.Exec(context.Background(), "UPDATE services SET duration_minutes = 60 WHERE id = $1", serviceID) if err != nil { t.Fatalf("failed to set service duration: %v", err) } token := jwt.GenerateUserToken(userID) thursday := nextWeekday(time.Thursday, london) thursdayStart := time.Date(thursday.Year(), thursday.Month(), thursday.Day(), 17, 30, 0, 0, london) req1 := CreateBookingRequest{ StartTime: thursdayStart, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w1 := makeRequest(handler, "POST", "/api/bookings", req1, token) if w1.Code != http.StatusCreated { t.Errorf("Thursday 17:30+60min should succeed (ends 18:30 < 20:00), got %d. body: %s", w1.Code, w1.Body.String()) } monday := nextWeekday(time.Monday, london) mondayStart := time.Date(monday.Year(), monday.Month(), monday.Day(), 16, 30, 0, 0, london) req2 := CreateBookingRequest{ StartTime: mondayStart, ServiceIDs: []string{serviceID}, } w2 := makeRequest(handler, "POST", "/api/bookings", req2, token) if w2.Code != http.StatusBadRequest { t.Errorf("Monday 16:30+60min should fail (ends 17:30 > 17:00), got %d. body: %s", w2.Code, w2.Body.String()) } if !bytes.Contains(w2.Body.Bytes(), []byte("closing")) { t.Errorf("expected error about closing hours, got: %s", w2.Body.String()) } } func TestCreateBooking_OneHourAdvanceCheck(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(CreateBookingHandler) soonTime := time.Now().Add(30 * time.Minute).Truncate(time.Second) req1 := CreateBookingRequest{ StartTime: soonTime, ServiceIDs: []string{serviceID}, } w1 := makeRequest(handler, "POST", "/api/bookings", req1, token) if w1.Code != http.StatusBadRequest { t.Errorf("expected status 400 for 30-min advance booking, got %d. body: %s", w1.Code, w1.Body.String()) } if !bytes.Contains(w1.Body.Bytes(), []byte("at least 1 hour")) { t.Errorf("expected error about 1 hour advance, got: %s", w1.Body.String()) } aheadTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) aheadTime = time.Date(aheadTime.Year(), aheadTime.Month(), aheadTime.Day(), 10, 0, 0, 0, aheadTime.Location()) req2 := CreateBookingRequest{ StartTime: aheadTime, ServiceIDs: []string{serviceID}, } w2 := makeRequest(handler, "POST", "/api/bookings", req2, token) if w2.Code != http.StatusCreated { t.Errorf("expected status 201 for 2h+ advance booking, got %d. body: %s", w2.Code, w2.Body.String()) } } func TestCreateBooking_ActiveBookingLimit(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) token := jwt.GenerateUserToken(userID) handler := http.HandlerFunc(CreateBookingHandler) firstTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) firstTime = time.Date(firstTime.Year(), firstTime.Month(), firstTime.Day(), 10, 0, 0, 0, firstTime.Location()) req1 := CreateBookingRequest{ StartTime: firstTime, ServiceIDs: []string{serviceID}, } w1 := makeRequest(handler, "POST", "/api/bookings", req1, token) if w1.Code != http.StatusCreated { t.Fatalf("expected first booking to succeed, got %d. body: %s", w1.Code, w1.Body.String()) } var booking1 Booking if err := parseResponseBody(w1, &booking1); err != nil { t.Fatalf("failed to parse first booking: %v", err) } secondTime := time.Now().Add(96 * time.Hour).Truncate(time.Second) secondTime = time.Date(secondTime.Year(), secondTime.Month(), secondTime.Day(), 14, 0, 0, 0, secondTime.Location()) req2 := CreateBookingRequest{ StartTime: secondTime, ServiceIDs: []string{serviceID}, } w2 := makeRequest(handler, "POST", "/api/bookings", req2, token) if w2.Code != http.StatusConflict { t.Errorf("expected status 409 for second booking with active booking, got %d. body: %s", w2.Code, w2.Body.String()) } if !bytes.Contains(w2.Body.Bytes(), []byte("active booking")) { t.Errorf("expected error about active booking, got: %s", w2.Body.String()) } _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", booking1.ID) if err != nil { t.Fatalf("failed to cancel first booking: %v", err) } w3 := makeRequest(handler, "POST", "/api/bookings", req2, token) if w3.Code != http.StatusCreated { t.Errorf("expected status 201 after cancelling active booking, got %d. body: %s", w3.Code, w3.Body.String()) } } func TestNextWeekdayHelper(t *testing.T) { london, err := time.LoadLocation("Europe/London") if err != nil { t.Fatalf("Europe/London not available: %v", err) } tests := []struct { name string weekday time.Weekday }{ {"Monday", time.Monday}, {"Tuesday", time.Tuesday}, {"Wednesday", time.Wednesday}, {"Thursday", time.Thursday}, {"Friday", time.Friday}, {"Saturday", time.Saturday}, {"Sunday", time.Sunday}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := nextWeekday(tt.weekday, london) if result.Weekday() != tt.weekday { t.Errorf("expected weekday %s, got %s", tt.weekday, result.Weekday()) } now := time.Now().In(london) today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, london) resultDay := time.Date(result.Year(), result.Month(), result.Day(), 0, 0, 0, 0, london) daysDiff := int(resultDay.Sub(today).Hours() / 24) if daysDiff < 2 { t.Errorf("expected result to be at least 2 calendar days ahead, got %d", daysDiff) } }) } } func TestCreateBooking_DepositSnapshot(t *testing.T) { resetTestData(t) seedDefaultWorkingHours(t) userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) serviceID, err := fixtures.CreateTestService(db.DB) if err != nil { t.Fatalf("failed to create test service: %v", err) } defer fixtures.DeleteService(db.DB, serviceID) _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set deposits_required: %v", err) } token := jwt.GenerateUserToken(userID) london, err := time.LoadLocation("Europe/London") if err != nil { t.Fatalf("Europe/London not available: %v", err) } bookingTime := nextWeekday(time.Monday, london).Add(10 * time.Hour) req := CreateBookingRequest{ StartTime: bookingTime, ServiceIDs: []string{serviceID}, } handler := http.HandlerFunc(CreateBookingHandler) w := makeRequest(handler, "POST", "/api/bookings", req, token) if w.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var booking Booking if err := parseResponseBody(w, &booking); err != nil { t.Fatalf("failed to parse booking response: %v", err) } if !booking.DepositRequired { t.Error("expected deposit_required=true on first booking") } var depositRequired bool err = db.DB.QueryRow(context.Background(), "SELECT deposit_required FROM bookings WHERE id = $1", booking.ID).Scan(&depositRequired) if err != nil { t.Fatalf("failed to query booking: %v", err) } if !depositRequired { t.Error("expected deposit_required=true in DB for first booking") } _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) if err != nil { t.Fatalf("failed to update deposits_required: %v", err) } err = db.DB.QueryRow(context.Background(), "SELECT deposit_required FROM bookings WHERE id = $1", booking.ID).Scan(&depositRequired) if err != nil { t.Fatalf("failed to query booking after user update: %v", err) } if !depositRequired { t.Error("expected booking deposit_required to remain true after user change") } bookingTime2 := nextWeekday(time.Tuesday, london).Add(10 * time.Hour) req2 := CreateBookingRequest{ StartTime: bookingTime2, ServiceIDs: []string{serviceID}, } w2 := makeRequest(handler, "POST", "/api/bookings", req2, token) if w2.Code != http.StatusCreated { t.Fatalf("expected status 201 for second booking, got %d. body: %s", w2.Code, w2.Body.String()) } var booking2 Booking if err := parseResponseBody(w2, &booking2); err != nil { t.Fatalf("failed to parse second booking response: %v", err) } if booking2.DepositRequired { t.Error("expected deposit_required=false on second booking after user deposits_required=0") } var depositRequired2 bool err = db.DB.QueryRow(context.Background(), "SELECT deposit_required FROM bookings WHERE id = $1", booking2.ID).Scan(&depositRequired2) if err != nil { t.Fatalf("failed to query second booking: %v", err) } if depositRequired2 { t.Error("expected second booking deposit_required=false in DB") } }