diff --git a/backend/handlers/admin/bookings_test.go b/backend/handlers/admin/bookings_test.go index 05db6ff..59a2122 100644 --- a/backend/handlers/admin/bookings_test.go +++ b/backend/handlers/admin/bookings_test.go @@ -1826,3 +1826,114 @@ func TestAdminBookings_Edit_OverlappingBlocker_WithWarning(t *testing.T) { } } } + +// TestAdminBookings_Create_EnforceDeposits_Bypass tests that admin can create bookings +// for users with outstanding deposits by setting enforce_deposits=false. +func TestAdminBookings_Create_EnforceDeposits_Bypass(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + defer fixtures.DeleteUser(db.DB, adminID) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + // Set user to have outstanding deposits (deposits_required = 3) + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) + falseVal := false + req := bookings.AdminCreateBookingForUserRequest{ + UserID: userID, + StartTime: futureTime, + ServiceIDs: []string{serviceID}, + EnforceDeposits: &falseVal, // Bypass deposit check + } + + handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req) + + // Should succeed (not 409 Conflict) because deposits check was bypassed + if w.Code != http.StatusCreated { + t.Errorf("expected status 201 when enforce_deposits=false, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestAdminBookings_Create_EnforceDeposits_Enforced tests that by default (or when enforce_deposits=true), +// admin bookings respect the deposit requirement rules. +func TestAdminBookings_Create_EnforceDeposits_Enforced(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + defer fixtures.DeleteUser(db.DB, adminID) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + // Set user to have outstanding deposits + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + // Create first booking for user (will have it active) + firstTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) + firstReq := bookings.AdminCreateBookingForUserRequest{ + UserID: userID, + StartTime: firstTime, + ServiceIDs: []string{serviceID}, + EnforceDeposits: nil, // Default: enforce deposits + } + + handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", firstReq) + + if w.Code != http.StatusCreated { + t.Fatalf("failed to create first booking: %d. body: %s", w.Code, w.Body.String()) + } + + // Try to create second booking (should fail due to one-active-booking limit) + secondTime := time.Now().Add(96 * time.Hour).Truncate(time.Second) + secondReq := bookings.AdminCreateBookingForUserRequest{ + UserID: userID, + StartTime: secondTime, + ServiceIDs: []string{serviceID}, + EnforceDeposits: nil, // Default: enforce (deposits_required > 0 still active) + } + + w = makeAdminRequest(handler, "POST", "/api/admin/bookings", secondReq) + + // Should get 409 Conflict because user has active booking and deposits outstanding + if w.Code != http.StatusConflict { + t.Errorf("expected status 409 when enforce_deposits is enforced, got %d. body: %s", w.Code, w.Body.String()) + } +} diff --git a/backend/handlers/bookings/bookings_test.go b/backend/handlers/bookings/bookings_test.go index 859d663..281b4d8 100644 --- a/backend/handlers/bookings/bookings_test.go +++ b/backend/handlers/bookings/bookings_test.go @@ -253,7 +253,7 @@ func TestBookings_Create(t *testing.T) { } defer fixtures.DeleteUser(db.DB, userID) - // Set deposits_required=0 to avoid 48h advance booking requirement + // 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) @@ -268,7 +268,7 @@ func TestBookings_Create(t *testing.T) { // Generate token for user token := jwt.GenerateUserToken(userID) - // Create booking request - use future time to avoid 48h deposit requirement + // Create booking request - use future time (1h+ advance is now enforced) // 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()) @@ -319,7 +319,7 @@ func TestBookings_Create_InvalidInput(t *testing.T) { } defer fixtures.DeleteUser(db.DB, userID) - // Set deposits_required=0 to avoid 48h advance booking requirement + // 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) @@ -383,7 +383,7 @@ func TestBookings_List(t *testing.T) { } defer fixtures.DeleteUser(db.DB, userID) - // Set deposits_required=0 to avoid 48h advance booking requirement + // 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) @@ -439,7 +439,7 @@ func TestBookings_List_FilterByStatus(t *testing.T) { } defer fixtures.DeleteUser(db.DB, userID) - // Set deposits_required=0 to avoid 48h advance booking requirement + // 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) @@ -510,7 +510,7 @@ func TestBookings_Get(t *testing.T) { } defer fixtures.DeleteUser(db.DB, userID) - // Set deposits_required=0 to avoid 48h advance booking requirement + // 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) @@ -565,7 +565,7 @@ func TestBookings_Get_NotFound(t *testing.T) { } defer fixtures.DeleteUser(db.DB, userID) - // Set deposits_required=0 to avoid 48h advance booking requirement + // 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) @@ -644,7 +644,7 @@ func TestBookings_GetCalendar(t *testing.T) { } defer fixtures.DeleteUser(db.DB, userID) - // Set deposits_required=0 to avoid 48h advance booking requirement + // 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) @@ -706,7 +706,7 @@ func TestBookings_GetCalendar_NotFound(t *testing.T) { } defer fixtures.DeleteUser(db.DB, userID) - // Set deposits_required=0 to avoid 48h advance booking requirement + // 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) @@ -739,7 +739,7 @@ func TestBookings_Edit(t *testing.T) { } defer fixtures.DeleteUser(db.DB, userID) - // Set deposits_required=0 to avoid 48h advance booking requirement + // 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) @@ -805,7 +805,7 @@ func TestBookings_Edit_InvalidInput(t *testing.T) { } defer fixtures.DeleteUser(db.DB, userID) - // Set deposits_required=0 to avoid 48h advance booking requirement + // 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) @@ -867,7 +867,7 @@ func TestBookings_Edit_NotFound(t *testing.T) { } defer fixtures.DeleteUser(db.DB, userID) - // Set deposits_required=0 to avoid 48h advance booking requirement + // 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) @@ -905,7 +905,7 @@ func TestBookings_Delete(t *testing.T) { } defer fixtures.DeleteUser(db.DB, userID) - // Set deposits_required=0 to avoid 48h advance booking requirement + // 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) @@ -960,7 +960,7 @@ func TestBookings_Delete_WithReason(t *testing.T) { } defer fixtures.DeleteUser(db.DB, userID) - // Set deposits_required=0 to avoid 48h advance booking requirement + // 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) @@ -1030,7 +1030,7 @@ func TestBookings_Delete_NotFound(t *testing.T) { } defer fixtures.DeleteUser(db.DB, userID) - // Set deposits_required=0 to avoid 48h advance booking requirement + // 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) @@ -1046,6 +1046,134 @@ func TestBookings_Delete_NotFound(t *testing.T) { } } +// 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) { + cleanup := setupTestDB(t) + defer cleanup() + + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + 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 + delReq := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": false} + w = makeRequest(handler, "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 != 3 { + t.Errorf("expected deposits=3 after no-show penalty, got %d", deposits) + } +} + +// TestBookings_Delete_NoShow_WithForgiveness tests that admin can forgive a no-show +// by passing forgive_no_show=true, which prevents the deposit penalty. +func TestBookings_Delete_NoShow_WithForgiveness(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + 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 + delReq := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": trueVal} + w = makeRequest(handler, "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 // ============================================================================= @@ -1064,7 +1192,7 @@ func TestBookings_Unauthorized(t *testing.T) { } defer fixtures.DeleteUser(db.DB, userID) - // Set deposits_required=0 to avoid 48h advance booking requirement + // 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) @@ -1181,7 +1309,7 @@ func TestBookings_List_Empty(t *testing.T) { } defer fixtures.DeleteUser(db.DB, userID) - // Set deposits_required=0 to avoid 48h advance booking requirement + // 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) @@ -1223,7 +1351,7 @@ func TestBookings_Get_InvalidBookingID(t *testing.T) { } defer fixtures.DeleteUser(db.DB, userID) - // Set deposits_required=0 to avoid 48h advance booking requirement + // 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) @@ -1252,7 +1380,7 @@ func TestBookings_Create_PastDate(t *testing.T) { } defer fixtures.DeleteUser(db.DB, userID) - // Set deposits_required=0 to avoid 48h advance booking requirement + // 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) @@ -1280,10 +1408,9 @@ func TestBookings_Create_PastDate(t *testing.T) { } } -// TestBookings_Create_Within48HourDepositRequired tests that when a booking -// is made within 48 hours and the user has deposits_required > 0, the booking -// should have deposit_required=true. With deposits_required=0, no deposit needed. -func TestBookings_Create_Within48HourDepositRequired(t *testing.T) { +// 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) { cleanup := setupTestDB(t) defer cleanup() @@ -1296,7 +1423,7 @@ func TestBookings_Create_Within48HourDepositRequired(t *testing.T) { } defer fixtures.DeleteUser(db.DB, userID) - // Set deposits_required=0 to avoid 48h advance booking requirement + // 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) @@ -1310,13 +1437,13 @@ func TestBookings_Create_Within48HourDepositRequired(t *testing.T) { token := jwt.GenerateUserToken(userID) - // Use 10:00 to ensure service fits within working hours (08:00-20:00) - within48h := time.Now().Add(24 * time.Hour).Truncate(time.Second) - within48h = time.Date(within48h.Year(), within48h.Month(), within48h.Day(), 10, 0, 0, 0, within48h.Location()) - req := CreateBookingRequest{ - StartTime: within48h, - ServiceIDs: []string{serviceID}, - } + // Test booking 1+ hour in advance - should succeed + aheadTime := time.Now().Add(2 * 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) @@ -1335,6 +1462,146 @@ func TestBookings_Create_Within48HourDepositRequired(t *testing.T) { } } +// 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) { + cleanup := setupTestDB(t) + defer cleanup() + + // 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(2 * 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) { + cleanup := setupTestDB(t) + defer cleanup() + + // 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(2 * 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) { + cleanup := setupTestDB(t) + defer cleanup() + + // 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. diff --git a/backend/handlers/scheduling/time_blockers_test.go b/backend/handlers/scheduling/time_blockers_test.go index 3b01251..2e98615 100644 --- a/backend/handlers/scheduling/time_blockers_test.go +++ b/backend/handlers/scheduling/time_blockers_test.go @@ -587,6 +587,90 @@ func TestGetTimeBlockersInRange_IncludesRecurring(t *testing.T) { } } +// TestCleanupOldReservations verifies that reservation blockers older than 1 hour +// are automatically deleted, while recent ones are kept. +func TestCleanupOldReservations(t *testing.T) { + cleanup := setupTimeBlockersTestDB(t) + defer cleanup() + + ctx := context.Background() + ukLocation, _ := time.LoadLocation("Europe/London") + + // Create old reservation (> 1 hour old) + oldTime := time.Now().Add(-2 * time.Hour).In(ukLocation) + old_id, err := db.DB.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, $2, 'user-123')`, oldTime, "RESERVATION:user-123:timestamp-old") + if err != nil { + t.Fatalf("failed to create old reservation: %v", err) + } + + // Create recent reservation (< 1 hour old) + recentTime := time.Now().Add(-30 * time.Minute).In(ukLocation) + recent_id, err := db.DB.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, $2, 'user-456')`, recentTime, "RESERVATION:user-456:timestamp-recent") + if err != nil { + t.Fatalf("failed to create recent reservation: %v", err) + } + + // Create non-reservation blocker (should never be deleted) + nonResTime := time.Now().Add(-2 * time.Hour).In(ukLocation) + _, err = db.DB.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, $2, NULL)`, nonResTime, "Admin Blocked Time") + if err != nil { + t.Fatalf("failed to create non-reservation blocker: %v", err) + } + + // Verify we have 3 blockers before cleanup + var countBefore int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers").Scan(&countBefore) + if err != nil { + t.Fatalf("failed to count blockers before cleanup: %v", err) + } + if countBefore != 3 { + t.Errorf("expected 3 blockers before cleanup, got %d", countBefore) + } + + // Run cleanup + err = CleanupOldReservations(ctx) + if err != nil { + t.Fatalf("CleanupOldReservations failed: %v", err) + } + + // Verify old reservation was deleted + var oldCount int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:%' AND start_time = $1", oldTime).Scan(&oldCount) + if err == nil && oldCount > 0 { + t.Error("expected old reservation to be deleted") + } + + // Verify recent reservation still exists + var recentCount int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:%' AND start_time = $1", recentTime).Scan(&recentCount) + if err != nil || recentCount == 0 { + t.Error("expected recent reservation to still exist") + } + + // Verify non-reservation blocker still exists + var nonResCount int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'Admin Blocked Time'").Scan(&nonResCount) + if err != nil || nonResCount == 0 { + t.Error("expected non-reservation blocker to still exist") + } + + // Verify final count (should be 2: recent reservation + non-reservation blocker) + var countAfter int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers").Scan(&countAfter) + if err != nil { + t.Fatalf("failed to count blockers after cleanup: %v", err) + } + if countAfter != 2 { + t.Errorf("expected 2 blockers after cleanup (1 old deletion), got %d", countAfter) + } +} + // Ensure pool is used to avoid unused import error var _ = pgxpool.Pool{} var _ = bytes.Buffer{}