Files
Crussell/backend/handlers/bookings/admin_reserve_test.go
T
popertots 88ee265603 refactor: remove auto deposit penalty on no-shows, add comprehensive tests
- Remove automatic deposits_required=3 on no-shows, give admin flexibility
- Add tests for no-show deposit logic (forgiven, over 24h, under 24h)
- Add tests for reservation cleanup TTL (admin walk-in/call-in 15min)
- Add tests for EXIF GPS data stripping in portfolio images
- Add tests for contact info endpoint
- Add tests for guest account anonymization
2026-05-03 15:59:40 +01:00

508 lines
16 KiB
Go

//go:build test
// +build test
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/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) {
cleanup := setupTestDB(t)
defer cleanup()
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,
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) {
cleanup := setupTestDB(t)
defer cleanup()
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) {
cleanup := setupTestDB(t)
defer cleanup()
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) {
cleanup := setupTestDB(t)
defer cleanup()
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) {
cleanup := setupTestDB(t)
defer cleanup()
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) {
cleanup := setupTestDB(t)
defer cleanup()
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) {
cleanup := setupTestDB(t)
defer cleanup()
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,
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())
}
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) {
cleanup := setupTestDB(t)
defer cleanup()
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)
}
}