Files
Crussell/backend/handlers/bookings/bookings_test.go
T
popertots 6d4bc4d637 feat(loyalty-discount): implement loyalty and discount system
- Add discount campaign management and validation logic
- Update booking handlers with discount application flow
- Add customer relationship endpoints for loyalty tracking
- Update frontend modals (booking, approval, payment, reschedule)
- Add DiscountsManagement and loyalty reference documentation
- Update dev scripts and database init for discount tables
- Clean up completed plan files
2026-06-04 23:13:02 +01:00

5383 lines
175 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"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"crussell/db"
"crussell/handlers/user"
"crussell/internal/validators"
"crussell/mw"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"crussell/testutils/testdb"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
func resetTestData(t *testing.T) {
t.Helper()
testdb.TruncateTables(t, db.DB)
}
// 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)
}
}
}
// nextWorkingHour returns a time within working hours (08:00-19:00) that is
// always < 24h from now. This avoids time-of-day flakiness in tests that need
// a booking within the 24-hour no-show window. Working hours are 08:00-20:00;
// we cap at 19:00 so a 60-minute service finishes before closing.
func nextWorkingHour() time.Time {
now := time.Now()
soon := now.Add(2 * time.Hour).Truncate(time.Second)
if soon.Hour() < 8 {
return time.Date(soon.Year(), soon.Month(), soon.Day(), 9, 0, 0, 0, soon.Location())
}
if soon.Hour() >= 19 {
return time.Date(soon.Year(), soon.Month(), soon.Day()+1, 9, 0, 0, 0, soon.Location())
}
return soon
}
// 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) {
resetTestData(t)
// 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)
// Note: deposits_required=0 by default now (was 3)
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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 (1h+ advance is enforced for USERS only)
// 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) {
resetTestData(t)
// 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)
// Note: deposits_required=0 by default now
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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) {
resetTestData(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)
// Note: deposits_required=0 by default now
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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) {
resetTestData(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)
// Note: deposits_required=0 by default now
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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) {
resetTestData(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)
// Note: deposits_required=0 by default now
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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) {
resetTestData(t)
// 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)
// Note: deposits_required=0 by default now
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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) {
resetTestData(t)
// 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) {
resetTestData(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)
// Note: deposits_required=0 by default now
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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) {
resetTestData(t)
// 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)
// Note: deposits_required=0 by default now
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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) {
resetTestData(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)
// Note: deposits_required=0 by default now
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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) {
resetTestData(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)
// Note: deposits_required=0 by default now
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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) {
resetTestData(t)
// 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)
// Note: deposits_required=0 by default now
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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) {
resetTestData(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)
// Note: deposits_required=0 by default now
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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) {
resetTestData(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)
// Note: deposits_required=0 by default now
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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) {
resetTestData(t)
// 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)
// Note: deposits_required=0 by default now
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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)
}
}
// TestBookings_Delete_NoShow24hThreshold tests the 24-hour no-show rule:
// - Cancellation < 24 hours before appointment: treated as no-show (deposits = 3)
// - Cancellation >= 24 hours before appointment: treated as late_cancellation
func TestBookings_Delete_NoShow24hThreshold(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
token := jwt.GenerateUserToken(userID)
// Create booking 23 hours from now (will be < 24h when deleted)
soonTime := time.Now().Add(23 * time.Hour).Truncate(time.Second)
soonTime = time.Date(soonTime.Year(), soonTime.Month(), soonTime.Day(), 10, 0, 0, 0, soonTime.Location())
bookingReq := CreateBookingRequest{
StartTime: soonTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token)
var booking Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Fatalf("failed to parse booking response: %v", err)
}
// Add payment so deletion works
_, _ = db.DB.Exec(context.Background(),
"INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)",
booking.ID)
// Delete within 24 hours (no forgiveness) - should result in no-show + deposits penalty
delHandler := http.HandlerFunc(DeleteBookingHandler)
delReq := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": false}
w = makeRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token)
if w.Code != http.StatusOK && w.Code != http.StatusNoContent {
t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String())
}
// Verify deposits were applied
var deposits int
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits)
if err != nil {
t.Errorf("failed to query deposits: %v", err)
}
if deposits != 0 {
t.Errorf("expected deposits=0 (admin flexibility, not auto-applied), got %d", deposits)
}
}
// TestBookings_Delete_NoShow_WithForgiveness tests that admin can forgive a no-show
func TestBookings_Delete_NoShow_WithForgiveness(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
token := jwt.GenerateUserToken(userID)
// Create booking 20 hours from now (will be < 24h)
soonTime := time.Now().Add(20 * time.Hour).Truncate(time.Second)
soonTime = time.Date(soonTime.Year(), soonTime.Month(), soonTime.Day(), 10, 0, 0, 0, soonTime.Location())
bookingReq := CreateBookingRequest{
StartTime: soonTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token)
var booking Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Fatalf("failed to parse booking response: %v", err)
}
// Add payment
_, _ = db.DB.Exec(context.Background(),
"INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)",
booking.ID)
// Delete within 24 hours WITH forgiveness
trueVal := true
delHandler := http.HandlerFunc(DeleteBookingHandler)
delReq := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": trueVal}
w = makeRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token)
if w.Code != http.StatusOK && w.Code != http.StatusNoContent {
t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String())
}
// Verify NO deposits were applied (forgiveness worked)
var deposits int
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits)
if err != nil {
t.Errorf("failed to query deposits: %v", err)
}
if deposits != 0 {
t.Errorf("expected deposits=0 after forgiveness, got %d", deposits)
}
}
// =============================================================================
// Unauthorized Tests
// =============================================================================
// 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) {
resetTestData(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)
// Note: deposits_required=0 by default now
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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, "")
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
}
})
}
}
// =============================================================================
// 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) {
resetTestData(t)
// 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)
// Note: deposits_required=0 by default now
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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) {
resetTestData(t)
// 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)
// Note: deposits_required=0 by default now
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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) {
resetTestData(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)
// Note: deposits_required=0 by default now
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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_MinimumAdvance tests that bookings must be made at least
// 1 hour in advance (changed from 48h deposit requirement to universal 1h rule).
func TestBookings_Create_MinimumAdvance(t *testing.T) {
resetTestData(t)
// 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)
// Note: deposits_required=0 by default now
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
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)
// Test booking 1+ hour in advance - should succeed
aheadTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
aheadTime = time.Date(aheadTime.Year(), aheadTime.Month(), aheadTime.Day(), 10, 0, 0, 0, aheadTime.Location())
req := CreateBookingRequest{
StartTime: aheadTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", req, token)
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_WithNotes_StatusPending tests that when a booking is created with notes,
// the booking status is automatically set to 'pending' (requires admin approval).
func TestBookings_Create_WithNotes_StatusPending(t *testing.T) {
resetTestData(t)
// Seed working hours
seedDefaultWorkingHours(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
token := jwt.GenerateUserToken(userID)
// Booking 2+ hours ahead with notes
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
notes := "Special treatment needed"
req := CreateBookingRequest{
StartTime: futureTime,
ServiceIDs: []string{serviceID},
Notes: &notes,
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", req, token)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var booking Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if booking.Status != "pending" {
t.Errorf("expected status 'pending' when notes provided, got %q", booking.Status)
}
if booking.Notes == nil || *booking.Notes != notes {
t.Error("notes not preserved in booking")
}
}
// TestBookings_Create_WithoutNotes_StatusConfirmed tests that when a booking is created without notes,
// the booking status is automatically set to 'confirmed' (auto-approved).
func TestBookings_Create_WithoutNotes_StatusConfirmed(t *testing.T) {
resetTestData(t)
// Seed working hours
seedDefaultWorkingHours(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
token := jwt.GenerateUserToken(userID)
// Booking 2+ hours ahead without notes
futureTime := time.Now().Add(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},
Notes: nil,
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", req, token)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var booking Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if booking.Status != "confirmed" {
t.Errorf("expected status 'confirmed' without notes, got %q", booking.Status)
}
}
// TestBookings_Create_Within1Hour_ShouldFail tests that bookings less than 1 hour in advance are rejected.
func TestBookings_Create_Within1Hour_ShouldFail(t *testing.T) {
resetTestData(t)
// Seed working hours
seedDefaultWorkingHours(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
token := jwt.GenerateUserToken(userID)
// Booking less than 1 hour in advance (30 minutes)
sooonTime := time.Now().Add(30 * time.Minute).Truncate(time.Second)
req := CreateBookingRequest{
StartTime: sooonTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", req, token)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestBookings_Create_MultipleServices verifies that a booking can include
// multiple services at once, and all services are properly associated with
// the booking in the database.
func TestBookings_Create_MultipleServices(t *testing.T) {
resetTestData(t)
// 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))
}
}
// =============================================================================
// No-Show Deposit Logic Tests (Task 5)
// =============================================================================
// TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3 tests that when a booking is
// deleted as no-show with less than 24 hours notice (and no forgiveness), the
// user's deposits_required is set to 3 and the booking status becomes "no_show".
func TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
// Create test user with deposits_required = 0
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Ensure 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)
token := jwt.GenerateUserToken(userID)
soonTime := nextWorkingHour()
bookingReq := CreateBookingRequest{
StartTime: soonTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token)
var booking Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Fatalf("failed to parse booking response: %v", err)
}
// Add a payment to the booking
_, 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)",
booking.ID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Delete booking with reason "no_show" and no forgiveness
delHandler := http.HandlerFunc(DeleteBookingHandler)
delReq := map[string]interface{}{"reason": "no_show", "forgive_no_show": false}
w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "")
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var deposits int
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits)
if err != nil {
t.Errorf("failed to query deposits: %v", err)
}
if deposits != 0 {
t.Errorf("expected deposits_required=0, got %d", deposits)
}
// Verify booking status = "no_show"
var status string
err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status)
if err != nil {
t.Errorf("failed to query booking status: %v", err)
}
if status != "no_show" {
t.Errorf("expected status 'no_show', got %s", status)
}
}
// TestDeleteBooking_CancelOver24h_NoDepositPenalty tests that when a booking is
// cancelled with more than 24 hours notice (client_cancelled), no deposit penalty
// is applied and deposits_required remains 0.
func TestDeleteBooking_CancelOver24h_NoDepositPenalty(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
// Create test user with deposits_required = 0
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Ensure 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)
token := jwt.GenerateUserToken(userID)
// Create booking with start_time = now + 48 hours (> 24h notice)
laterTime := time.Now().Add(48 * time.Hour).Truncate(time.Second)
laterTime = time.Date(laterTime.Year(), laterTime.Month(), laterTime.Day(), 10, 0, 0, 0, laterTime.Location())
bookingReq := CreateBookingRequest{
StartTime: laterTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token)
var booking Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Fatalf("failed to parse booking response: %v", err)
}
// Add a payment to the booking
_, 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)",
booking.ID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Delete booking with reason "client_cancelled" (>= 24h notice)
delHandler := http.HandlerFunc(DeleteBookingHandler)
delReq := map[string]interface{}{"reason": "client_cancelled"}
w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "")
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Verify deposits_required = 0 (no penalty)
var deposits int
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits)
if err != nil {
t.Errorf("failed to query deposits: %v", err)
}
if deposits != 0 {
t.Errorf("expected deposits_required=0, got %d", deposits)
}
// Verify booking status = "client_cancelled"
var status string
err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status)
if err != nil {
t.Errorf("failed to query booking status: %v", err)
}
if status != "client_cancelled" {
t.Errorf("expected status 'client_cancelled', got %s", status)
}
}
// TestDeleteBooking_NoShowWithForgiveness_NoPenalty tests that when a booking is
// deleted as no-show with forgiveness (forgive_no_show: true), no deposit penalty
// is applied and the booking status becomes "client_cancelled".
func TestDeleteBooking_NoShowWithForgiveness_NoPenalty(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
// Create test user with deposits_required = 0
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Ensure 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)
token := jwt.GenerateUserToken(userID)
soonTime := nextWorkingHour()
bookingReq := CreateBookingRequest{
StartTime: soonTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token)
var booking Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Fatalf("failed to parse booking response: %v", err)
}
// Add a payment to the booking
_, 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)",
booking.ID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Delete booking with reason "no_show" AND forgiveness
delHandler := http.HandlerFunc(DeleteBookingHandler)
delReq := map[string]interface{}{"reason": "no_show", "forgive_no_show": true}
w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "")
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Verify deposits_required = 0 (no penalty due to forgiveness)
var deposits int
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits)
if err != nil {
t.Errorf("failed to query deposits: %v", err)
}
if deposits != 0 {
t.Errorf("expected deposits_required=0 after forgiveness, got %d", deposits)
}
// Verify booking status = "client_cancelled" (not "no_show")
var status string
err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status)
if err != nil {
t.Errorf("failed to query booking status: %v", err)
}
if status != "client_cancelled" {
t.Errorf("expected status 'client_cancelled' with forgiveness, got %s", status)
}
}
// TestDeleteBooking_SecondNoShow_StaysAt3 tests that when a user has their second
// no-show, the deposits_required stays at 3 (not 6). The handler sets deposits to 3
// on the first no-show and doesn't increment on subsequent no-shows.
func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
// Create test user with deposits_required = 0
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Ensure 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)
token := jwt.GenerateUserToken(userID)
// === First booking: no-show ===
soonTime1 := nextWorkingHour()
bookingReq1 := CreateBookingRequest{
StartTime: soonTime1,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", bookingReq1, token)
var booking1 Booking
if err := parseResponseBody(w, &booking1); err != nil {
t.Fatalf("failed to parse booking response: %v", err)
}
// Add payment
_, _ = db.DB.Exec(context.Background(),
"INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)",
booking1.ID)
// Delete as no-show
delHandler := http.HandlerFunc(DeleteBookingHandler)
delReq1 := map[string]interface{}{"reason": "no_show", "forgive_no_show": false}
w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking1.ID, delReq1, token, "")
if w.Code != http.StatusOK {
t.Fatalf("first delete failed: %d body: %s", w.Code, w.Body.String())
}
// Verify deposits = 3 after first no-show
var deposits1 int
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits1)
if err != nil {
t.Errorf("failed to query deposits: %v", err)
}
if deposits1 != 0 {
t.Errorf("expected deposits=0 after first no-show, got %d", deposits1)
}
// === Second booking: no-show ===
soonTime2 := nextWorkingHour().AddDate(0, 0, 1)
bookingReq2 := CreateBookingRequest{
StartTime: soonTime2,
ServiceIDs: []string{serviceID},
}
w = makeRequest(handler, "POST", "/api/bookings", bookingReq2, token)
var booking2 Booking
if err := parseResponseBody(w, &booking2); err != nil {
t.Fatalf("failed to parse second booking response: %v", err)
}
// Add payment
_, _ = db.DB.Exec(context.Background(),
"INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)",
booking2.ID)
// Delete as no-show
delReq2 := map[string]interface{}{"reason": "no_show", "forgive_no_show": false}
w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking2.ID, delReq2, token, "")
if w.Code != http.StatusOK {
t.Fatalf("second delete failed: %d body: %s", w.Code, w.Body.String())
}
var deposits2 int
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits2)
if err != nil {
t.Errorf("failed to query deposits: %v", err)
}
if deposits2 != 0 {
t.Errorf("expected deposits=0, got %d", deposits2)
}
}
// TestCountUnforgivenNoShows_ExcludesForgiven tests that CountUnforgivenNoShows
// excludes bookings that have been forgiven (in forgiven_no_shows table).
func TestCountUnforgivenNoShows_ExcludesForgiven(t *testing.T) {
resetTestData(t)
// 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)
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 3 bookings with status "no_show" within last 6 months
now := time.Now()
for i := 0; i < 3; i++ {
startTime := now.Add(time.Duration(i*30) * 24 * time.Hour) // 0, 30, 60 days ago
var bookingID string
err := db.DB.QueryRow(context.Background(), `
INSERT INTO bookings (user_id, start_time, status, notes)
VALUES ($1, $2, 'no_show', 'test no-show')
RETURNING id
`, userID, startTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking %d: %v", i, err)
}
// Link service
_, err = db.DB.Exec(context.Background(),
"INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)",
bookingID, serviceID)
if err != nil {
t.Fatalf("failed to link service: %v", err)
}
// Forgive the first one (i == 0)
if i == 0 {
_, err = db.DB.Exec(context.Background(),
"INSERT INTO forgiven_no_shows (booking_id) VALUES ($1)",
bookingID)
if err != nil {
t.Fatalf("failed to forgive no-show: %v", err)
}
}
}
// Call CountUnforgivenNoShows directly (unexported but in same package)
count, err := CountUnforgivenNoShows(context.Background(), userID)
if err != nil {
t.Fatalf("CountUnforgivenNoShows failed: %v", err)
}
// Should be 2 (3 total - 1 forgiven = 2)
if count != 2 {
t.Errorf("expected count=2 (3 total - 1 forgiven), got %d", count)
}
}
// TestCountUnforgivenNoShows_ExcludesOld tests that CountUnforgivenNoShows
// excludes no-shows older than 6 months.
func TestCountUnforgivenNoShows_ExcludesOld(t *testing.T) {
resetTestData(t)
// 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)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
now := time.Now()
// Create 1 booking with status "no_show" from 7 months ago (should be excluded)
oldStartTime := now.Add(-7 * 30 * 24 * time.Hour)
var oldBookingID string
err = db.DB.QueryRow(context.Background(), `
INSERT INTO bookings (user_id, start_time, status, notes)
VALUES ($1, $2, 'no_show', 'old no-show')
RETURNING id
`, userID, oldStartTime).Scan(&oldBookingID)
if err != nil {
t.Fatalf("failed to create old booking: %v", err)
}
_, _ = db.DB.Exec(context.Background(),
"INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)",
oldBookingID, serviceID)
// Create 1 booking with status "no_show" from 1 month ago (should be included)
recentStartTime := now.Add(-30 * 24 * time.Hour)
var recentBookingID string
err = db.DB.QueryRow(context.Background(), `
INSERT INTO bookings (user_id, start_time, status, notes)
VALUES ($1, $2, 'no_show', 'recent no-show')
RETURNING id
`, userID, recentStartTime).Scan(&recentBookingID)
if err != nil {
t.Fatalf("failed to create recent booking: %v", err)
}
_, _ = db.DB.Exec(context.Background(),
"INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)",
recentBookingID, serviceID)
// Call CountUnforgivenNoShows directly
count, err := CountUnforgivenNoShows(context.Background(), userID)
if err != nil {
t.Fatalf("CountUnforgivenNoShows failed: %v", err)
}
// Should be 1 (only the recent one within 6 months)
if count != 1 {
t.Errorf("expected count=1 (only recent within 6 months), got %d", count)
}
}
// TestApplyDepositsIfNeeded_AppliesAt2Plus tests that ApplyDepositsIfNeeded
// applies 3 deposits when user has 2 or more unforgiven no-shows in last 6 months.
func TestApplyDepositsIfNeeded_AppliesAt2Plus(t *testing.T) {
resetTestData(t)
// Create test user with deposits_required = 0
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Ensure 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)
// Create 2 bookings with status "no_show" within last 6 months
now := time.Now()
for i := 0; i < 2; i++ {
startTime := now.Add(time.Duration(i*30) * 24 * time.Hour) // 0, 30 days ago
var bookingID string
err := db.DB.QueryRow(context.Background(), `
INSERT INTO bookings (user_id, start_time, status, notes)
VALUES ($1, $2, 'no_show', 'test no-show')
RETURNING id
`, userID, startTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking %d: %v", i, err)
}
// Link service
_, err = db.DB.Exec(context.Background(),
"INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)",
bookingID, serviceID)
if err != nil {
t.Fatalf("failed to link service: %v", err)
}
}
// Call ApplyDepositsIfNeeded directly
applied, err := ApplyDepositsIfNeeded(context.Background(), userID)
if err != nil {
t.Fatalf("ApplyDepositsIfNeeded failed: %v", err)
}
// Should return true (deposits were applied)
if !applied {
t.Error("expected ApplyDepositsIfNeeded to return true when 2+ no-shows")
}
// Verify deposits_required = 3
var deposits int
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits)
if err != nil {
t.Errorf("failed to query deposits: %v", err)
}
if deposits != 3 {
t.Errorf("expected deposits_required=3, got %d", deposits)
}
}
// TestApplyDepositsIfNeeded_DoesNotApplyAt1 tests that ApplyDepositsIfNeeded
// does NOT apply deposits when user has only 1 unforgiven no-show.
func TestApplyDepositsIfNeeded_DoesNotApplyAt1(t *testing.T) {
resetTestData(t)
// Create test user with deposits_required = 0
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Ensure 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)
// Create 1 booking with status "no_show" within last 6 months
startTime := time.Now().Add(-30 * 24 * time.Hour) // 30 days ago
var bookingID string
err = db.DB.QueryRow(context.Background(), `
INSERT INTO bookings (user_id, start_time, status, notes)
VALUES ($1, $2, 'no_show', 'test no-show')
RETURNING id
`, userID, startTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Link service
_, err = db.DB.Exec(context.Background(),
"INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)",
bookingID, serviceID)
if err != nil {
t.Fatalf("failed to link service: %v", err)
}
// Call ApplyDepositsIfNeeded directly
applied, err := ApplyDepositsIfNeeded(context.Background(), userID)
if err != nil {
t.Fatalf("ApplyDepositsIfNeeded failed: %v", err)
}
// Should return false (deposits were NOT applied)
if applied {
t.Error("expected ApplyDepositsIfNeeded to return false when only 1 no-show")
}
// Verify deposits_required = 0 (unchanged)
var deposits int
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits)
if err != nil {
t.Errorf("failed to query deposits: %v", err)
}
if deposits != 0 {
t.Errorf("expected deposits_required=0 (unchanged), got %d", deposits)
}
}
// =============================================================================
// Notes Validation Tests
// =============================================================================
// TestNotesValidation_UnderLimit verifies that a Notes string of 999,999
// characters passes go-playground/validator validation (max=1000000).
func TestNotesValidation_UnderLimit(t *testing.T) {
longNotes := strings.Repeat("a", 999999)
req := CreateBookingRequest{
StartTime: time.Now().Add(72 * time.Hour),
ServiceIDs: []string{"test-service-id"},
Notes: &longNotes,
}
err := validators.Validate.Struct(&req)
if err != nil {
t.Errorf("expected validation to pass for 999999-char notes, got: %v", err)
}
}
// TestNotesValidation_AtLimit verifies that a Notes string of exactly
// 1,000,000 characters passes validation (max=1000000, boundary test).
func TestNotesValidation_AtLimit(t *testing.T) {
exactNotes := strings.Repeat("b", 1000000)
req := CreateBookingRequest{
StartTime: time.Now().Add(72 * time.Hour),
ServiceIDs: []string{"test-service-id"},
Notes: &exactNotes,
}
err := validators.Validate.Struct(&req)
if err != nil {
t.Errorf("expected validation to pass for 1000000-char notes (boundary), got: %v", err)
}
}
// TestNotesValidation_OverLimit verifies that a Notes string of 1,000,001
// characters fails validation with max=1000000.
func TestNotesValidation_OverLimit(t *testing.T) {
tooLongNotes := strings.Repeat("c", 1000001)
req := CreateBookingRequest{
StartTime: time.Now().Add(72 * time.Hour),
ServiceIDs: []string{"test-service-id"},
Notes: &tooLongNotes,
}
err := validators.Validate.Struct(&req)
if err == nil {
t.Error("expected validation to fail for 1000001-char notes (over limit)")
}
}
// TestNotesValidation_NilPointer verifies that a nil Notes pointer passes
// validation (omitempty tag allows nil/empty).
func TestNotesValidation_NilPointer(t *testing.T) {
req := CreateBookingRequest{
StartTime: time.Now().Add(72 * time.Hour),
ServiceIDs: []string{"test-service-id"},
Notes: nil,
}
err := validators.Validate.Struct(&req)
if err != nil {
t.Errorf("expected validation to pass for nil Notes (omitempty), got: %v", err)
}
}
// TestNotesValidation_EmptyString verifies that an empty string Notes passes
// validation (omitempty and max=1000000 allows empty strings).
func TestNotesValidation_EmptyString(t *testing.T) {
emptyNotes := ""
req := CreateBookingRequest{
StartTime: time.Now().Add(72 * time.Hour),
ServiceIDs: []string{"test-service-id"},
Notes: &emptyNotes,
}
err := validators.Validate.Struct(&req)
if err != nil {
t.Errorf("expected validation to pass for empty string Notes, got: %v", err)
}
}
// 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) {
resetTestData(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)
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) {
resetTestData(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)
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) {
resetTestData(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)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// First, confirm the booking (so it's not pending)
_, err = db.DB.Exec(context.Background(),
"UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Add a payment to trigger soft delete path (bookings with payments use soft delete)
_, err = db.DB.Exec(context.Background(), `
INSERT INTO payments (id, booking_id, payment_type, payment_method, amount, status, created_at)
VALUES ($1, $2, 'deposit', 'in_person_card', 50.00, 'completed', NOW())
`, bookingID[:8]+"pay", bookingID)
if err != nil {
t.Fatalf("failed to add payment: %v", err)
}
token := jwt.GenerateUserToken(userID)
// Cancel the confirmed booking with a reason (required for soft delete)
handler := http.HandlerFunc(DeleteBookingHandler)
reqBody := map[string]string{"reason": "client_cancelled"}
w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, reqBody, token)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Verify admin notification was created
var notifCount int
err = db.DB.QueryRow(context.Background(),
`SELECT COUNT(*) FROM admin_notifications
WHERE booking_id = $1 AND reason = 'cancelled_booking'`,
bookingID).Scan(&notifCount)
if err != nil {
t.Errorf("failed to query notifications: %v", err)
}
if notifCount != 1 {
t.Errorf("expected 1 admin notification for confirmed->cancelled, got %d", notifCount)
}
}
// 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) {
resetTestData(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)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
// Verify booking is in 'pending' status
var status string
err = db.DB.QueryRow(context.Background(),
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if status != "pending" {
t.Fatalf("expected booking status 'pending', got %s", status)
}
token := jwt.GenerateUserToken(userID)
// Cancel the pending booking
handler := http.HandlerFunc(DeleteBookingHandler)
w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Verify NO admin notification was created (pending cancellations don't notify)
var notifCount int
err = db.DB.QueryRow(context.Background(),
`SELECT COUNT(*) FROM admin_notifications
WHERE booking_id = $1 AND reason = 'cancelled_booking'`,
bookingID).Scan(&notifCount)
if err != nil {
t.Errorf("failed to query notifications: %v", err)
}
if notifCount != 0 {
t.Errorf("expected NO admin notification for pending->cancelled, got %d", notifCount)
}
}
// =============================================================================
// Transaction and Error Handling Tests
// =============================================================================
// TestUserCancelBooking_TransactionIntegrity verifies that if any part of the
// cancellation transaction fails, the booking status is NOT changed (rollback behavior)
func TestUserCancelBooking_TransactionIntegrity(t *testing.T) {
resetTestData(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)
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) {
resetTestData(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)
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_requested' AND acknowledged_at IS NULL`,
bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query notifications: %v", err)
}
if notifCount != 1 {
t.Errorf("expected 1 unacknowledged admin notification, got %d", notifCount)
}
}
// TestCreateEditRequest_WithTimeChange verifies that a user can request an edit to
// change the booking time, and a time_blocker is created to reserve the new slot.
func TestCreateEditRequest_WithTimeChange(t *testing.T) {
resetTestData(t)
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)
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
token := jwt.GenerateUserToken(userID)
newStartTime := time.Now().Add(24 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
handler := http.HandlerFunc(RequestEditHandler)
reqBody := map[string]interface{}{
"new_start_time": newStartTime.Format(time.RFC3339),
}
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())
}
var erNewTime time.Time
err = db.DB.QueryRow(context.Background(),
"SELECT new_start_time FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erNewTime)
if err != nil {
t.Fatalf("failed to query edit request: %v", err)
}
if !erNewTime.Truncate(time.Second).Equal(newStartTime) {
t.Errorf("expected new_start_time %v, got %v", newStartTime, erNewTime)
}
var blockerCount int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCount)
if err != nil {
t.Fatalf("failed to query time_blockers: %v", err)
}
if blockerCount != 1 {
t.Errorf("expected 1 time_blocker for edit request, got %d", blockerCount)
}
var blockerStart time.Time
var blockerDuration int
err = db.DB.QueryRow(context.Background(),
"SELECT start_time, duration_minutes FROM time_blockers WHERE description = $1",
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerStart, &blockerDuration)
if err != nil {
t.Fatalf("failed to query time_blocker details: %v", err)
}
if !blockerStart.Truncate(time.Second).Equal(newStartTime) {
t.Errorf("expected blocker start_time %v, got %v", newStartTime, blockerStart)
}
if blockerDuration < 15 {
t.Errorf("expected blocker duration >= 15, got %d", blockerDuration)
}
}
// TestDeleteEditRequest tests that user deleting their edit request deletes the admin notification
func TestDeleteEditRequest(t *testing.T) {
resetTestData(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
_, 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_requested', $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_requested'`,
bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query notifications: %v", err)
}
if notifCount != 0 {
t.Errorf("expected 0 admin notifications after delete, got %d", notifCount)
}
}
// TestAdminApproveEditRequest tests that admin approving acknowledges the notification (not deletes)
func TestAdminApproveEditRequest(t *testing.T) {
resetTestData(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
_, 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
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, 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_requested', $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_requested'`,
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_requested'`,
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) {
resetTestData(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
_, 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_requested', $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_requested'`,
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_requested'`,
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")
}
}
// TestAdminApproveEditRequest_DeletesTimeBlocker verifies that when admin approves
// an edit request, the associated time_blocker reservation is deleted.
func TestAdminApproveEditRequest_DeletesTimeBlocker(t *testing.T) {
resetTestData(t)
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)
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
createReq := http.HandlerFunc(RequestEditHandler)
createBody := map[string]interface{}{
"new_start_time": newStartTime.Format(time.RFC3339),
}
w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken)
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String())
}
var blockerCountBefore int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore)
if err != nil {
t.Fatalf("failed to query blockers: %v", err)
}
if blockerCountBefore != 1 {
t.Fatalf("expected 1 blocker before approval, got %d", blockerCountBefore)
}
var editRequestID string
err = db.DB.QueryRow(context.Background(),
"SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID)
if err != nil {
t.Fatalf("failed to get edit request ID: %v", err)
}
r := chi.NewRouter()
r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/approve", AdminApproveEditRequestHandler)
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil)
ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
rctx.URLParams.Add("request_id", editRequestID)
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
req = req.WithContext(ctx)
w = httptest.NewRecorder()
r.ServeHTTP(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())
}
var blockerCountAfter int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter)
if err != nil {
t.Fatalf("failed to query blockers after approval: %v", err)
}
if blockerCountAfter != 0 {
t.Errorf("expected 0 blockers after approval, got %d", blockerCountAfter)
}
}
// TestAdminRejectEditRequest_DeletesTimeBlocker verifies that when admin rejects
// an edit request, the associated time_blocker reservation is deleted.
func TestAdminRejectEditRequest_DeletesTimeBlocker(t *testing.T) {
resetTestData(t)
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)
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
createReq := http.HandlerFunc(RequestEditHandler)
createBody := map[string]interface{}{
"new_start_time": newStartTime.Format(time.RFC3339),
}
w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken)
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String())
}
var blockerCountBefore int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore)
if err != nil {
t.Fatalf("failed to query blockers: %v", err)
}
if blockerCountBefore != 1 {
t.Fatalf("expected 1 blocker before rejection, got %d", blockerCountBefore)
}
var editRequestID string
err = db.DB.QueryRow(context.Background(),
"SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID)
if err != nil {
t.Fatalf("failed to get edit request ID: %v", err)
}
r := chi.NewRouter()
r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/deny", AdminRejectEditRequestHandler)
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/deny", nil)
ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
rctx.URLParams.Add("request_id", editRequestID)
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
req = req.WithContext(ctx)
w = httptest.NewRecorder()
r.ServeHTTP(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())
}
var blockerCountAfter int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter)
if err != nil {
t.Fatalf("failed to query blockers after rejection: %v", err)
}
if blockerCountAfter != 0 {
t.Errorf("expected 0 blockers after rejection, got %d", blockerCountAfter)
}
}
// TestDeleteEditRequest_DeletesTimeBlocker verifies that when user cancels their
// own edit request, the associated time_blocker reservation is deleted.
func TestDeleteEditRequest_DeletesTimeBlocker(t *testing.T) {
resetTestData(t)
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)
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
createReq := http.HandlerFunc(RequestEditHandler)
createBody := map[string]interface{}{
"new_start_time": newStartTime.Format(time.RFC3339),
}
w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken)
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String())
}
var blockerCountBefore int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore)
if err != nil {
t.Fatalf("failed to query blockers: %v", err)
}
if blockerCountBefore != 1 {
t.Fatalf("expected 1 blocker before delete, got %d", blockerCountBefore)
}
delHandler := http.HandlerFunc(DeleteEditRequestHandler)
w = makeRequest(delHandler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, userToken)
if w.Code != http.StatusOK && w.Code != http.StatusNoContent {
t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String())
}
var blockerCountAfter int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter)
if err != nil {
t.Fatalf("failed to query blockers after delete: %v", err)
}
if blockerCountAfter != 0 {
t.Errorf("expected 0 blockers after user delete, got %d", blockerCountAfter)
}
}
// TestAdminApproveEditRequest_TimeBlockerOverlap tests that approving an edit
// request fails when the new time conflicts with an existing time_blocker.
func TestAdminApproveEditRequest_TimeBlockerOverlap(t *testing.T) {
resetTestData(t)
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)
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
createReq := http.HandlerFunc(RequestEditHandler)
createBody := map[string]interface{}{
"new_start_time": newStartTime.Format(time.RFC3339),
}
w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken)
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String())
}
_, err = db.DB.Exec(context.Background(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'Existing blocker', $2)
`, newStartTime, userID)
if err != nil {
t.Fatalf("failed to create blocking time_blocker: %v", err)
}
var editRequestID string
err = db.DB.QueryRow(context.Background(),
"SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID)
if err != nil {
t.Fatalf("failed to get edit request ID: %v", err)
}
r := chi.NewRouter()
r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/approve", AdminApproveEditRequestHandler)
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil)
ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
rctx.URLParams.Add("request_id", editRequestID)
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
req = req.WithContext(ctx)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Errorf("expected status 409 Conflict due to time_blocker overlap, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestBookings_RequestEdit_BookingNotFound tests that requesting an edit for a non-existent booking returns 404
func TestBookings_RequestEdit_BookingNotFound(t *testing.T) {
resetTestData(t)
// 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) {
resetTestData(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)
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_requested', $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(&notes)
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) {
resetTestData(t)
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) {
resetTestData(t)
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).
// Booking start_time must be before testedAt + noticeHours to trigger this.
// Use a booking time in the future (passes 1-hour advance check) but before
// eligibleFrom (testedAt + 24h = now + 23h).
futureTime := time.Now().Add(2 * time.Hour).Truncate(time.Second)
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), futureTime.Hour(), 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("notice period")) {
t.Errorf("expected error message about notice period, 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) {
resetTestData(t)
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) {
resetTestData(t)
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_Within24Hours verifies that a user with deposits_required > 0
// cannot book within 24 hours notice (deposit payment window). They must complete more appointments
// to remove this restriction.
func TestBookings_Create_DepositRequired_Within24Hours(t *testing.T) {
resetTestData(t)
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 24h 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 24 hours (should be blocked by deposit advance rule)
// 23h advance: passes 1h minimum, fails 24h deposit rule
within24h := time.Now().Add(23 * time.Hour)
req := CreateBookingRequest{
StartTime: within24h,
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 24h booking with deposit requirement, got %d. body: %s", w.Code, w.Body.String())
}
if !bytes.Contains(w.Body.Bytes(), []byte("24 hours")) {
t.Errorf("expected error message about 24 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) {
resetTestData(t)
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) {
resetTestData(t)
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())
}
}
// =============================================================================
// Deposit Snapshot and Field Tests
// =============================================================================
// TestBookings_Create_DepositSnapshot verifies that deposit_required is snapshotted
// at booking creation time from user's current deposits_required value.
func TestBookings_Create_DepositSnapshot(t *testing.T) {
resetTestData(t)
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 BEFORE creating booking
_, 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)
// Create booking after deposits_required is set
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.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
// Verify deposit_required was snapshotted on the booking
var depositRequired bool
err = db.DB.QueryRow(context.Background(),
"SELECT deposit_required FROM bookings WHERE user_id = $1", userID).Scan(&depositRequired)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if !depositRequired {
t.Error("expected deposit_required=true to be snapshotted on booking")
}
// Now change user's deposits_required to 0
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to update deposits_required: %v", err)
}
// Verify the booking's deposit_required is still true (snapshot is not updated)
err = db.DB.QueryRow(context.Background(),
"SELECT deposit_required FROM bookings WHERE user_id = $1", userID).Scan(&depositRequired)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if !depositRequired {
t.Error("expected deposit_required to remain true after user's deposits_required changed")
}
}
// TestBookings_Create_DepositRequired_OneActiveBookingLimit verifies that a user
// with deposits_required > 0 can only have ONE active booking at a time.
func TestBookings_Create_DepositRequired_OneActiveBookingLimit(t *testing.T) {
resetTestData(t)
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 (triggers one-active-booking limit)
_, 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)
// Create first booking (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())
req1 := CreateBookingRequest{
StartTime: after48h,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", req1, token)
if w.Code != http.StatusCreated {
t.Fatalf("expected first booking to succeed, got %d. body: %s", w.Code, w.Body.String())
}
// Try to create second booking (should fail - one active booking limit)
after72h := time.Now().Add(96 * time.Hour).Truncate(time.Second)
after72h = time.Date(after72h.Year(), after72h.Month(), after72h.Day(), 10, 0, 0, 0, after72h.Location())
req2 := CreateBookingRequest{
StartTime: after72h,
ServiceIDs: []string{serviceID},
}
w = makeRequest(handler, "POST", "/api/bookings", req2, token)
if w.Code != http.StatusConflict {
t.Errorf("expected status 409 for second booking attempt, got %d. body: %s", w.Code, w.Body.String())
}
if !bytes.Contains(w.Body.Bytes(), []byte("active booking")) {
t.Errorf("expected error about active booking, got: %s", w.Body.String())
}
}
// TestBookings_Get_DepositFieldsReturned verifies that GET /api/bookings returns
// the deposit-related fields (deposit_required, deposit_amount, deposit_paid, deposit_deadline).
func TestBookings_Get_DepositFieldsReturned(t *testing.T) {
resetTestData(t)
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 and create booking
_, 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)
// Create booking with deposit requirement
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.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
// GET the booking and verify deposit fields
w = makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 1 {
t.Fatalf("expected 1 booking, got %d", len(resp.Bookings))
}
booking := resp.Bookings[0]
// Verify deposit fields exist
// Verify deposit fields exist
if !booking.DepositRequired {
t.Error("expected DepositRequired to be true")
}
if booking.DepositAmount <= 0 {
t.Error("expected DepositAmount to be positive")
}
// DepositPaid is a bool, check it's set (should be false for new booking)
// Just verify the field exists by accessing it
_ = booking.DepositPaid
if booking.DepositDeadline == nil {
t.Error("expected DepositDeadline to be set")
}
}
// 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) {
resetTestData(t)
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)
// DB convention: 0=Monday..6=Sunday; Go: 0=Sunday..6=Saturday. Convert.
dbWeekday := (int(targetDate.Weekday()) + 6) % 7
daysToMonday := int(targetDate.Weekday())
if daysToMonday == 0 {
daysToMonday = 7
}
mondayDate := targetDate.AddDate(0, 0, -daysToMonday+1)
weekStart := time.Date(mondayDate.Year(), mondayDate.Month(), mondayDate.Day(), 0, 0, 0, 0, mondayDate.Location())
// 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, dbWeekday)
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) {
resetTestData(t)
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
}
mondayDate := targetDate.AddDate(0, 0, -daysToMonday+1)
weekStart := time.Date(mondayDate.Year(), mondayDate.Month(), mondayDate.Day(), 0, 0, 0, 0, mondayDate.Location())
// 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())
}
}
// =============================================================================
// Time Blocker Tests for User Bookings
// =============================================================================
// TestBookings_Create_OverlappingBlocker_UserBlocked verifies that a regular user
// CANNOT create a booking that overlaps with a time blocker. They receive 409 Conflict.
func TestBookings_Create_OverlappingBlocker_UserBlocked(t *testing.T) {
resetTestData(t)
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)
// Create a time blocker for a specific time
ukLocation, _ := time.LoadLocation("Europe/London")
blockerTime := time.Date(2099, 12, 31, 10, 0, 0, 0, ukLocation)
_, err = db.DB.Exec(context.Background(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'Staff meeting', NULL)
`, blockerTime)
if err != nil {
t.Fatalf("failed to create time blocker: %v", err)
}
token := jwt.GenerateUserToken(userID)
// User tries to create booking overlapping the blocker
req := CreateBookingRequest{
StartTime: blockerTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", req, token)
// User should get 409 Conflict (not 201 Created)
if w.Code != http.StatusConflict {
t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String())
}
// Verify error message mentions the blocker
if !bytes.Contains(w.Body.Bytes(), []byte("blocked")) {
t.Errorf("expected error message to mention 'blocked', got: %s", w.Body.String())
}
// Verify NO 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.Fatalf("failed to query bookings: %v", err)
}
if count != 0 {
t.Errorf("expected 0 bookings (user should be blocked), got %d", count)
}
}
// TestBookings_Edit_OverlappingBlocker_UserBlocked verifies that a regular user
// CANNOT edit a booking to a time that overlaps with a time blocker.
func TestBookings_Edit_OverlappingBlocker_UserBlocked(t *testing.T) {
resetTestData(t)
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)
// Create a booking first
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 a time blocker for a specific time
ukLocation, _ := time.LoadLocation("Europe/London")
blockerTime := time.Date(2099, 12, 31, 10, 0, 0, 0, ukLocation)
_, err = db.DB.Exec(context.Background(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'Staff meeting', NULL)
`, blockerTime)
if err != nil {
t.Fatalf("failed to create time blocker: %v", err)
}
token := jwt.GenerateUserToken(userID)
// User tries to edit booking to overlap the blocker
req := EditBookingRequest{
StartTime: blockerTime,
}
handler := http.HandlerFunc(EditBookingHandler)
w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, req, token)
// User should get 409 Conflict
if w.Code != http.StatusConflict {
t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String())
}
// Verify error message mentions the blocker
if !bytes.Contains(w.Body.Bytes(), []byte("blocked")) {
t.Errorf("expected error message to mention 'blocked', got: %s", w.Body.String())
}
}
// --- Guest Booking Tests ---
func TestGuestUser_Create_Success(t *testing.T) {
resetTestData(t)
req := map[string]string{
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@test.com",
"phone": "07123456789",
}
handler := http.HandlerFunc(user.CreateGuestUserHandler)
w := makeRequest(handler, "POST", "/api/users/guest", req, "")
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var resp map[string]string
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["role"] != "guest" {
t.Errorf("expected role 'guest', got '%s'", resp["role"])
}
if resp["id"] == "" {
t.Error("expected non-empty user ID")
}
// Verify user exists in DB
var role string
err := db.DB.QueryRow(context.Background(), `SELECT account_role FROM users WHERE id = $1`, resp["id"]).Scan(&role)
if err != nil {
t.Fatalf("failed to query user: %v", err)
}
if role != "guest" {
t.Errorf("expected role 'guest' in DB, got '%s'", role)
}
}
func TestGuestUser_Create_DuplicateEmail(t *testing.T) {
resetTestData(t)
// First guest creation
req := map[string]string{
"firstName": "John",
"lastName": "Smith",
"email": "john@test.com",
"phone": "07123456789",
}
handler := http.HandlerFunc(user.CreateGuestUserHandler)
w1 := makeRequest(handler, "POST", "/api/users/guest", req, "")
if w1.Code != http.StatusCreated {
t.Fatalf("first guest creation failed: %d", w1.Code)
}
var resp1 map[string]string
json.Unmarshal(w1.Body.Bytes(), &resp1)
// Second guest with same email — should create a NEW account
req2 := map[string]string{
"firstName": "Jane",
"lastName": "Smith",
"email": "john@test.com", // same email
"phone": "07123456780",
}
w2 := makeRequest(handler, "POST", "/api/users/guest", req2, "")
if w2.Code != http.StatusCreated {
t.Errorf("expected status 201 for second guest, got %d. body: %s", w2.Code, w2.Body.String())
}
var resp2 map[string]string
json.Unmarshal(w2.Body.Bytes(), &resp2)
if resp1["id"] == resp2["id"] {
t.Error("expected different user IDs for duplicate email, got same ID")
}
// Verify two separate guest accounts exist
var count int
db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM users WHERE email = $1 AND account_role = 'guest'`, "john@test.com").Scan(&count)
if count != 2 {
t.Errorf("expected 2 guest accounts with same email, got %d", count)
}
}
func TestGuestUser_Create_RegisteredEmailCollision(t *testing.T) {
resetTestData(t)
// Create a registered user with a known email
registeredEmail := "registered@example.com"
db.DB.Exec(context.Background(), `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, account_role)
VALUES ('Registered', 'User', $1, '07123456700', '1990-01-01', 'verified_email')
`, registeredEmail)
// Try to create a guest with same email as the registered user
req := map[string]string{
"firstName": "Evil",
"lastName": "Guest",
"email": registeredEmail,
"phone": "07123456799",
}
handler := http.HandlerFunc(user.CreateGuestUserHandler)
w := makeRequest(handler, "POST", "/api/users/guest", req, "")
if w.Code != http.StatusConflict {
t.Errorf("expected status 409 for registered email collision, got %d. body: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "log in") {
t.Errorf("expected error to mention 'log in', got: %s", w.Body.String())
}
}
func TestGuestBooking_Create_Success(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
// Create guest user
guestReq := map[string]string{
"firstName": "Guest",
"lastName": "User",
"email": "guest@test.com",
"phone": "07123456789",
}
handler := http.HandlerFunc(user.CreateGuestUserHandler)
w := makeRequest(handler, "POST", "/api/users/guest", guestReq, "")
if w.Code != http.StatusCreated {
t.Fatalf("failed to create guest user: %d", w.Code)
}
var guestResp map[string]string
json.Unmarshal(w.Body.Bytes(), &guestResp)
guestID := guestResp["id"]
// Create booking as guest
serviceID, _ := fixtures.CreateTestService(db.DB)
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},
UserID: &guestID,
}
bookingHandler := http.HandlerFunc(CreateBookingHandler)
w2 := makeRequest(bookingHandler, "POST", "/api/bookings", req, "")
if w2.Code != http.StatusCreated {
t.Errorf("expected status 201 for guest booking, got %d. body: %s", w2.Code, w2.Body.String())
}
// Verify booking in DB
var count int
db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1`, guestID).Scan(&count)
if count != 1 {
t.Errorf("expected 1 booking for guest, got %d", count)
}
}
func TestGuestBooking_Create_WithoutUserID(t *testing.T) {
resetTestData(t)
// Attempt booking without auth AND without user_id
serviceID, _ := fixtures.CreateTestService(db.DB)
futureTime := time.Now().Add(72 * time.Hour)
req := CreateBookingRequest{
StartTime: futureTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", req, "")
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401 for missing user_id, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestGuestBooking_Create_NonGuestUserID(t *testing.T) {
resetTestData(t)
// Create a registered (non-guest) user
userID, _ := fixtures.CreateTestUser(db.DB)
// Try to book using their user_id but without auth token
serviceID, _ := fixtures.CreateTestService(db.DB)
futureTime := time.Now().Add(72 * time.Hour)
req := CreateBookingRequest{
StartTime: futureTime,
ServiceIDs: []string{serviceID},
UserID: &userID,
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", req, "")
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for non-guest user_id, got %d. body: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "guest") {
t.Errorf("expected error to mention 'guest', got: %s", w.Body.String())
}
}
func TestGuestBooking_SkipsDepositCheck(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
// Create guest user
guestReq := map[string]string{
"firstName": "Guest",
"lastName": "Skipper",
"email": "skip@test.com",
"phone": "07123456788",
}
handler := http.HandlerFunc(user.CreateGuestUserHandler)
w := makeRequest(handler, "POST", "/api/users/guest", guestReq, "")
var guestResp map[string]string
json.Unmarshal(w.Body.Bytes(), &guestResp)
guestID := guestResp["id"]
// Give them an active booking with deposit required
serviceID, _ := fixtures.CreateTestService(db.DB)
pastTime := time.Now().Add(72 * time.Hour)
db.DB.Exec(context.Background(), `
INSERT INTO bookings (user_id, start_time, status, deposit_required)
VALUES ($1, $2, 'confirmed'::booking_status, false)
`, guestID, pastTime)
// Guest should still be able to create a second booking (deposit check skipped)
futureTime := time.Now().Add(96 * time.Hour).Truncate(time.Second)
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 14, 0, 0, 0, futureTime.Location())
req := CreateBookingRequest{
StartTime: futureTime,
ServiceIDs: []string{serviceID},
UserID: &guestID,
}
bookingHandler := http.HandlerFunc(CreateBookingHandler)
w2 := makeRequest(bookingHandler, "POST", "/api/bookings", req, "")
if w2.Code != http.StatusCreated {
t.Errorf("expected guest to bypass deposit check, got %d. body: %s", w2.Code, w2.Body.String())
}
}
// =============================================================================
// Booking Notification Creation Tests
// =============================================================================
func TestCreateBooking_Notifications_NewBookingAlwaysCreated(t *testing.T) {
resetTestData(t)
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)
token := jwt.GenerateUserToken(userID)
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.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var bookingID string
err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to get booking ID: %v", err)
}
var notifCount int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'new_booking'", bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query notifications: %v", err)
}
if notifCount != 1 {
t.Errorf("expected 1 new_booking notification, got %d", notifCount)
}
}
func TestCreateBooking_Notifications_PendingBookingWithNotes(t *testing.T) {
resetTestData(t)
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)
token := jwt.GenerateUserToken(userID)
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())
notes := "Please do French tips with gold foil"
req := CreateBookingRequest{
StartTime: futureTime,
ServiceIDs: []string{serviceID},
Notes: &notes,
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", req, token)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var bookingID string
err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to get booking ID: %v", err)
}
var newBookingCount int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'new_booking'", bookingID).Scan(&newBookingCount)
if err != nil {
t.Fatalf("failed to query new_booking notifications: %v", err)
}
if newBookingCount != 1 {
t.Errorf("expected 1 new_booking notification, got %d", newBookingCount)
}
var pendingCount int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'pending_booking'", bookingID).Scan(&pendingCount)
if err != nil {
t.Fatalf("failed to query pending_booking notifications: %v", err)
}
if pendingCount != 1 {
t.Errorf("expected 1 pending_booking notification for booking with notes, got %d", pendingCount)
}
}
func TestCreateBooking_Notifications_NoPendingBookingWithoutNotes(t *testing.T) {
resetTestData(t)
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)
token := jwt.GenerateUserToken(userID)
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.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var bookingID string
err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to get booking ID: %v", err)
}
var pendingCount int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'pending_booking'", bookingID).Scan(&pendingCount)
if err != nil {
t.Fatalf("failed to query pending_booking notifications: %v", err)
}
if pendingCount != 0 {
t.Errorf("expected 0 pending_booking notifications for booking without notes, got %d", pendingCount)
}
}
// =============================================================================
// Closing Hours, Advance Check, and Active Booking Limit Tests
// =============================================================================
func TestCreateBooking_ClosingHoursValidation(t *testing.T) {
resetTestData(t)
london, err := time.LoadLocation("Europe/London")
if err != nil {
t.Fatalf("failed to load London timezone: %v", err)
}
hours := []struct {
weekday int
startTime string
endTime string
isOpen bool
}{
{0, "08:00", "17:00", true},
{1, "08:00", "20:00", true},
{2, "08:00", "20:00", true},
{3, "08:00", "20:00", true},
{4, "08:00", "20:00", true},
{5, "08:00", "20:00", true},
{6, "08:00", "20:00", true},
}
seedCustomWorkingHours(t, hours)
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)
_, err = db.DB.Exec(context.Background(), "UPDATE services SET duration_minutes = 60 WHERE id = $1", serviceID)
if err != nil {
t.Fatalf("failed to set service duration: %v", err)
}
token := jwt.GenerateUserToken(userID)
thursday := nextWeekday(time.Thursday, london)
thursdayStart := time.Date(thursday.Year(), thursday.Month(), thursday.Day(), 17, 30, 0, 0, london)
req1 := CreateBookingRequest{
StartTime: thursdayStart,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(CreateBookingHandler)
w1 := makeRequest(handler, "POST", "/api/bookings", req1, token)
if w1.Code != http.StatusCreated {
t.Errorf("Thursday 17:30+60min should succeed (ends 18:30 < 20:00), got %d. body: %s", w1.Code, w1.Body.String())
}
monday := nextWeekday(time.Monday, london)
mondayStart := time.Date(monday.Year(), monday.Month(), monday.Day(), 16, 30, 0, 0, london)
req2 := CreateBookingRequest{
StartTime: mondayStart,
ServiceIDs: []string{serviceID},
}
w2 := makeRequest(handler, "POST", "/api/bookings", req2, token)
if w2.Code != http.StatusBadRequest {
t.Errorf("Monday 16:30+60min should fail (ends 17:30 > 17:00), got %d. body: %s", w2.Code, w2.Body.String())
}
if !bytes.Contains(w2.Body.Bytes(), []byte("closing")) {
t.Errorf("expected error about closing hours, got: %s", w2.Body.String())
}
}
func TestCreateBooking_OneHourAdvanceCheck(t *testing.T) {
resetTestData(t)
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)
token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(CreateBookingHandler)
soonTime := time.Now().Add(30 * time.Minute).Truncate(time.Second)
req1 := CreateBookingRequest{
StartTime: soonTime,
ServiceIDs: []string{serviceID},
}
w1 := makeRequest(handler, "POST", "/api/bookings", req1, token)
if w1.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for 30-min advance booking, got %d. body: %s", w1.Code, w1.Body.String())
}
if !bytes.Contains(w1.Body.Bytes(), []byte("at least 1 hour")) {
t.Errorf("expected error about 1 hour advance, got: %s", w1.Body.String())
}
aheadTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
aheadTime = time.Date(aheadTime.Year(), aheadTime.Month(), aheadTime.Day(), 10, 0, 0, 0, aheadTime.Location())
req2 := CreateBookingRequest{
StartTime: aheadTime,
ServiceIDs: []string{serviceID},
}
w2 := makeRequest(handler, "POST", "/api/bookings", req2, token)
if w2.Code != http.StatusCreated {
t.Errorf("expected status 201 for 2h+ advance booking, got %d. body: %s", w2.Code, w2.Body.String())
}
}
func TestCreateBooking_ActiveBookingLimit(t *testing.T) {
resetTestData(t)
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 = 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)
handler := http.HandlerFunc(CreateBookingHandler)
firstTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
firstTime = time.Date(firstTime.Year(), firstTime.Month(), firstTime.Day(), 10, 0, 0, 0, firstTime.Location())
req1 := CreateBookingRequest{
StartTime: firstTime,
ServiceIDs: []string{serviceID},
}
w1 := makeRequest(handler, "POST", "/api/bookings", req1, token)
if w1.Code != http.StatusCreated {
t.Fatalf("expected first booking to succeed, got %d. body: %s", w1.Code, w1.Body.String())
}
var booking1 Booking
if err := parseResponseBody(w1, &booking1); err != nil {
t.Fatalf("failed to parse first booking: %v", err)
}
secondTime := time.Now().Add(96 * time.Hour).Truncate(time.Second)
secondTime = time.Date(secondTime.Year(), secondTime.Month(), secondTime.Day(), 14, 0, 0, 0, secondTime.Location())
req2 := CreateBookingRequest{
StartTime: secondTime,
ServiceIDs: []string{serviceID},
}
w2 := makeRequest(handler, "POST", "/api/bookings", req2, token)
if w2.Code != http.StatusConflict {
t.Errorf("expected status 409 for second booking with active booking, got %d. body: %s", w2.Code, w2.Body.String())
}
if !bytes.Contains(w2.Body.Bytes(), []byte("active booking")) {
t.Errorf("expected error about active booking, got: %s", w2.Body.String())
}
_, err = db.DB.Exec(context.Background(),
"UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", booking1.ID)
if err != nil {
t.Fatalf("failed to cancel first booking: %v", err)
}
w3 := makeRequest(handler, "POST", "/api/bookings", req2, token)
if w3.Code != http.StatusCreated {
t.Errorf("expected status 201 after cancelling active booking, got %d. body: %s", w3.Code, w3.Body.String())
}
}
func TestNextWeekdayHelper(t *testing.T) {
london, err := time.LoadLocation("Europe/London")
if err != nil {
t.Fatalf("Europe/London not available: %v", err)
}
tests := []struct {
name string
weekday time.Weekday
}{
{"Monday", time.Monday},
{"Tuesday", time.Tuesday},
{"Wednesday", time.Wednesday},
{"Thursday", time.Thursday},
{"Friday", time.Friday},
{"Saturday", time.Saturday},
{"Sunday", time.Sunday},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := nextWeekday(tt.weekday, london)
if result.Weekday() != tt.weekday {
t.Errorf("expected weekday %s, got %s", tt.weekday, result.Weekday())
}
now := time.Now().In(london)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, london)
resultDay := time.Date(result.Year(), result.Month(), result.Day(), 0, 0, 0, 0, london)
daysDiff := int(resultDay.Sub(today).Hours() / 24)
if daysDiff < 2 {
t.Errorf("expected result to be at least 2 calendar days ahead, got %d", daysDiff)
}
})
}
}
func TestCreateBooking_DepositSnapshot(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
_, 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)
}
token := jwt.GenerateUserToken(userID)
london, err := time.LoadLocation("Europe/London")
if err != nil {
t.Fatalf("Europe/London not available: %v", err)
}
bookingTime := nextWeekday(time.Monday, london).Add(10 * time.Hour)
req := CreateBookingRequest{
StartTime: bookingTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", req, token)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var booking Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Fatalf("failed to parse booking response: %v", err)
}
if !booking.DepositRequired {
t.Error("expected deposit_required=true on first booking")
}
var depositRequired bool
err = db.DB.QueryRow(context.Background(),
"SELECT deposit_required FROM bookings WHERE id = $1", booking.ID).Scan(&depositRequired)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if !depositRequired {
t.Error("expected deposit_required=true in DB for first booking")
}
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to update deposits_required: %v", err)
}
err = db.DB.QueryRow(context.Background(),
"SELECT deposit_required FROM bookings WHERE id = $1", booking.ID).Scan(&depositRequired)
if err != nil {
t.Fatalf("failed to query booking after user update: %v", err)
}
if !depositRequired {
t.Error("expected booking deposit_required to remain true after user change")
}
bookingTime2 := nextWeekday(time.Tuesday, london).Add(10 * time.Hour)
req2 := CreateBookingRequest{
StartTime: bookingTime2,
ServiceIDs: []string{serviceID},
}
w2 := makeRequest(handler, "POST", "/api/bookings", req2, token)
if w2.Code != http.StatusCreated {
t.Fatalf("expected status 201 for second booking, got %d. body: %s", w2.Code, w2.Body.String())
}
var booking2 Booking
if err := parseResponseBody(w2, &booking2); err != nil {
t.Fatalf("failed to parse second booking response: %v", err)
}
if booking2.DepositRequired {
t.Error("expected deposit_required=false on second booking after user deposits_required=0")
}
var depositRequired2 bool
err = db.DB.QueryRow(context.Background(),
"SELECT deposit_required FROM bookings WHERE id = $1", booking2.ID).Scan(&depositRequired2)
if err != nil {
t.Fatalf("failed to query second booking: %v", err)
}
if depositRequired2 {
t.Error("expected second booking deposit_required=false in DB")
}
}
func TestGetBooking_WithDiscounts(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
token := jwt.GenerateUserToken(userID)
// Create completed booking
london, _ := time.LoadLocation("Europe/London")
bookingTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour)
bookingID := createCompletedBookingWithTime(t, userID, serviceID, bookingTime, 50.00)
// Create a discount campaign and apply it
var campaignID string
err = db.DB.QueryRow(context.Background(), `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '1 day')
RETURNING id
`, "Test Campaign").Scan(&campaignID)
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
_, err = db.DB.Exec(context.Background(), `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'time_based', 10.0, 50.00, 5.00)
`, bookingID, userID, campaignID)
if err != nil {
t.Fatalf("failed to create booking discount: %v", err)
}
// Call GetBookingHandler
handler := http.HandlerFunc(GetBookingHandler)
w := makeRequest(handler, "GET", "/api/bookings/"+bookingID, nil, token)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var booking Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Fatalf("failed to parse booking: %v", err)
}
if len(booking.Discounts) != 1 {
t.Fatalf("expected 1 discount, got %d", len(booking.Discounts))
}
d := booking.Discounts[0]
if d.CampaignName == nil || *d.CampaignName != "Test Campaign" {
t.Errorf("expected campaign name 'Test Campaign', got %v", d.CampaignName)
}
if d.DiscountAmount != 5.00 {
t.Errorf("expected discount amount 5.00, got %.2f", d.DiscountAmount)
}
}
func createCompletedBookingWithTime(t *testing.T, userID, serviceID string, startTime time.Time, price float64) string {
t.Helper()
var bookingID string
err := db.DB.QueryRow(context.Background(), `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'completed')
RETURNING id
`, userID, startTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create completed booking: %v", err)
}
_, err = db.DB.Exec(context.Background(), `
INSERT INTO booking_services (booking_id, service_id, override_price)
VALUES ($1, $2, $3)
`, bookingID, serviceID, price)
if err != nil {
t.Fatalf("failed to link service: %v", err)
}
return bookingID
}