//go:build test && dev // +build test,dev package bookings // Package bookings contains tests for admin booking reservation endpoints. // // Test Coverage: // - AdminReserveSlotHandler: POST /api/admin/bookings/reserve - Admin slot reservation (walk-in or call-in) // // Tests cover walk-in and call-in reservation types, validation, slot overlaps, and replacement behavior. import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "crussell/db" "crussell/testutils" "crussell/mw" "crussell/testutils/fixtures" "github.com/go-chi/chi/v5" ) // makeAdminReserveRequest creates a request with admin context for admin reserve slot handler // It sets mw.UserIDKey and mw.UserRoleKey to "admin" in the context func makeAdminReserveRequest(handler http.Handler, body interface{}, adminID string) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) req = httptest.NewRequest("POST", "/api/admin/bookings/reserve", bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest("POST", "/api/admin/bookings/reserve", nil) } rctx := chi.NewRouteContext() ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) ctx = context.WithValue(ctx, mw.UserIDKey, adminID) ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } // ============================================================================= // Walk-in Reservation Tests // ============================================================================= // TestAdminReserveSlot_WalkIn_Success tests that an admin can successfully // create a walk-in reservation with a valid duration. The test verifies // the reservation is created in the database with the correct duration. func TestAdminReserveSlot_WalkIn_Success(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) tomorrow := time.Now().Add(24 * time.Hour) now := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 12, 0, 0, 0, tomorrow.Location()) req := AdminReserveSlotRequest{ ReservationType: "walkin", StartTime: now, DurationMinutes: 30, TTLMinutes: 15, } handler := http.HandlerFunc(AdminReserveSlotHandler) w := makeAdminReserveRequest(handler, req, adminID) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } // Parse response and verify duration var resp AdminReserveSlotResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp.DurationMinutes != 30 { t.Errorf("expected duration 30, got %d", resp.DurationMinutes) } // Verify time_blocker was created with correct description pattern var desc string err = db.DB.QueryRow(context.Background(), "SELECT description FROM time_blockers WHERE description LIKE 'RESERVATION:admin:walkin:%'", ).Scan(&desc) if err != nil { t.Errorf("failed to query time_blocker: %v", err) } if !strings.HasPrefix(desc, "RESERVATION:admin:walkin:") { t.Errorf("expected description to start with 'RESERVATION:admin:walkin:', got %s", desc) } } // ============================================================================= // Call-in Reservation Tests // ============================================================================= // TestAdminReserveSlot_CallIn_Success tests that an admin can successfully // create a call-in reservation with valid service IDs. The test verifies // the reservation duration matches the service duration. func TestAdminReserveSlot_CallIn_Success(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin: %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) _, err = db.DB.Exec(context.Background(), "UPDATE services SET duration_minutes = 30 WHERE id = $1", serviceID) if err != nil { t.Fatalf("failed to update service duration: %v", err) } tomorrow := time.Now().Add(24 * time.Hour).Truncate(time.Second) tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location()) req := AdminReserveSlotRequest{ UserID: &userID, ReservationType: "callin", StartTime: tomorrow, ServiceIDs: []string{serviceID}, TTLMinutes: 15, } handler := http.HandlerFunc(AdminReserveSlotHandler) w := makeAdminReserveRequest(handler, req, adminID) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } // Parse response and verify duration matches service (30 min) var resp AdminReserveSlotResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp.DurationMinutes != 30 { t.Errorf("expected duration 30, got %d", resp.DurationMinutes) } // Verify time_blocker was created with correct description pattern var desc string err = db.DB.QueryRow(context.Background(), "SELECT description FROM time_blockers WHERE description LIKE 'RESERVATION:admin:callin:%'", ).Scan(&desc) if err != nil { t.Errorf("failed to query time_blocker: %v", err) } if !strings.HasPrefix(desc, "RESERVATION:admin:callin:") { t.Errorf("expected description to start with 'RESERVATION:admin:callin:', got %s", desc) } } // ============================================================================= // Validation Tests - Missing Duration // ============================================================================= // TestAdminReserveSlot_WalkIn_MissingDuration tests that walk-in reservations // fail with HTTP 400 when duration_minutes is missing or zero. func TestAdminReserveSlot_WalkIn_MissingDuration(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) now := time.Now() req := AdminReserveSlotRequest{ ReservationType: "walkin", StartTime: now, TTLMinutes: 15, } handler := http.HandlerFunc(AdminReserveSlotHandler) w := makeAdminReserveRequest(handler, req, adminID) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } // Verify error message mentions duration_minutes body := w.Body.String() if !strings.Contains(body, "duration_minutes") { t.Errorf("expected body to contain 'duration_minutes', got %s", body) } } // ============================================================================= // Validation Tests - Missing Services // ============================================================================= // TestAdminReserveSlot_CallIn_MissingServices tests that call-in reservations // fail with HTTP 400 when service_ids is empty. func TestAdminReserveSlot_CallIn_MissingServices(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) tomorrow := time.Now().Add(24 * time.Hour).Truncate(time.Second) tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location()) req := AdminReserveSlotRequest{ ReservationType: "callin", StartTime: tomorrow, ServiceIDs: []string{}, TTLMinutes: 15, } handler := http.HandlerFunc(AdminReserveSlotHandler) w := makeAdminReserveRequest(handler, req, adminID) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } // Verify error message mentions service body := w.Body.String() if !strings.Contains(body, "service") { t.Errorf("expected body to contain 'service', got %s", body) } } // ============================================================================= // Validation Tests - Invalid Reservation Type // ============================================================================= // TestAdminReserveSlot_InvalidReservationType tests that reservations // fail with HTTP 400 when reservation_type is invalid. func TestAdminReserveSlot_InvalidReservationType(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) req := AdminReserveSlotRequest{ ReservationType: "invalid", StartTime: time.Now(), DurationMinutes: 30, } handler := http.HandlerFunc(AdminReserveSlotHandler) w := makeAdminReserveRequest(handler, req, adminID) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } // Verify error message mentions valid types body := w.Body.String() if !strings.Contains(body, "walkin") && !strings.Contains(body, "callin") { t.Errorf("expected body to contain 'walkin' or 'callin', got %s", body) } } // ============================================================================= // Slot Overlap Tests // ============================================================================= // TestAdminReserveSlot_SlotOverlap tests that a reservation fails // with HTTP 409 when the slot overlaps with an existing booking. func TestAdminReserveSlot_SlotOverlap(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) // Create test admin user (for the booking) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) // Create test regular user userID, err := fixtures.CreateTestUser(db.DB) if err != nil { t.Fatalf("failed to create test user: %v", err) } defer fixtures.DeleteUser(db.DB, userID) 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 deposits_required=0 for test user _, 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) } tomorrow := time.Now().Add(24 * time.Hour).Truncate(time.Second) tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location()) 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 start_time = $1, status = 'confirmed' WHERE id = $2", tomorrow, bookingID) if err != nil { t.Fatalf("failed to update booking time: %v", err) } // Now try to reserve a call-in that overlaps (tomorrow 10:15 - 15 min after start) overlapTime := tomorrow.Add(15 * time.Minute) req := AdminReserveSlotRequest{ ReservationType: "callin", StartTime: overlapTime, ServiceIDs: []string{serviceID}, TTLMinutes: 15, } handler := http.HandlerFunc(AdminReserveSlotHandler) w := makeAdminReserveRequest(handler, req, adminID) // Should return 409 Conflict due to overlap if w.Code != http.StatusConflict { t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // Reservation Replacement Tests // ============================================================================= // TestAdminReserveSlot_ReplacesExisting tests that reserving twice // on the same admin replaces the previous reservation. func TestAdminReserveSlot_ReplacesExisting(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) tomorrow := time.Now().Add(24 * time.Hour) now := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 12, 0, 0, 0, tomorrow.Location()) req := AdminReserveSlotRequest{ ReservationType: "walkin", StartTime: now, DurationMinutes: 30, TTLMinutes: 15, } // First reservation handler := http.HandlerFunc(AdminReserveSlotHandler) w := makeAdminReserveRequest(handler, req, adminID) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var firstResp AdminReserveSlotResponse if err := json.Unmarshal(w.Body.Bytes(), &firstResp); err != nil { t.Fatalf("failed to parse first response: %v", err) } // Count reservations before second request var countBefore int var qerr error qerr = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%'", ).Scan(&countBefore) if qerr != nil { t.Fatalf("failed to count reservations: %v", qerr) } // Second reservation (same admin, new time) laterTime := now.Add(1 * time.Hour) req2 := AdminReserveSlotRequest{ ReservationType: "walkin", StartTime: laterTime, DurationMinutes: 45, TTLMinutes: 15, } w = makeAdminReserveRequest(handler, req2, adminID) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var secondResp AdminReserveSlotResponse if err := json.Unmarshal(w.Body.Bytes(), &secondResp); err != nil { t.Fatalf("failed to parse second response: %v", err) } // Count reservations after second request - should still be 1 var countAfter int qerr = db.DB.QueryRow(context.Background(), "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%'", ).Scan(&countAfter) if qerr != nil { t.Fatalf("failed to count reservations: %v", qerr) } // Old one should be deleted, new one exists (id should be different) if countAfter != 1 { t.Errorf("expected 1 reservation after replacement, got %d", countAfter) } if firstResp.ID == secondResp.ID { t.Errorf("expected new reservation ID to be different from old one") } } // ============================================================================= // Past Start Time Tests // ============================================================================= // TestAdminReserveSlot_WalkIn_PastStart tests that walk-in reservations func TestAdminReserveSlot_WalkIn_PastStart(t *testing.T) { testutils.SetupTestDB(t) seedDefaultWorkingHours(t) adminID, err := fixtures.CreateTestAdminUser(db.DB) if err != nil { t.Fatalf("failed to create admin: %v", err) } defer fixtures.DeleteUser(db.DB, adminID) pastTime := time.Now().Add(-5 * time.Minute) req := AdminReserveSlotRequest{ ReservationType: "walkin", StartTime: pastTime, DurationMinutes: 30, TTLMinutes: 15, } handler := http.HandlerFunc(AdminReserveSlotHandler) w := makeAdminReserveRequest(handler, req, adminID) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } // Verify error message mentions the past time limit body := w.Body.String() if !strings.Contains(body, "past") { t.Errorf("expected body to contain 'past', got %s", body) } }