2805 lines
90 KiB
Go
2805 lines
90 KiB
Go
//go:build test
|
|
// +build test
|
|
|
|
package bookings
|
|
|
|
// Package bookings contains tests for user-facing booking endpoints.
|
|
//
|
|
// Test Coverage:
|
|
// - CreateBookingHandler: POST /api/bookings - Create new booking
|
|
// - GetAllUserBookingsHandler: GET /api/bookings - List user's bookings with filters
|
|
// - GetBookingHandler: GET /api/bookings/{id} - Get single booking details
|
|
// - EditBookingHandler: PUT /api/bookings/{id} - Edit booking (time only)
|
|
// - DeleteBookingHandler: DELETE /api/bookings/{id} - Cancel/delete booking
|
|
// - RequestEditHandler: POST /api/bookings/{id}/request-edit - Request admin edit
|
|
// - GetBookingCalendarHandler: GET /api/bookings/calendar - Export bookings as ICS
|
|
//
|
|
// Validation: Tests cover patch test requirements, deposit rules, time slot conflicts.
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"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()
|
|
}
|
|
}
|
|
|
|
// seedDefaultWorkingHours seeds default working hours for tests
|
|
func seedDefaultWorkingHours(t *testing.T) {
|
|
t.Helper()
|
|
|
|
// Seed 7 days of working hours (Monday=0 to Sunday=6)
|
|
// Use wide hours to avoid test failures due to business logic time checks
|
|
hours := []struct {
|
|
weekday int
|
|
startTime string
|
|
endTime string
|
|
isOpen bool
|
|
}{
|
|
{0, "08:00", "20:00", true}, // Monday
|
|
{1, "08:00", "20:00", true}, // Tuesday
|
|
{2, "08:00", "20:00", true}, // Wednesday
|
|
{3, "08:00", "20:00", true}, // Thursday
|
|
{4, "08:00", "20:00", true}, // Friday
|
|
{5, "08:00", "20:00", true}, // Saturday
|
|
{6, "08:00", "20:00", true}, // Sunday
|
|
}
|
|
|
|
for _, h := range hours {
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (weekday) DO UPDATE SET start_time = $2, end_time = $3, is_open = $4
|
|
`, h.weekday, h.startTime, h.endTime, h.isOpen)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed working hours: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// helper function to make JSON request with JWT auth
|
|
// For authenticated requests, use makeAuthRequest which extracts user from JWT
|
|
func makeRequest(handler http.Handler, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
|
|
return makeAuthRequest(handler, method, path, body, token, "")
|
|
}
|
|
|
|
// makeAuthRequest creates request with optional JWT auth and userID override
|
|
func makeAuthRequest(handler http.Handler, method, path string, body interface{}, token, userIDOverride string) *httptest.ResponseRecorder {
|
|
var req *http.Request
|
|
if body != nil {
|
|
bodyBytes, _ := json.Marshal(body)
|
|
req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
} else {
|
|
req = httptest.NewRequest(method, path, nil)
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
|
|
// Set up chi routing context for path params
|
|
rctx := chi.NewRouteContext()
|
|
if id, paramName := extractIDFromPath(path); id != "" {
|
|
rctx.URLParams.Add(paramName, id)
|
|
}
|
|
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
|
|
// Set user context - either from override or attempt to extract from token
|
|
var userID, userRole string
|
|
if userIDOverride != "" {
|
|
userID = userIDOverride
|
|
userRole = "verified_email"
|
|
} else if token != "" {
|
|
// For test JWTs, extract user info from token by parsing it
|
|
// Use JWT secret to parse
|
|
if info := extractUserFromTestJWT(token); info != nil {
|
|
userID = info.userID
|
|
userRole = info.role
|
|
}
|
|
}
|
|
|
|
if userID != "" {
|
|
ctx = context.WithValue(ctx, mw.UserIDKey, userID)
|
|
ctx = context.WithValue(ctx, mw.UserRoleKey, userRole)
|
|
}
|
|
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
// userInfo holds extracted user from JWT
|
|
type userInfo struct {
|
|
userID string
|
|
role string
|
|
}
|
|
|
|
// extractUserFromTestJWT extracts user info from test JWT
|
|
func extractUserFromTestJWT(token string) *userInfo {
|
|
// Parse the JWT without verification for tests
|
|
// JWT format: header.payload.signature
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
return nil
|
|
}
|
|
|
|
// Decode the payload (middle part)
|
|
payload := parts[1]
|
|
// Add padding if needed
|
|
if len(payload)%4 != 0 {
|
|
payload += strings.Repeat("=", 4-len(payload)%4)
|
|
}
|
|
|
|
decoded, err := base64URLDecode(payload)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Parse JSON to get claims
|
|
var claims map[string]interface{}
|
|
if err := json.Unmarshal(decoded, &claims); err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Extract user_id (not "sub" - auth.GenerateToken uses "user_id")
|
|
userID, _ := claims["user_id"].(string)
|
|
role, _ := claims["role"].(string)
|
|
|
|
if userID == "" {
|
|
return nil
|
|
}
|
|
|
|
return &userInfo{userID: userID, role: role}
|
|
}
|
|
|
|
func base64URLDecode(s string) ([]byte, error) {
|
|
return base64.URLEncoding.DecodeString(s)
|
|
}
|
|
|
|
// extractIDFromPath extracts the ID from URL paths
|
|
func extractIDFromPath(path string) (string, string) {
|
|
patterns := []struct {
|
|
prefix string
|
|
paramName string
|
|
}{
|
|
{"/api/bookings/", "id"},
|
|
{"/api/admin/bookings/", "id"},
|
|
{"/api/services/", "id"},
|
|
}
|
|
for _, p := range patterns {
|
|
if idx := findLastSegment(path, p.prefix); idx >= 0 {
|
|
// Extract only up to next '/' or end of path
|
|
endIdx := len(path)
|
|
for i := idx; i < len(path); i++ {
|
|
if path[i] == '/' {
|
|
endIdx = i
|
|
break
|
|
}
|
|
}
|
|
return path[idx:endIdx], p.paramName
|
|
}
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
func findLastSegment(path, prefix string) int {
|
|
for i := len(path) - 1; i >= len(prefix); i-- {
|
|
if len(path) > i && path[i-len(prefix):i] == prefix {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// Helper to parse response body
|
|
func parseResponseBody(w *httptest.ResponseRecorder, dest interface{}) error {
|
|
return json.Unmarshal(w.Body.Bytes(), dest)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Create Booking Tests
|
|
// =============================================================================
|
|
|
|
// TestBookings_Create tests that a user can successfully create a new booking
|
|
// with a valid future time and at least one service. The test verifies the
|
|
// booking is created in the database and associated with the correct user.
|
|
func TestBookings_Create(t *testing.T) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
// Seed working hours for booking tests
|
|
seedDefaultWorkingHours(t)
|
|
|
|
// Create test user and service
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
// 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
|
|
// Use 10:00 to ensure service fits within working hours (08:00-20:00)
|
|
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
|
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
|
|
req := CreateBookingRequest{
|
|
StartTime: futureTime,
|
|
ServiceIDs: []string{serviceID},
|
|
}
|
|
|
|
handler := http.HandlerFunc(CreateBookingHandler)
|
|
w := makeRequest(handler, "POST", "/api/bookings", req, token)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify booking was created in DB
|
|
var count int
|
|
err = db.DB.QueryRow(context.Background(),
|
|
"SELECT COUNT(*) FROM bookings WHERE user_id = $1", userID).Scan(&count)
|
|
if err != nil {
|
|
t.Errorf("failed to query bookings: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected 1 booking, got %d", count)
|
|
}
|
|
|
|
// Verify booking_services was created
|
|
err = db.DB.QueryRow(context.Background(),
|
|
"SELECT COUNT(*) FROM booking_services WHERE booking_id IN (SELECT id FROM bookings WHERE user_id = $1)", userID).Scan(&count)
|
|
if err != nil {
|
|
t.Errorf("failed to query booking_services: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected 1 booking_service, got %d", count)
|
|
}
|
|
}
|
|
|
|
// TestBookings_Create_InvalidInput verifies that booking creation fails
|
|
// with HTTP 400 when required fields are missing: start time or service IDs.
|
|
func TestBookings_Create_InvalidInput(t *testing.T) {
|
|
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
|
|
// =============================================================================
|
|
|
|
// TestBookings_List tests that a user can retrieve their list of bookings.
|
|
// The test verifies the response includes the correct total count and that
|
|
// bookings are properly returned.
|
|
func TestBookings_List(t *testing.T) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestBookings_List_FilterByStatus tests that booking list can be filtered
|
|
// by status (e.g., pending, completed). It verifies that non-matching statuses
|
|
// return empty results.
|
|
func TestBookings_List_FilterByStatus(t *testing.T) {
|
|
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
|
|
// =============================================================================
|
|
|
|
// TestBookings_Get tests that a user can retrieve a single booking by its ID.
|
|
// The test verifies the booking details including services are returned.
|
|
func TestBookings_Get(t *testing.T) {
|
|
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))
|
|
}
|
|
}
|
|
|
|
// TestBookings_Get_NotFound verifies that requesting a non-existent booking
|
|
// returns HTTP 404 Not Found.
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestBookings_Get_AccessDenied tests that a user cannot access another user's
|
|
// booking. The test creates two users, one creates a booking, and the other
|
|
// attempts to access it - expecting HTTP 404 (not found/access denied).
|
|
func TestBookings_Get_AccessDenied(t *testing.T) {
|
|
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
|
|
// =============================================================================
|
|
|
|
// TestBookings_GetCalendar tests that a user can export their booking
|
|
// as an ICS calendar file. It verifies the response has the correct
|
|
// text/calendar Content-Type and contains ICS-formatted data.
|
|
func TestBookings_GetCalendar(t *testing.T) {
|
|
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")
|
|
}
|
|
}
|
|
|
|
// TestBookings_GetCalendar_NotFound verifies that attempting to export
|
|
// a non-existent booking to calendar returns HTTP 404.
|
|
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
|
|
// =============================================================================
|
|
|
|
// TestBookings_Edit tests that a user can modify the start time of
|
|
// their existing booking. The test verifies the time is updated in the DB.
|
|
func TestBookings_Edit(t *testing.T) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestBookings_Edit_InvalidInput verifies that editing fails with HTTP 400
|
|
// when the start time is missing or is in the past.
|
|
func TestBookings_Edit_InvalidInput(t *testing.T) {
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBookings_Edit_NotFound verifies that editing a non-existent
|
|
// booking returns HTTP 404.
|
|
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
|
|
// =============================================================================
|
|
|
|
// TestBookings_Delete tests that a user can delete (cancel) their booking.
|
|
// For bookings without payments, it performs a hard delete. The test verifies
|
|
// the booking is removed from the database.
|
|
func TestBookings_Delete(t *testing.T) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestBookings_Delete_WithReason verifies that cancelling a booking with
|
|
// an associated payment requires a reason (client_cancelled). Without a reason,
|
|
// the request fails with HTTP 400. With a reason, the booking is soft-deleted
|
|
// (status changed to client_cancelled).
|
|
func TestBookings_Delete_WithReason(t *testing.T) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestBookings_Delete_NotFound verifies that deleting a non-existent
|
|
// booking returns HTTP 404.
|
|
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
|
|
// =============================================================================
|
|
|
|
// TestBookings_Unauthorized tests that all booking endpoints require
|
|
// authentication. It verifies that requests without a token are rejected with
|
|
// HTTP 401 for protected endpoints.
|
|
func TestBookings_Unauthorized(t *testing.T) {
|
|
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
|
|
// =============================================================================
|
|
|
|
// TestBookings_List_Empty tests that listing bookings for a user with no
|
|
// bookings returns an empty list with total 0.
|
|
func TestBookings_List_Empty(t *testing.T) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestBookings_Get_InvalidBookingID verifies that using an invalid
|
|
// booking ID format returns HTTP 404 or 400.
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestBookings_Create_PastDate verifies that creating a booking with a
|
|
// past start time fails with HTTP 400 Bad Request.
|
|
func TestBookings_Create_PastDate(t *testing.T) {
|
|
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())
|
|
}
|
|
}
|
|
|
|
// TestBookings_Create_Within48HourDepositRequired tests that when a booking
|
|
// is made within 48 hours and the user has deposits_required > 0, the booking
|
|
// should have deposit_required=true. With deposits_required=0, no deposit needed.
|
|
func TestBookings_Create_Within48HourDepositRequired(t *testing.T) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
// Seed working hours for booking tests
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
// Set deposits_required=0 to avoid 48h advance booking requirement
|
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set deposits_required: %v", err)
|
|
}
|
|
|
|
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)
|
|
|
|
// Use 10:00 to ensure service fits within working hours (08:00-20:00)
|
|
within48h := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
|
within48h = time.Date(within48h.Year(), within48h.Month(), within48h.Day(), 10, 0, 0, 0, within48h.Location())
|
|
req := CreateBookingRequest{
|
|
StartTime: within48h,
|
|
ServiceIDs: []string{serviceID},
|
|
}
|
|
|
|
handler := http.HandlerFunc(CreateBookingHandler)
|
|
w := makeRequest(handler, "POST", "/api/bookings", req, token)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var booking Booking
|
|
if err := parseResponseBody(w, &booking); err != nil {
|
|
t.Errorf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if booking.DepositRequired {
|
|
t.Error("expected deposit_required=false when user has deposits_required=0")
|
|
}
|
|
}
|
|
|
|
// TestBookings_Create_MultipleServices verifies that a booking can include
|
|
// multiple services at once, and all services are properly associated with
|
|
// the booking in the database.
|
|
func TestBookings_Create_MultipleServices(t *testing.T) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
// Seed working hours for booking tests
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
// Set deposits_required=0 to avoid 48h advance booking requirement
|
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set deposits_required: %v", err)
|
|
}
|
|
|
|
serviceID1, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service 1: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID1)
|
|
|
|
serviceID2, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service 2: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID2)
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
// Use 10:00 to ensure services fit within working hours (08:00-20:00)
|
|
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
|
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
|
|
req := CreateBookingRequest{
|
|
StartTime: futureTime,
|
|
ServiceIDs: []string{serviceID1, serviceID2},
|
|
}
|
|
|
|
handler := http.HandlerFunc(CreateBookingHandler)
|
|
w := makeRequest(handler, "POST", "/api/bookings", req, token)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var booking Booking
|
|
if err := parseResponseBody(w, &booking); err != nil {
|
|
t.Errorf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if len(booking.Services) != 2 {
|
|
t.Errorf("expected 2 services in booking, got %d", len(booking.Services))
|
|
}
|
|
}
|
|
|
|
// Ensure test compilation - import pgxpool to avoid unused import
|
|
var _ = func() *pgxpool.Pool { return nil }
|
|
|
|
// Import mw to avoid unused import
|
|
var _ = mw.UserIDKey
|
|
|
|
// =============================================================================
|
|
// Auth and Security Tests
|
|
// =============================================================================
|
|
|
|
// TestBookings_Get_NoAuthHeader confirms that accessing a booking without
|
|
// an Authorization header returns HTTP 401 Unauthorized.
|
|
func TestBookings_Get_NoAuthHeader(t *testing.T) {
|
|
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
|
|
// =============================================================================
|
|
|
|
// TestBookings_GetCalendar_ValidICS validates that the ICS calendar export
|
|
// contains all required fields: BEGIN:VCALENDAR, END:VCALENDAR, BEGIN:VEVENT,
|
|
// END:VEVENT, DTSTART, DTEND, and SUMMARY.
|
|
func TestBookings_GetCalendar_ValidICS(t *testing.T) {
|
|
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
|
|
// =============================================================================
|
|
|
|
// TestUserCancelBooking_ConfirmedCreatesNotification verifies that when a
|
|
// user cancels a confirmed booking (one with payments), an admin notification
|
|
// is created to alert staff of the cancellation.
|
|
func TestUserCancelBooking_ConfirmedCreatesNotification(t *testing.T) {
|
|
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(¬ifCount)
|
|
if err != nil {
|
|
t.Errorf("failed to query notifications: %v", err)
|
|
}
|
|
if notifCount != 1 {
|
|
t.Errorf("expected 1 admin notification for confirmed->cancelled, got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// TestUserCancelBooking_PendingNoNotification verifies that cancelling a
|
|
// pending booking (one without payments) does NOT create an admin notification,
|
|
// as pending cancellations don't require staff attention.
|
|
func TestUserCancelBooking_PendingNoNotification(t *testing.T) {
|
|
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(¬ifCount)
|
|
if err != nil {
|
|
t.Errorf("failed to query notifications: %v", err)
|
|
}
|
|
if notifCount != 0 {
|
|
t.Errorf("expected NO admin notification for pending->cancelled, got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Transaction and Error Handling Tests
|
|
// =============================================================================
|
|
|
|
// TestUserCancelBooking_TransactionIntegrity verifies that if any part of the
|
|
// cancellation transaction fails, the booking status is NOT changed (rollback behavior)
|
|
func TestUserCancelBooking_TransactionIntegrity(t *testing.T) {
|
|
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 verifies that a user can request an edit to their
|
|
// confirmed booking (e.g., change time). This creates a booking_edit_request record
|
|
// and generates an admin notification for staff review.
|
|
func TestCreateEditRequest(t *testing.T) {
|
|
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(¬ifCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query notifications: %v", err)
|
|
}
|
|
if notifCount != 1 {
|
|
t.Errorf("expected 1 unacknowledged admin notification, got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// 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(¬ifCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query notifications: %v", err)
|
|
}
|
|
if notifCount != 0 {
|
|
t.Errorf("expected 0 admin notifications after delete, got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// TestAdminApproveEditRequest tests that admin approving acknowledges the notification (not deletes)
|
|
func TestAdminApproveEditRequest(t *testing.T) {
|
|
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
|
|
// Note: The handler actually replaces (upserts) the existing request, not rejects it
|
|
handler := http.HandlerFunc(RequestEditHandler)
|
|
reqBody := map[string]interface{}{
|
|
"notes": "Please change to a different day",
|
|
}
|
|
w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token)
|
|
|
|
// Expect HTTP 201 Created (handler replaces existing request)
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify only 1 edit request exists in DB (the old one was replaced)
|
|
var erCount int
|
|
err = db.DB.QueryRow(context.Background(),
|
|
"SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query edit requests: %v", err)
|
|
}
|
|
if erCount != 1 {
|
|
t.Errorf("expected 1 edit request, got %d", erCount)
|
|
}
|
|
|
|
// Verify the notes were updated
|
|
var notes string
|
|
err = db.DB.QueryRow(context.Background(),
|
|
"SELECT notes FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(¬es)
|
|
if err != nil {
|
|
t.Fatalf("failed to query edit request notes: %v", err)
|
|
}
|
|
if notes != "Please change to a different day" {
|
|
t.Errorf("expected notes 'Please change to a different day', got '%s'", notes)
|
|
}
|
|
}
|
|
|
|
|
|
// =============================================================================
|
|
// Patch Test Validation Tests
|
|
// =============================================================================
|
|
|
|
// TestBookings_Create_PatchTestRequired_NoRecord verifies that a user without a patch test record
|
|
// cannot book a service that requires a patch test. The booking should be rejected with 400.
|
|
func TestBookings_Create_PatchTestRequired_NoRecord(t *testing.T) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
seedDefaultWorkingHours(t)
|
|
|
|
// Create user and service with patch test requirement
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
// Set deposits_required=0 to avoid 48h requirement
|
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set deposits_required: %v", err)
|
|
}
|
|
|
|
serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service with patch test: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
_ = patchTestID // We don't delete patch tests, they cascade with service
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
// Try to book service requiring patch test - user has no patch test record
|
|
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
|
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
|
|
req := CreateBookingRequest{
|
|
StartTime: futureTime,
|
|
ServiceIDs: []string{serviceID},
|
|
}
|
|
|
|
handler := http.HandlerFunc(CreateBookingHandler)
|
|
w := makeRequest(handler, "POST", "/api/bookings", req, token)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400 for missing patch test, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
if !bytes.Contains(w.Body.Bytes(), []byte("patch test")) {
|
|
t.Errorf("expected error message about patch test, got: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestBookings_Create_PatchTestRequired_WithinNoticePeriod verifies that a user
|
|
// cannot book within the notice period after completing a patch test (e.g., 24h wait).
|
|
func TestBookings_Create_PatchTestRequired_WithinNoticePeriod(t *testing.T) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set deposits_required: %v", err)
|
|
}
|
|
|
|
serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service with patch test: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
// Create patch test record with tested_at only 1 hour ago (notice is 24h)
|
|
testedAt := time.Now().Add(-1 * time.Hour).Format("2006-01-02 15:04:05")
|
|
err = fixtures.CreateUserPatchTest(db.DB, userID, patchTestID, testedAt)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user patch test: %v", err)
|
|
}
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
// Try to book within notice period (24h required, but only 1h passed)
|
|
futureTime := time.Now().Add(12 * time.Hour).Truncate(time.Second)
|
|
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
|
|
req := CreateBookingRequest{
|
|
StartTime: futureTime,
|
|
ServiceIDs: []string{serviceID},
|
|
}
|
|
|
|
handler := http.HandlerFunc(CreateBookingHandler)
|
|
w := makeRequest(handler, "POST", "/api/bookings", req, token)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400 for within notice period, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
if !bytes.Contains(w.Body.Bytes(), []byte("wait")) {
|
|
t.Errorf("expected error message about waiting, got: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestBookings_Create_PatchTestRequired_Expired verifies that a user
|
|
// with an expired patch test cannot book services requiring patch test.
|
|
func TestBookings_Create_PatchTestRequired_Expired(t *testing.T) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set deposits_required: %v", err)
|
|
}
|
|
|
|
serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service with patch test: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
// Create patch test record from 7 months ago (expiry is 6 months)
|
|
testedAt := time.Now().AddDate(0, -7, 0).Format("2006-01-02 15:04:05")
|
|
err = fixtures.CreateUserPatchTest(db.DB, userID, patchTestID, testedAt)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user patch test: %v", err)
|
|
}
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
// Try to book with expired patch test
|
|
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
|
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
|
|
req := CreateBookingRequest{
|
|
StartTime: futureTime,
|
|
ServiceIDs: []string{serviceID},
|
|
}
|
|
|
|
handler := http.HandlerFunc(CreateBookingHandler)
|
|
w := makeRequest(handler, "POST", "/api/bookings", req, token)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400 for expired patch test, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
if !bytes.Contains(w.Body.Bytes(), []byte("expired")) {
|
|
t.Errorf("expected error message about expiry, got: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestBookings_Create_PatchTestRequired_ValidRecord verifies that a user
|
|
// with a valid patch test record can successfully book services requiring patch test.
|
|
func TestBookings_Create_PatchTestRequired_ValidRecord(t *testing.T) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set deposits_required: %v", err)
|
|
}
|
|
|
|
serviceID, patchTestID, err := fixtures.CreateTestServiceWithPatchTest(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service with patch test: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
// Create patch test record from 48 hours ago (notice is 24h, so valid now)
|
|
testedAt := time.Now().Add(-48 * time.Hour).Format("2006-01-02 15:04:05")
|
|
err = fixtures.CreateUserPatchTest(db.DB, userID, patchTestID, testedAt)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user patch test: %v", err)
|
|
}
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
// Book with valid patch test record (after notice period)
|
|
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
|
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
|
|
req := CreateBookingRequest{
|
|
StartTime: futureTime,
|
|
ServiceIDs: []string{serviceID},
|
|
}
|
|
|
|
handler := http.HandlerFunc(CreateBookingHandler)
|
|
w := makeRequest(handler, "POST", "/api/bookings", req, token)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected status 201 for valid patch test, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify booking was created
|
|
var count int
|
|
err = db.DB.QueryRow(context.Background(),
|
|
"SELECT COUNT(*) FROM bookings WHERE user_id = $1", userID).Scan(&count)
|
|
if err != nil {
|
|
t.Errorf("failed to query bookings: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected 1 booking, got %d", count)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Deposit Requirement Tests
|
|
// =============================================================================
|
|
|
|
// TestBookings_Create_DepositRequired_Within48Hours verifies that a user with deposits_required > 0
|
|
// cannot book within 48 hours notice. They must complete more appointments to remove this restriction.
|
|
func TestBookings_Create_DepositRequired_Within48Hours(t *testing.T) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
// Set deposits_required=3 to trigger 48h advance booking requirement
|
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set deposits_required: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
// Try to book within 48 hours (should be blocked)
|
|
within48h := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
|
within48h = time.Date(within48h.Year(), within48h.Month(), within48h.Day(), 10, 0, 0, 0, within48h.Location())
|
|
req := CreateBookingRequest{
|
|
StartTime: within48h,
|
|
ServiceIDs: []string{serviceID},
|
|
}
|
|
|
|
handler := http.HandlerFunc(CreateBookingHandler)
|
|
w := makeRequest(handler, "POST", "/api/bookings", req, token)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400 for within 48h booking with deposit requirement, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
if !bytes.Contains(w.Body.Bytes(), []byte("48 hours")) {
|
|
t.Errorf("expected error message about 48 hours, got: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestBookings_Create_DepositRequired_After48Hours verifies that a user with deposits_required > 0
|
|
// CAN book if the start time is at least 48 hours in the future.
|
|
func TestBookings_Create_DepositRequired_After48Hours(t *testing.T) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
// Set deposits_required=3 to trigger 48h advance booking requirement
|
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set deposits_required: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
// Book more than 48 hours in advance (should succeed)
|
|
after48h := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
|
after48h = time.Date(after48h.Year(), after48h.Month(), after48h.Day(), 10, 0, 0, 0, after48h.Location())
|
|
req := CreateBookingRequest{
|
|
StartTime: after48h,
|
|
ServiceIDs: []string{serviceID},
|
|
}
|
|
|
|
handler := http.HandlerFunc(CreateBookingHandler)
|
|
w := makeRequest(handler, "POST", "/api/bookings", req, token)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected status 201 for booking after 48h, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify booking was created
|
|
var count int
|
|
err = db.DB.QueryRow(context.Background(),
|
|
"SELECT COUNT(*) FROM bookings WHERE user_id = $1", userID).Scan(&count)
|
|
if err != nil {
|
|
t.Errorf("failed to query bookings: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected 1 booking, got %d", count)
|
|
}
|
|
}
|
|
|
|
// TestBookings_Create_NoDepositRequired_Within48Hours verifies that a user with deposits_required=0
|
|
// can book at any time (no 48h restriction).
|
|
func TestBookings_Create_NoDepositRequired_Within48Hours(t *testing.T) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
// deposits_required=0 means no 48h restriction
|
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set deposits_required: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
// Book within 48 hours (should succeed since no deposit required)
|
|
within48h := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
|
within48h = time.Date(within48h.Year(), within48h.Month(), within48h.Day(), 10, 0, 0, 0, within48h.Location())
|
|
req := CreateBookingRequest{
|
|
StartTime: within48h,
|
|
ServiceIDs: []string{serviceID},
|
|
}
|
|
|
|
handler := http.HandlerFunc(CreateBookingHandler)
|
|
w := makeRequest(handler, "POST", "/api/bookings", req, token)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected status 201 for booking within 48h with no deposit required, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Holiday/Closed Day Booking Tests
|
|
// =============================================================================
|
|
|
|
// TestBookings_Edit_ClosedDay_UserBlocked verifies that a regular user cannot edit a booking
|
|
// to fall on a closed day (exceptional hours marked as is_open=false).
|
|
func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set deposits_required: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
// Create exceptional hours group for holiday
|
|
var groupID string
|
|
err = db.DB.QueryRow(context.Background(),
|
|
"INSERT INTO exceptional_working_hours_groups (name, description) VALUES ($1, $2) RETURNING id",
|
|
"Holiday Closure", "Closed for holiday").Scan(&groupID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create exceptional hours group: %v", err)
|
|
}
|
|
|
|
// Calculate week start for the booking target date
|
|
targetDate := time.Now().Add(96 * time.Hour)
|
|
weekday := int(targetDate.Weekday())
|
|
daysToMonday := weekday
|
|
if daysToMonday == 0 {
|
|
daysToMonday = 7
|
|
}
|
|
weekStart := targetDate.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
|
|
|
|
// Apply group to this week
|
|
_, err = db.DB.Exec(context.Background(),
|
|
"INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2)",
|
|
groupID, weekStart)
|
|
if err != nil {
|
|
t.Fatalf("failed to apply exceptional hours group: %v", err)
|
|
}
|
|
|
|
// Create closed exceptional hours for that weekday
|
|
_, err = db.DB.Exec(context.Background(),
|
|
`INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
|
|
VALUES ($1, $2, '08:00:00', '20:00:00', false)`,
|
|
groupID, weekday)
|
|
if err != nil {
|
|
t.Fatalf("failed to create closed exceptional hours: %v", err)
|
|
}
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
// Try to edit booking to closed day
|
|
newStartTime := targetDate.Truncate(time.Second)
|
|
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 10, 0, 0, 0, newStartTime.Location())
|
|
req := EditBookingRequest{
|
|
StartTime: newStartTime,
|
|
}
|
|
|
|
handler := http.HandlerFunc(EditBookingHandler)
|
|
w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400 for closed day edit, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
if !bytes.Contains(w.Body.Bytes(), []byte("closed day")) {
|
|
t.Errorf("expected error message about closed day, got: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestBookings_Edit_OpenDay_UserAllowed verifies that a user CAN edit a booking
|
|
// to a day that is marked as open in exceptional hours.
|
|
func TestBookings_Edit_OpenDay_UserAllowed(t *testing.T) {
|
|
cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set deposits_required: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
// Create exceptional hours group with OPEN hours (is_open=true)
|
|
var groupID string
|
|
err = db.DB.QueryRow(context.Background(),
|
|
"INSERT INTO exceptional_working_hours_groups (name, description) VALUES ($1, $2) RETURNING id",
|
|
"Special Opening", "Extended hours").Scan(&groupID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create exceptional hours group: %v", err)
|
|
}
|
|
|
|
// Calculate week start for the booking target date
|
|
targetDate := time.Now().Add(96 * time.Hour)
|
|
weekday := int(targetDate.Weekday())
|
|
daysToMonday := weekday
|
|
if daysToMonday == 0 {
|
|
daysToMonday = 7
|
|
}
|
|
weekStart := targetDate.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
|
|
|
|
// Apply group to this week
|
|
_, err = db.DB.Exec(context.Background(),
|
|
"INSERT INTO exceptional_group_applications (group_id, week_start) VALUES ($1, $2)",
|
|
groupID, weekStart)
|
|
if err != nil {
|
|
t.Fatalf("failed to apply exceptional hours group: %v", err)
|
|
}
|
|
|
|
// Create OPEN exceptional hours for that weekday
|
|
_, err = db.DB.Exec(context.Background(),
|
|
`INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
|
|
VALUES ($1, $2, '08:00:00', '20:00:00', true)`,
|
|
groupID, weekday)
|
|
if err != nil {
|
|
t.Fatalf("failed to create open exceptional hours: %v", err)
|
|
}
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
// Edit booking to open day (should succeed)
|
|
newStartTime := targetDate.Truncate(time.Second)
|
|
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 10, 0, 0, 0, newStartTime.Location())
|
|
req := EditBookingRequest{
|
|
StartTime: newStartTime,
|
|
}
|
|
|
|
handler := http.HandlerFunc(EditBookingHandler)
|
|
w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200 for open day edit, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
} |