Files
Crussell/backend/handlers/bookings/bookings_test.go
T
2026-03-01 16:05:02 +00:00

2122 lines
63 KiB
Go

//go:build test
// +build test
package bookings
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"crussell/db"
"crussell/mw"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"crussell/testutils/testdb"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/lib/pq"
)
// setupTestDB replaces the global db.DB with a test pool and returns a cleanup function
func setupTestDB(t *testing.T) func() {
t.Helper()
pool := testdb.Pool(t)
testdb.Migrate(t, pool)
testdb.TruncateTables(t, pool) // Clear data between tests
// Replace global db.DB with test pool
originalDB := db.DB
db.DB = pool
// Initialize JWT for tests
jwt.Init()
return func() {
db.DB = originalDB
pool.Close()
}
}
// 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
// =============================================================================
func TestBookings_Create(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Create test user and service
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// Generate token for user
token := jwt.GenerateUserToken(userID)
// Create booking request - use future time to avoid 48h deposit requirement
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
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)
}
}
func TestBookings_Create_InvalidInput(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Create test user
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
token := jwt.GenerateUserToken(userID)
tests := []struct {
name string
req CreateBookingRequest
}{
{
name: "missing start time",
req: CreateBookingRequest{
ServiceIDs: []string{"some-service-id"},
},
},
{
name: "missing service IDs",
req: CreateBookingRequest{
StartTime: time.Now().Add(72 * time.Hour),
},
},
{
name: "empty service IDs",
req: CreateBookingRequest{
StartTime: time.Now().Add(72 * time.Hour),
ServiceIDs: []string{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", tt.req, token)
// Both missing start time and missing/empty service IDs should return 400
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
})
}
}
// =============================================================================
// List Bookings Tests
// =============================================================================
func TestBookings_List(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Create test user and service
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// Create a booking
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(GetAllUserBookingsHandler)
w := makeRequest(handler, "GET", "/api/bookings", nil, token)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 1 {
t.Errorf("expected 1 booking, got %d", len(resp.Bookings))
}
if resp.Total != 1 {
t.Errorf("expected total 1, got %d", resp.Total)
}
}
func TestBookings_List_FilterByStatus(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Create test user and service
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// Create a pending booking
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
token := jwt.GenerateUserToken(userID)
// Test filtering by status
handler := http.HandlerFunc(GetAllUserBookingsHandler)
w := makeRequest(handler, "GET", "/api/bookings?status=pending", nil, token)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 1 {
t.Errorf("expected 1 booking, got %d", len(resp.Bookings))
}
// Test filtering by non-matching status
w = makeRequest(handler, "GET", "/api/bookings?status=completed", nil, token)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
if err := parseResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 0 {
t.Errorf("expected 0 bookings for completed status, got %d", len(resp.Bookings))
}
}
// =============================================================================
// Get Single Booking Tests
// =============================================================================
func TestBookings_Get(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Create test user and service
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// Create a booking
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(GetBookingHandler)
w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var booking Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if booking.ID != bookingID {
t.Errorf("expected booking ID %s, got %s", bookingID, booking.ID)
}
if len(booking.Services) != 1 {
t.Errorf("expected 1 service, got %d", len(booking.Services))
}
}
func TestBookings_Get_NotFound(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Create test user
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(GetBookingHandler)
w := makeRequest(handler, "GET", "/api/bookings/nonexistent-id", nil, token)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d", w.Code)
}
}
func TestBookings_Get_AccessDenied(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Create two test users
userID1, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user 1: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID1)
userID2, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user 2: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID2)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// Create a booking for user1
bookingID, err := fixtures.CreateTestBooking(db.DB, userID1, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Try to access with user2's token
token := jwt.GenerateUserToken(userID2)
handler := http.HandlerFunc(GetBookingHandler)
w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token)
// Should return not found (or access denied) since user2 doesn't own the booking
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d", w.Code)
}
}
// =============================================================================
// Get Calendar Tests
// =============================================================================
func TestBookings_GetCalendar(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Create test user and service
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// Create a booking
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(GetBookingCalendarHandler)
w := makeRequest(handler, "GET", "/api/bookings/"+bookingID+"/calendar", nil, token)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Check that Content-Type is text/calendar
contentType := w.Header().Get("Content-Type")
if contentType != "text/calendar; charset=utf-8" {
t.Errorf("expected Content-Type text/calendar, got %s", contentType)
}
// Check that response contains ICS data
body := w.Body.String()
if len(body) == 0 {
t.Error("expected non-empty ICS response")
}
// Basic ICS validation
if !bytes.Contains([]byte(body), []byte("BEGIN:VCALENDAR")) {
t.Error("expected ICS to contain BEGIN:VCALENDAR")
}
if !bytes.Contains([]byte(body), []byte("BEGIN:VEVENT")) {
t.Error("expected ICS to contain BEGIN:VEVENT")
}
}
func TestBookings_GetCalendar_NotFound(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Create test user
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(GetBookingCalendarHandler)
w := makeRequest(handler, "GET", "/api/bookings/nonexistent-id/calendar", nil, token)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d", w.Code)
}
}
// =============================================================================
// Edit Booking Tests
// =============================================================================
func TestBookings_Edit(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Create test user and service
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// Create a booking
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
token := jwt.GenerateUserToken(userID)
// Update to a future time
newStartTime := time.Now().Add(96 * time.Hour).Truncate(time.Second)
req := EditBookingRequest{
StartTime: newStartTime,
}
handler := http.HandlerFunc(EditBookingHandler)
w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var booking Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Errorf("failed to parse response: %v", err)
}
// Verify the start time was updated in DB
var dbStartTime time.Time
err = db.DB.QueryRow(context.Background(),
"SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&dbStartTime)
if err != nil {
t.Errorf("failed to query booking: %v", err)
}
// Times should match (truncated to seconds)
if !dbStartTime.Truncate(time.Second).Equal(newStartTime) {
t.Errorf("expected start_time %v, got %v", newStartTime, dbStartTime)
}
}
func TestBookings_Edit_InvalidInput(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Create test user and service
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// Create a booking
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
token := jwt.GenerateUserToken(userID)
tests := []struct {
name string
req EditBookingRequest
}{
{
name: "missing start time",
req: EditBookingRequest{},
},
{
name: "past start time",
req: EditBookingRequest{
StartTime: time.Now().Add(-1 * time.Hour),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := http.HandlerFunc(EditBookingHandler)
w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, tt.req, token)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
})
}
}
func TestBookings_Edit_NotFound(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Create test user
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
token := jwt.GenerateUserToken(userID)
req := EditBookingRequest{
StartTime: time.Now().Add(96 * time.Hour),
}
handler := http.HandlerFunc(EditBookingHandler)
w := makeRequest(handler, "PUT", "/api/bookings/nonexistent-id", req, token)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d", w.Code)
}
}
// =============================================================================
// Delete Booking Tests
// =============================================================================
func TestBookings_Delete(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Create test user and service
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// Create a booking (without payments)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
token := jwt.GenerateUserToken(userID)
// Delete the booking
handler := http.HandlerFunc(DeleteBookingHandler)
w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Verify booking was deleted from DB
var count int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM bookings WHERE id = $1", bookingID).Scan(&count)
if err != nil {
t.Errorf("failed to query bookings: %v", err)
}
if count != 0 {
t.Errorf("expected booking to be deleted, but found %d", count)
}
}
func TestBookings_Delete_WithReason(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Create test user and service
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
// Create a booking
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Add a payment to the booking (so it requires a reason)
_, err = db.DB.Exec(context.Background(),
"INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)",
bookingID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
token := jwt.GenerateUserToken(userID)
// Try to delete without reason - should fail
handler := http.HandlerFunc(DeleteBookingHandler)
w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for missing reason, got %d", w.Code)
}
// Delete with reason
req := map[string]string{"reason": "client_cancelled"}
w = makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, req, token)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Verify booking status was updated (not hard deleted)
var status string
err = db.DB.QueryRow(context.Background(),
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil {
t.Errorf("failed to query booking: %v", err)
}
if status != "client_cancelled" {
t.Errorf("expected status client_cancelled, got %s", status)
}
}
func TestBookings_Delete_NotFound(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Create test user
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(DeleteBookingHandler)
w := makeRequest(handler, "DELETE", "/api/bookings/nonexistent-id", nil, token)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d", w.Code)
}
}
// =============================================================================
// Unauthorized Tests
// =============================================================================
func TestBookings_Unauthorized(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Create test user and service
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
tests := []struct {
name string
method string
path string
body interface{}
}{
{
name: "Create without token",
method: "POST",
path: "/api/bookings",
body: CreateBookingRequest{ServiceIDs: []string{serviceID}},
},
{
name: "List without token",
method: "GET",
path: "/api/bookings",
body: nil,
},
{
name: "Get without token",
method: "GET",
path: "/api/bookings/" + bookingID,
body: nil,
},
{
name: "GetCalendar without token",
method: "GET",
path: "/api/bookings/" + bookingID + "/calendar",
body: nil,
},
{
name: "Edit without token",
method: "PUT",
path: "/api/bookings/" + bookingID,
body: EditBookingRequest{StartTime: time.Now().Add(96 * time.Hour)},
},
{
name: "Delete without token",
method: "DELETE",
path: "/api/bookings/" + bookingID,
body: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Get the appropriate handler
var handler http.HandlerFunc
switch tt.path {
case "/api/bookings":
if tt.method == "POST" {
handler = CreateBookingHandler
} else {
handler = GetAllUserBookingsHandler
}
case "/api/bookings/" + bookingID:
if tt.method == "GET" {
handler = GetBookingHandler
} else if tt.method == "PUT" {
handler = EditBookingHandler
} else {
handler = DeleteBookingHandler
}
case "/api/bookings/" + bookingID + "/calendar":
handler = GetBookingCalendarHandler
}
w := makeRequest(http.HandlerFunc(handler), tt.method, tt.path, tt.body, "")
// GetCalendar returns 404 when no auth because handler checks booking first
expectedStatus := http.StatusUnauthorized
if tt.path == "/api/bookings/"+bookingID+"/calendar" {
expectedStatus = http.StatusNotFound
}
if w.Code != expectedStatus {
t.Errorf("expected status %d, got %d", expectedStatus, w.Code)
}
})
}
}
// =============================================================================
// Additional Edge Case Tests
// =============================================================================
func TestBookings_List_Empty(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Create test user (with no bookings)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(GetAllUserBookingsHandler)
w := makeRequest(handler, "GET", "/api/bookings", nil, token)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 0 {
t.Errorf("expected 0 bookings, got %d", len(resp.Bookings))
}
if resp.Total != 0 {
t.Errorf("expected total 0, got %d", resp.Total)
}
}
func TestBookings_Get_InvalidBookingID(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Create test user
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(GetBookingHandler)
w := makeRequest(handler, "GET", "/api/bookings/", nil, token) // trailing slash
// Should return 404 or 400 depending on routing
if w.Code != http.StatusNotFound && w.Code != http.StatusBadRequest {
t.Errorf("expected status 404 or 400, got %d", w.Code)
}
}
func TestBookings_Create_PastDate(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
token := jwt.GenerateUserToken(userID)
pastTime := time.Now().Add(-24 * time.Hour).Truncate(time.Second)
req := CreateBookingRequest{
StartTime: pastTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", req, token)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for past date, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestBookings_Create_Within48HourDepositRequired(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
token := jwt.GenerateUserToken(userID)
within48h := time.Now().Add(24 * time.Hour).Truncate(time.Second)
req := CreateBookingRequest{
StartTime: within48h,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", req, token)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var booking Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if booking.DepositRequired {
t.Error("expected deposit_required=false when user has deposits_required=0")
}
}
func TestBookings_Create_MultipleServices(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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)
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
req := CreateBookingRequest{
StartTime: futureTime,
ServiceIDs: []string{serviceID1, serviceID2},
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", req, token)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var booking Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if len(booking.Services) != 2 {
t.Errorf("expected 2 services in booking, got %d", len(booking.Services))
}
}
// Ensure test compilation - import pgxpool to avoid unused import
var _ = func() *pgxpool.Pool { return nil }
// Import mw to avoid unused import
var _ = mw.UserIDKey
// =============================================================================
// Auth and Security Tests
// =============================================================================
func TestBookings_Get_NoAuthHeader(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Make request WITHOUT token (empty string passed as token parameter)
handler := http.HandlerFunc(GetBookingHandler)
w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, "")
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401 for missing auth, got %d", w.Code)
}
}
// =============================================================================
// Calendar Export Tests - ICS Format Validation
// =============================================================================
func TestBookings_GetCalendar_ValidICS(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(GetBookingCalendarHandler)
w := makeRequest(handler, "GET", "/api/bookings/"+bookingID+"/calendar", nil, token)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
body := w.Body.String()
// Validate ICS structure is present
if !bytes.Contains(w.Body.Bytes(), []byte("BEGIN:VCALENDAR")) {
t.Error("ICS response missing BEGIN:VCALENDAR")
}
if !bytes.Contains(w.Body.Bytes(), []byte("END:VCALENDAR")) {
t.Error("ICS response missing END:VCALENDAR")
}
if !bytes.Contains(w.Body.Bytes(), []byte("BEGIN:VEVENT")) {
t.Error("ICS response missing BEGIN:VEVENT")
}
if !bytes.Contains(w.Body.Bytes(), []byte("END:VEVENT")) {
t.Error("ICS response missing END:VEVENT")
}
// Validate basic ICS properties
if !bytes.Contains(w.Body.Bytes(), []byte("DTSTART")) {
t.Error("ICS response missing DTSTART")
}
if !bytes.Contains(w.Body.Bytes(), []byte("DTEND")) {
t.Error("ICS response missing DTEND")
}
if !bytes.Contains(w.Body.Bytes(), []byte("SUMMARY")) {
t.Error("ICS response missing SUMMARY")
}
if len(body) == 0 {
t.Error("expected non-empty ICS response")
}
}
// =============================================================================
// Cancellation and Notification Tests
// =============================================================================
func TestUserCancelBooking_ConfirmedCreatesNotification(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// First, confirm the booking (so it's not pending)
_, err = db.DB.Exec(context.Background(),
"UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Add a payment to trigger soft delete path (bookings with payments use soft delete)
_, err = db.DB.Exec(context.Background(), `
INSERT INTO payments (id, booking_id, payment_type, payment_method, amount, status, created_at)
VALUES ($1, $2, 'deposit', 'in_person_card', 50.00, 'completed', NOW())
`, bookingID[:8]+"pay", bookingID)
if err != nil {
t.Fatalf("failed to add payment: %v", err)
}
token := jwt.GenerateUserToken(userID)
// Cancel the confirmed booking with a reason (required for soft delete)
handler := http.HandlerFunc(DeleteBookingHandler)
reqBody := map[string]string{"reason": "client_cancelled"}
w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, reqBody, token)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Verify admin notification was created
var notifCount int
err = db.DB.QueryRow(context.Background(),
`SELECT COUNT(*) FROM admin_notifications
WHERE booking_id = $1 AND reason = 'cancelled_booking'`,
bookingID).Scan(&notifCount)
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)
}
}
func TestUserCancelBooking_PendingNoNotification(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Verify booking is in 'pending' status
var status string
err = db.DB.QueryRow(context.Background(),
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if status != "pending" {
t.Fatalf("expected booking status 'pending', got %s", status)
}
token := jwt.GenerateUserToken(userID)
// Cancel the pending booking
handler := http.HandlerFunc(DeleteBookingHandler)
w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Verify NO admin notification was created (pending cancellations don't notify)
var notifCount int
err = db.DB.QueryRow(context.Background(),
`SELECT COUNT(*) FROM admin_notifications
WHERE booking_id = $1 AND reason = 'cancelled_booking'`,
bookingID).Scan(&notifCount)
if err != nil {
t.Errorf("failed to query notifications: %v", err)
}
if notifCount != 0 {
t.Errorf("expected NO admin notification for pending->cancelled, got %d", notifCount)
}
}
// =============================================================================
// Transaction and Error Handling Tests
// =============================================================================
// TestUserCancelBooking_TransactionIntegrity verifies that if any part of the
// cancellation transaction fails, the booking status is NOT changed (rollback behavior)
func TestUserCancelBooking_TransactionIntegrity(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Confirm the booking
_, err = db.DB.Exec(context.Background(),
"UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Add a payment to trigger soft delete path (bookings with payments use soft delete)
_, err = db.DB.Exec(context.Background(), `
INSERT INTO payments (id, booking_id, payment_type, payment_method, amount, status, created_at)
VALUES ($1, $2, 'deposit', 'in_person_card', 50.00, 'completed', NOW())
`, bookingID[:8]+"pay", bookingID)
if err != nil {
t.Fatalf("failed to add payment: %v", err)
}
// Verify initial state
var statusBefore string
err = db.DB.QueryRow(context.Background(),
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&statusBefore)
if statusBefore != "confirmed" {
t.Fatalf("expected status 'confirmed' before cancel, got %s", statusBefore)
}
token := jwt.GenerateUserToken(userID)
// Cancel the booking with a reason (required for soft delete)
handler := http.HandlerFunc(DeleteBookingHandler)
reqBody := map[string]string{"reason": "client_cancelled"}
w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, reqBody, token)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Verify that status WAS changed (successful transaction commit)
var statusAfter string
err = db.DB.QueryRow(context.Background(),
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&statusAfter)
if statusAfter == "confirmed" {
t.Error("booking status should have changed after cancellation (transaction should have committed)")
}
}
// TestCreateEditRequest tests that creating an edit request creates an admin notification
func TestCreateEditRequest(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Confirm the booking
_, err = db.DB.Exec(context.Background(),
"UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
token := jwt.GenerateUserToken(userID)
// Create edit request
handler := http.HandlerFunc(RequestEditHandler)
reqBody := map[string]interface{}{
"notes": "Please change the time",
}
w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token)
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
t.Errorf("expected status 200/201, got %d. body: %s", w.Code, w.Body.String())
}
// Verify edit request was created
var erCount int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount)
if err != nil {
t.Fatalf("failed to query edit requests: %v", err)
}
if erCount != 1 {
t.Errorf("expected 1 edit request, got %d", erCount)
}
// Verify admin notification was created
var notifCount int
err = db.DB.QueryRow(context.Background(),
`SELECT COUNT(*) FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request' AND acknowledged_at IS NULL`,
bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query notifications: %v", err)
}
if notifCount != 1 {
t.Errorf("expected 1 unacknowledged admin notification, got %d", notifCount)
}
}
// TestDeleteEditRequest tests that user deleting their edit request deletes the admin notification
func TestDeleteEditRequest(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Confirm the booking
_, err = db.DB.Exec(context.Background(),
"UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create edit request directly in DB (simulating user request)
var editRequestID string
err = db.DB.QueryRow(context.Background(),
`INSERT INTO booking_edit_requests (booking_id, requested_by, notes)
VALUES ($1, $2, 'Please change time')
RETURNING id`,
bookingID, userID).Scan(&editRequestID)
if err != nil {
t.Fatalf("failed to create edit request: %v", err)
}
// Create admin notification
_, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $1, $2)`,
bookingID, userID)
if err != nil {
t.Fatalf("failed to create admin notification: %v", err)
}
token := jwt.GenerateUserToken(userID)
// Delete edit request (user cancels their request)
handler := http.HandlerFunc(DeleteEditRequestHandler)
w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, token)
if w.Code != http.StatusNoContent {
t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String())
}
// Verify edit request was deleted
var erCount int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&erCount)
if err != nil {
t.Fatalf("failed to query edit requests: %v", err)
}
if erCount != 0 {
t.Errorf("expected 0 edit requests after delete, got %d", erCount)
}
// Verify admin notification was DELETED (not acknowledged)
var notifCount int
err = db.DB.QueryRow(context.Background(),
`SELECT COUNT(*) FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'`,
bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query notifications: %v", err)
}
if notifCount != 0 {
t.Errorf("expected 0 admin notifications after delete, got %d", notifCount)
}
}
// TestAdminApproveEditRequest tests that admin approving acknowledges the notification (not deletes)
func TestAdminApproveEditRequest(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Confirm the booking
_, err = db.DB.Exec(context.Background(),
"UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create edit request directly in DB (need pq.Array for PostgreSQL array)
var editRequestID string
newTime := time.Now().Add(24 * time.Hour).Truncate(time.Minute)
var emptyServices []string
err = db.DB.QueryRow(context.Background(),
`INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes)
VALUES ($1, $2, $3, $4, 'Please change time')
RETURNING id`,
bookingID, userID, newTime, pq.Array(&emptyServices)).Scan(&editRequestID)
if err != nil {
t.Fatalf("failed to create edit request: %v", err)
}
if err != nil {
t.Fatalf("failed to create edit request: %v", err)
}
// Create admin notification
_, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $1, $2)`,
bookingID, userID)
if err != nil {
t.Fatalf("failed to create admin notification: %v", err)
}
// Verify notification starts as unacknowledged
var ackTime *time.Time
err = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'`,
bookingID).Scan(&ackTime)
if err != nil {
t.Fatalf("failed to query notification: %v", err)
}
if ackTime != nil {
t.Fatalf("expected notification to be unacknowledged initially")
}
// Simulate admin approval
adminToken := jwt.GenerateAdminToken()
// Create request with chi context
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
rctx.URLParams.Add("request_id", editRequestID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
AdminApproveEditRequestHandler(w, req)
if w.Code != http.StatusNoContent && w.Code != http.StatusOK {
t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String())
}
// Verify edit request was deleted (approved)
var erCount int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&erCount)
if err != nil {
t.Fatalf("failed to query edit requests: %v", err)
}
if erCount != 0 {
t.Errorf("expected 0 edit requests after approve, got %d", erCount)
}
// Verify admin notification was ACKNOWLEDGED (not deleted) - history preserved
var ackTimeAfter *time.Time
err = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'`,
bookingID).Scan(&ackTimeAfter)
if err != nil {
t.Fatalf("failed to query notification: %v", err)
}
if ackTimeAfter == nil {
t.Errorf("expected notification to be acknowledged after approve, but acknowledged_at is still NULL")
}
}
// TestAdminRejectEditRequest tests that admin rejecting acknowledges the notification (not deletes)
func TestAdminRejectEditRequest(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Confirm the booking
_, err = db.DB.Exec(context.Background(),
"UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create edit request directly in DB
var editRequestID string
err = db.DB.QueryRow(context.Background(),
`INSERT INTO booking_edit_requests (booking_id, requested_by, notes)
VALUES ($1, $2, 'Please change time')
RETURNING id`,
bookingID, userID).Scan(&editRequestID)
if err != nil {
t.Fatalf("failed to create edit request: %v", err)
}
// Create admin notification
_, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $1, $2)`,
bookingID, userID)
if err != nil {
t.Fatalf("failed to create admin notification: %v", err)
}
// Verify notification starts as unacknowledged
var ackTime *time.Time
err = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'`,
bookingID).Scan(&ackTime)
if err != nil {
t.Fatalf("failed to query notification: %v", err)
}
if ackTime != nil {
t.Fatalf("expected notification to be unacknowledged initially")
}
// Simulate admin denial
adminToken := jwt.GenerateAdminToken()
// Create request with chi context
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/deny", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
rctx.URLParams.Add("request_id", editRequestID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
AdminRejectEditRequestHandler(w, req)
if w.Code != http.StatusNoContent && w.Code != http.StatusOK {
t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String())
}
// Verify edit request was deleted (rejected)
var erCount int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&erCount)
if err != nil {
t.Fatalf("failed to query edit requests: %v", err)
}
if erCount != 0 {
t.Errorf("expected 0 edit requests after reject, got %d", erCount)
}
// Verify admin notification was ACKNOWLEDGED (not deleted) - history preserved
var ackTimeAfter *time.Time
err = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'`,
bookingID).Scan(&ackTimeAfter)
if err != nil {
t.Fatalf("failed to query notification: %v", err)
}
if ackTimeAfter == nil {
t.Errorf("expected notification to be acknowledged after reject, but acknowledged_at is still NULL")
}
}
// TestBookings_RequestEdit_BookingNotFound tests that requesting an edit for a non-existent booking returns 404
func TestBookings_RequestEdit_BookingNotFound(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Create test user
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(RequestEditHandler)
reqBody := map[string]interface{}{
"notes": "Please change the time",
}
w := makeRequest(handler, "POST", "/api/bookings/nonexistent-booking-id/edit-request", reqBody, token)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d", w.Code)
}
}
// TestBookings_RequestEdit_AlreadyHasPending tests that a user cannot create a second edit request while one already exists
func TestBookings_RequestEdit_AlreadyHasPending(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Set deposits_required=0 to avoid 48h advance booking requirement
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Confirm the booking
_, err = db.DB.Exec(context.Background(),
"UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create a pending edit request directly in DB (pre-condition)
_, err = db.DB.Exec(context.Background(),
`INSERT INTO booking_edit_requests (booking_id, requested_by, notes)
VALUES ($1, $2, 'Please change the time')`,
bookingID, userID)
if err != nil {
t.Fatalf("failed to create initial edit request: %v", err)
}
// Create admin notification for the initial edit request
_, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $1, $2)`,
bookingID, userID)
if err != nil {
t.Fatalf("failed to create admin notification: %v", err)
}
token := jwt.GenerateUserToken(userID)
// Try to create another edit request via API
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 400 Bad Request or 409 Conflict
if w.Code != http.StatusBadRequest && w.Code != http.StatusConflict {
t.Errorf("expected status 400 or 409, got %d. body: %s", w.Code, w.Body.String())
}
// Verify only 1 edit request exists in DB (the original one)
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)
}
}