refactor(bookings): migrate remaining handlers and tests to clock.Now()

Replace time.Now() with clock.Now() in bookings handlers and all test files. Includes deposit, discount, dedup, overlap, and edit request test updates.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-24 23:43:32 +01:00
co-authored by Sisyphus
parent 0ea1bb64b4
commit 40bbd9ba49
10 changed files with 2057 additions and 494 deletions
+181 -82
View File
@@ -3,6 +3,7 @@ package bookings
import ( import (
"context" "context"
"crussell/db" "crussell/db"
"crussell/clock"
"crussell/handlers/notifications" "crussell/handlers/notifications"
"crussell/handlers/payments" "crussell/handlers/payments"
"crussell/handlers/scheduling" "crussell/handlers/scheduling"
@@ -16,6 +17,7 @@ import (
"log" "log"
"math" "math"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"strings" "strings"
@@ -28,7 +30,7 @@ import (
var londonLocation = func() *time.Location { var londonLocation = func() *time.Location {
loc, err := time.LoadLocation("Europe/London") loc, err := time.LoadLocation("Europe/London")
if err != nil { if err != nil {
panic("Europe/London timezone not available") panic("failed to load Europe/London timezone: " + err.Error())
} }
return loc return loc
}() }()
@@ -395,12 +397,13 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
} }
if req.StartDate != nil { if req.StartDate != nil {
whereClause += fmt.Sprintf(" AND b.start_time >= $%d", paramCount) whereClause += fmt.Sprintf(" AND b.start_time >= $%d", paramCount)
startTime, err := time.ParseInLocation("2006-01-02", *req.StartDate, londonLocation) startTime, err := time.Parse("2006-01-02", *req.StartDate)
if err != nil { if err != nil {
http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest) http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest)
return return
} }
startTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, londonLocation) londonDate := startTime.In(londonLocation)
startTime = time.Date(londonDate.Year(), londonDate.Month(), londonDate.Day(), 0, 0, 0, 0, londonLocation).UTC()
whereArgs = append(whereArgs, startTime) whereArgs = append(whereArgs, startTime)
paramCount++ paramCount++
} }
@@ -411,7 +414,8 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest) http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest)
return return
} }
endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second) londonEnd := endTime.In(londonLocation)
endTime = time.Date(londonEnd.Year(), londonEnd.Month(), londonEnd.Day(), 23, 59, 59, 999999999, londonLocation).UTC()
whereArgs = append(whereArgs, endTime) whereArgs = append(whereArgs, endTime)
paramCount++ paramCount++
} }
@@ -702,7 +706,8 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest) http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest)
return return
} }
args = append(args, startTime) londonDate := startTime.In(londonLocation)
args = append(args, time.Date(londonDate.Year(), londonDate.Month(), londonDate.Day(), 0, 0, 0, 0, londonLocation).UTC())
paramCount++ paramCount++
} }
if req.EndDate != nil { if req.EndDate != nil {
@@ -712,7 +717,8 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest) http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest)
return return
} }
args = append(args, endTime.Add(23*time.Hour+59*time.Minute+59*time.Second)) londonEnd := endTime.In(londonLocation)
args = append(args, time.Date(londonEnd.Year(), londonEnd.Month(), londonEnd.Day(), 23, 59, 59, 999999999, londonLocation).UTC())
paramCount++ paramCount++
} }
@@ -760,13 +766,15 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
} }
if req.StartDate != nil { if req.StartDate != nil {
addCountWhere(fmt.Sprintf("b.start_time >= $%d", cp)) addCountWhere(fmt.Sprintf("b.start_time >= $%d", cp))
st, _ := time.ParseInLocation("2006-01-02", *req.StartDate, londonLocation) st, _ := time.Parse("2006-01-02", *req.StartDate)
countArgs = append(countArgs, time.Date(st.Year(), st.Month(), st.Day(), 0, 0, 0, 0, londonLocation)) londonDate := st.In(londonLocation)
countArgs = append(countArgs, time.Date(londonDate.Year(), londonDate.Month(), londonDate.Day(), 0, 0, 0, 0, londonLocation).UTC())
} }
if req.EndDate != nil { if req.EndDate != nil {
addCountWhere(fmt.Sprintf("b.start_time <= $%d", cp)) addCountWhere(fmt.Sprintf("b.start_time <= $%d", cp))
et, _ := time.Parse("2006-01-02", *req.EndDate) et, _ := time.Parse("2006-01-02", *req.EndDate)
countArgs = append(countArgs, et.Add(23*time.Hour+59*time.Minute+59*time.Second)) londonEnd := et.In(londonLocation)
countArgs = append(countArgs, time.Date(londonEnd.Year(), londonEnd.Month(), londonEnd.Day(), 23, 59, 59, 999999999, londonLocation).UTC())
} }
if err := db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM bookings b"+countWhere, countArgs...).Scan(&total); err != nil { if err := db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM bookings b"+countWhere, countArgs...).Scan(&total); err != nil {
log.Printf("Failed to count admin bookings: %v", err) log.Printf("Failed to count admin bookings: %v", err)
@@ -1433,15 +1441,29 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
newEndTime := startTime.Add(time.Duration(newTotalDuration) * time.Minute) newEndTime := startTime.Add(time.Duration(newTotalDuration) * time.Minute)
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
// Evict any pending_release bookings that overlap this slot.
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, startTime, newEndTime); evictErr != nil {
log.Printf("Failed to evict pending_release bookings for slot %s: %v", startTime, evictErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var overlapCount int var overlapCount int
err := db.Conn.QueryRow(r.Context(), ` if err := tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings SELECT COUNT(*) FROM bookings
WHERE id != $1 WHERE id != $1
AND status IN ('confirmed', 'pending', 'in_progress', 'completed') AND status IN ('confirmed', 'pending', 'in_progress', 'completed')
AND start_time < $3 AND start_time < $3
AND end_time > $2 AND end_time > $2
`, bookingID, startTime, newEndTime).Scan(&overlapCount) `, bookingID, startTime, newEndTime).Scan(&overlapCount); err != nil {
if err != nil {
log.Printf("Failed to check overlap: %v", err) log.Printf("Failed to check overlap: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
@@ -1451,14 +1473,6 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
if _, err := tx.Exec(r.Context(), "DELETE FROM booking_services WHERE booking_id = $1", bookingID); err != nil { if _, err := tx.Exec(r.Context(), "DELETE FROM booking_services WHERE booking_id = $1", bookingID); err != nil {
log.Printf("Failed to delete booking services for %s: %v", bookingID, err) log.Printf("Failed to delete booking services for %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -2052,12 +2066,12 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
} }
// Check 1h minimum advance for all users // Check 1h minimum advance for all users
if req.StartTime.Before(time.Now().Add(1 * time.Hour)) { if req.StartTime.Before(clock.Now().Add(1 * time.Hour)) {
http.Error(w, "Bookings must be at least 1 hour in advance", http.StatusBadRequest) http.Error(w, "Bookings must be at least 1 hour in advance", http.StatusBadRequest)
return return
} }
if !isGuest && depositsRequired > 0 && req.StartTime.Before(time.Now().Add(payments.DepositAdvanceWindow)) { if !isGuest && depositsRequired > 0 && req.StartTime.Before(clock.Now().Add(payments.DepositAdvanceWindow)) {
http.Error(w, fmt.Sprintf("When deposits are required, bookings must be made at least %.0f hours in advance to allow time for payment.", payments.DepositAdvanceWindow.Hours()), http.StatusBadRequest) http.Error(w, fmt.Sprintf("When deposits are required, bookings must be made at least %.0f hours in advance to allow time for payment.", payments.DepositAdvanceWindow.Hours()), http.StatusBadRequest)
return return
} }
@@ -2154,7 +2168,7 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
if req.StartTime.Before(time.Now()) { if req.StartTime.Before(clock.Now()) {
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest) http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
return return
} }
@@ -2179,13 +2193,30 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
} }
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute) endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
localEnd := localStart.Add(time.Duration(svcDuration) * time.Minute) localEndLondon := localStart.Add(time.Duration(svcDuration) * time.Minute).In(londonLocation)
closeTime, _ := time.Parse("15:04:05", closeStr) if err := checkClosingHours(localEndLondon, closeStr); err != nil {
if localEnd.Hour() > closeTime.Hour() || (localEnd.Hour() == closeTime.Hour() && localEnd.Minute() > closeTime.Minute()) {
http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest) http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
return return
} }
// Clean up any existing reservation for this user BEFORE the transaction
// and time-blocker check. The reservation was created by the reserve step
// (POST /api/bookings/reserve) and is stored in the time_blockers table.
// If not deleted here, CheckTimeBlockerOverlap below would detect this
// reservation as a conflict and reject the booking — a self-blocking race.
// Using db.Conn.Exec (not tx.Exec) so the delete is visible to the
// separate connection used by CheckTimeBlockerOverlap.
if _, err := db.Conn.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description LIKE 'RESERVATION:%'
AND (created_by = $1
OR (description LIKE 'RESERVATION:anon:%' AND start_time = $2))
`, userID, req.StartTime); err != nil {
log.Printf("Failed to delete reservation time blocker for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
tx, err := db.Conn.Begin(r.Context()) tx, err := db.Conn.Begin(r.Context())
if err != nil { if err != nil {
log.Printf("Failed to start transaction: %v", err) log.Printf("Failed to start transaction: %v", err)
@@ -2284,10 +2315,9 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
if req.Notes != nil && *req.Notes != "" { if req.Notes != nil && *req.Notes != "" {
needsApproval = true needsApproval = true
} else { } else {
london, _ := time.LoadLocation("Europe/London") londonNow := clock.Now().In(londonLocation)
now := time.Now().In(london) londonBookingDay := req.StartTime.In(londonLocation)
bookingDay := req.StartTime.In(london) if londonNow.Year() == londonBookingDay.Year() && londonNow.YearDay() == londonBookingDay.YearDay() {
if now.Year() == bookingDay.Year() && now.YearDay() == bookingDay.YearDay() {
needsApproval = true needsApproval = true
} }
} }
@@ -2371,7 +2401,7 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Start time is required", http.StatusBadRequest) http.Error(w, "Start time is required", http.StatusBadRequest)
return return
} }
if req.StartTime.Before(time.Now()) { if req.StartTime.Before(clock.Now()) {
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest) http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
return return
} }
@@ -2392,16 +2422,6 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
var durationMinutes int
if err := db.Conn.QueryRow(r.Context(), `
SELECT total_duration_minutes FROM bookings WHERE id = $1
`, bookingID).Scan(&durationMinutes); err != nil {
log.Printf("Failed to get booking duration %s: %v", bookingID, err)
durationMinutes = 60
}
newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute)
tx, err := db.Conn.Begin(r.Context()) tx, err := db.Conn.Begin(r.Context())
if err != nil { if err != nil {
log.Printf("Failed to start transaction: %v", err) log.Printf("Failed to start transaction: %v", err)
@@ -2410,6 +2430,16 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
} }
defer tx.Rollback(r.Context()) defer tx.Rollback(r.Context())
var durationMinutes int
if err := tx.QueryRow(r.Context(), `
SELECT total_duration_minutes FROM bookings WHERE id = $1
`, bookingID).Scan(&durationMinutes); err != nil {
log.Printf("Failed to get booking duration %s: %v", bookingID, err)
durationMinutes = 60
}
newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute)
// Evict any pending_release bookings that overlap this slot. // Evict any pending_release bookings that overlap this slot.
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEndTime); evictErr != nil { if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEndTime); evictErr != nil {
log.Printf("Failed to evict pending_release bookings on edit: %v", evictErr) log.Printf("Failed to evict pending_release bookings on edit: %v", evictErr)
@@ -2442,15 +2472,22 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
localStart := req.StartTime.In(londonLocation)
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert. // DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
weekday := int((req.StartTime.Weekday() + 6) % 7) weekday := int((localStart.Weekday() + 6) % 7)
bookingTime := req.StartTime.Format("15:04:05") bookingTime := localStart.Format("15:04:05")
daysToMonday := int(req.StartTime.Weekday()) daysToMonday := int(localStart.Weekday())
if daysToMonday == 0 { if daysToMonday == 0 {
daysToMonday = 7 daysToMonday = 7
} }
tm := req.StartTime.AddDate(0, 0, -daysToMonday+1) tm := localStart.AddDate(0, 0, -daysToMonday+1)
weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, tm.Location()) // Use UTC midnight so the time.Time has Location=UTC at the London calendar date.
// tm has Location=London (from .In(londonLocation) above), so tm.Year/Month/Day()
// return London calendar values. Creating a UTC midnight of those values produces
// a Location=UTC time at the correct London calendar Monday. pgx's DATE codec
// extracts the calendar date from the time's own location — so this maps correctly
// to ega.week_start (DATE column), regardless of BST/GMT.
weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.UTC)
var isClosed bool var isClosed bool
if err := tx.QueryRow(r.Context(), ` if err := tx.QueryRow(r.Context(), `
@@ -2547,6 +2584,36 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
} }
defer tx.Rollback(r.Context()) defer tx.Rollback(r.Context())
// Read current status before updating to validate the transition
var currentStatus string
if err := tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 FOR UPDATE", bookingID).Scan(&currentStatus); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to fetch current status for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
validTransitions := map[string]map[string]bool{
"pending": {"confirmed": true, "completed": true, "client_cancelled": true, "we_cancelled": true},
"confirmed": {"in_progress": true, "completed": true, "client_cancelled": true, "we_cancelled": true},
"in_progress": {"completed": true},
"pending_release": {"pending": true, "confirmed": true, "client_cancelled": true, "we_cancelled": true},
"no_show": {},
"deposit_lapsed": {},
}
if targets, ok := validTransitions[currentStatus]; ok {
if !targets[req.Status] {
http.Error(w, fmt.Sprintf("Cannot transition booking from '%s' to '%s'", currentStatus, req.Status), http.StatusBadRequest)
return
}
} else if currentStatus != req.Status {
http.Error(w, fmt.Sprintf("Cannot transition booking from '%s' to '%s'", currentStatus, req.Status), http.StatusBadRequest)
return
}
var booking Booking var booking Booking
booking.User = &UserSummary{} booking.User = &UserSummary{}
if err := tx.QueryRow(r.Context(), ` if err := tx.QueryRow(r.Context(), `
@@ -2568,6 +2635,9 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
} }
if req.Status == "completed" { if req.Status == "completed" {
if currentStatus == "completed" {
log.Printf("Booking %s is already completed — skipping duplicate completion", bookingID)
} else {
// Collect patch test IDs first so the rows are consumed before INSERT operations. // Collect patch test IDs first so the rows are consumed before INSERT operations.
var patchTestIDs []string var patchTestIDs []string
ptRows, err := tx.Query(r.Context(), ` ptRows, err := tx.Query(r.Context(), `
@@ -2721,7 +2791,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
_ = tx.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount) _ = tx.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount)
var hasInPersonPayment bool var hasInPersonPayment bool
db.Conn.QueryRow(r.Context(), ` tx.QueryRow(r.Context(), `
SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND payment_method = 'in_person_card')`, bookingID).Scan(&hasInPersonPayment) SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND payment_method = 'in_person_card')`, bookingID).Scan(&hasInPersonPayment)
if hasInPersonPayment { if hasInPersonPayment {
@@ -2783,6 +2853,10 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
} }
annRows.Close() annRows.Close()
// Sort by milestone_value descending so we apply the longest anniversary only
sort.Slice(campaigns, func(i, j int) bool {
return campaigns[i].value > campaigns[j].value
})
for _, c := range campaigns { for _, c := range campaigns {
var matches bool var matches bool
elapsed := time.Since(firstVisitDate) elapsed := time.Since(firstVisitDate)
@@ -2813,7 +2887,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
`, c.id); err != nil { `, c.id); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err) log.Printf("ALERT: failed to insert payment record: %v", err)
} }
break break // apply longest matching only
} }
} }
} }
@@ -2850,6 +2924,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
`, bookingID, booking.User.ID); err != nil { `, bookingID, booking.User.ID); err != nil {
log.Printf("Failed to consume name_history for user %s: %v", booking.User.ID, err) log.Printf("Failed to consume name_history for user %s: %v", booking.User.ID, err)
} }
} // close the else from alreadyCompleted check
} }
if err := tx.Commit(r.Context()); err != nil { if err := tx.Commit(r.Context()); err != nil {
@@ -2907,22 +2982,6 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
var bkStart time.Time
var dur int
if err := db.Conn.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&bkStart); err != nil {
log.Printf("Failed to get start time: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := db.Conn.QueryRow(r.Context(), `
SELECT total_duration_minutes FROM bookings WHERE id = $1
`, bookingID).Scan(&dur); err != nil {
log.Printf("Failed to calculate duration on confirm: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
endTime := bkStart.Add(time.Duration(dur) * time.Minute)
tx, err := db.Conn.Begin(r.Context()) tx, err := db.Conn.Begin(r.Context())
if err != nil { if err != nil {
log.Printf("Failed to start transaction: %v", err) log.Printf("Failed to start transaction: %v", err)
@@ -2931,6 +2990,22 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
} }
defer tx.Rollback(r.Context()) defer tx.Rollback(r.Context())
var bkStart time.Time
var dur int
if err := tx.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&bkStart); err != nil {
log.Printf("Failed to get start time: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := tx.QueryRow(r.Context(), `
SELECT total_duration_minutes FROM bookings WHERE id = $1
`, bookingID).Scan(&dur); err != nil {
log.Printf("Failed to calculate duration on confirm: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
endTime := bkStart.Add(time.Duration(dur) * time.Minute)
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, bkStart, endTime); evictErr != nil { if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, bkStart, endTime); evictErr != nil {
log.Printf("Failed to evict pending_release bookings on confirm: %v", evictErr) log.Printf("Failed to evict pending_release bookings on confirm: %v", evictErr)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -3127,7 +3202,7 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
var calcErr error var calcErr error
refundResult, calcErr = payments.ProcessCancellationRefund( refundResult, calcErr = payments.ProcessCancellationRefund(
r.Context(), bookingID, payInfo.TotalAmount, payInfo.TotalPaid, r.Context(), bookingID, payInfo.TotalAmount, payInfo.TotalPaid,
startTime, time.Now(), "client_cancelled", nil, startTime, clock.Now(), "client_cancelled", nil,
) )
if calcErr != nil { if calcErr != nil {
log.Printf("Refund processing failed for booking %s — cancellation aborted: %v", bookingID, calcErr) log.Printf("Refund processing failed for booking %s — cancellation aborted: %v", bookingID, calcErr)
@@ -3168,15 +3243,22 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
} }
if originalStatus == "confirmed" { if originalStatus == "confirmed" {
noticeHours := startTime.Sub(time.Now()).Hours() // Only apply no-show logic for future bookings — past bookings
// that happen to still be "confirmed" should not retroactively
// receive a no-show penalty when cancelled after the fact.
noticeHours := startTime.Sub(clock.Now()).Hours()
if noticeHours < 24 { if startTime.After(clock.Now()) && noticeHours < 24 {
isForgiving := req.ForgiveNoShow != nil && *req.ForgiveNoShow isForgiving := req.ForgiveNoShow != nil && *req.ForgiveNoShow
if !isForgiving { if !isForgiving {
tx.Exec(r.Context(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bookingID) if _, err := tx.Exec(r.Context(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bookingID); err != nil {
log.Printf("Failed to update no-show status for booking %s: %v", bookingID, err)
}
} else { } else {
tx.Exec(r.Context(), "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID) if _, err := tx.Exec(r.Context(), "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID); err != nil {
log.Printf("Failed to update client_cancelled status for booking %s: %v", bookingID, err)
}
} }
} }
@@ -3195,8 +3277,10 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
} }
// After a no-show is recorded, check if user now has 2+ no-shows in 6 months. // After a no-show is recorded, check if user now has 2+ no-shows in 6 months.
if originalStatus == "confirmed" && startTime.Sub(time.Now()).Hours() < 24 && !(req.ForgiveNoShow != nil && *req.ForgiveNoShow) { // Only apply for future bookings — past confirmed bookings should not
if applied, err := ApplyDepositsIfNeeded(r.Context(), userID); err != nil { // trigger deposit requirements when cancelled after the fact.
if originalStatus == "confirmed" && startTime.After(clock.Now()) && startTime.Sub(clock.Now()).Hours() < 24 && !(req.ForgiveNoShow != nil && *req.ForgiveNoShow) {
if applied, err := ApplyDepositsIfNeeded(r.Context(), db.Conn, userID); err != nil {
log.Printf("Failed to check deposits after no-show for user %s: %v", userID, err) log.Printf("Failed to check deposits after no-show for user %s: %v", userID, err)
} else if applied { } else if applied {
log.Printf("Deposits required set to 3 for user %s due to 2+ no-shows in 6 months", userID) log.Printf("Deposits required set to 3 for user %s due to 2+ no-shows in 6 months", userID)
@@ -3521,6 +3605,9 @@ func GetBookingCalendarHandler(w http.ResponseWriter, r *http.Request) {
services = append(services, name) services = append(services, name)
totalPrice += price totalPrice += price
} }
if err := rows.Err(); err != nil {
log.Printf("Row iteration error in GetBookingICalHandler: %v", err)
}
serviceList := strings.Join(services, ", ") serviceList := strings.Join(services, ", ")
endTime := startTime.Add(time.Duration(durationMinutes) * time.Minute) endTime := startTime.Add(time.Duration(durationMinutes) * time.Minute)
@@ -3542,10 +3629,10 @@ func sanitizeICS(s string) string {
} }
func generateICS(serviceList string, start, end time.Time, status, notes string, price float64) string { func generateICS(serviceList string, start, end time.Time, status, notes string, price float64) string {
uid := fmt.Sprintf("booking-%d@crussell.com", time.Now().UnixNano()) uid := fmt.Sprintf("booking-%d@crussell.com", clock.Now().UnixNano())
dtstamp := time.Now().UTC().Format("20060102T150405Z") dtstamp := clock.Now().UTC().Format("20060102T150405Z")
dtstart := start.Format("20060102T150405") dtstart := start.UTC().Format("20060102T150405Z")
dtend := end.Format("20060102T150405") dtend := end.UTC().Format("20060102T150405Z")
sanitizedServiceList := sanitizeICS(serviceList) sanitizedServiceList := sanitizeICS(serviceList)
sanitizedStatus := sanitizeICS(status) sanitizedStatus := sanitizeICS(status)
@@ -3812,7 +3899,12 @@ func GetBookingsByDateRangeHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
endOfDay := endTime.Add(24 * time.Hour) // Convert date-only params to London-aligned boundaries so bookings
// at BST midnight (23:xx UTC = 00:xx BST next day) are correctly included.
startLondon := startTime.In(londonLocation)
startTime = time.Date(startLondon.Year(), startLondon.Month(), startLondon.Day(), 0, 0, 0, 0, londonLocation).UTC()
endLondon := endTime.In(londonLocation)
endOfDay := time.Date(endLondon.Year(), endLondon.Month(), endLondon.Day(), 23, 59, 59, 999999999, londonLocation).UTC()
rows, err := db.Conn.Query(r.Context(), ` rows, err := db.Conn.Query(r.Context(), `
SELECT SELECT
@@ -4015,7 +4107,7 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Start time is required", http.StatusBadRequest) http.Error(w, "Start time is required", http.StatusBadRequest)
return return
} }
if req.StartTime.Before(time.Now()) { if req.StartTime.Before(clock.Now()) {
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest) http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
return return
} }
@@ -4087,14 +4179,21 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
weekday := int((req.StartTime.Weekday() + 6) % 7) localStart := req.StartTime.In(londonLocation)
bookingTime := req.StartTime.Format("15:04:05") weekday := int((localStart.Weekday() + 6) % 7)
daysToMonday := int(req.StartTime.Weekday()) bookingTime := localStart.Format("15:04:05")
daysToMonday := int(localStart.Weekday())
if daysToMonday == 0 { if daysToMonday == 0 {
daysToMonday = 7 daysToMonday = 7
} }
tm := req.StartTime.AddDate(0, 0, -daysToMonday+1) tm := localStart.AddDate(0, 0, -daysToMonday+1)
weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, tm.Location()) // Use UTC midnight so the time.Time has Location=UTC at the London calendar date.
// tm has Location=London (from .In(londonLocation) above), so tm.Year/Month/Day()
// return London calendar values. Creating a UTC midnight of those values produces
// a Location=UTC time at the correct London calendar Monday. pgx's DATE codec
// extracts the calendar date from the time's own location — so this maps correctly
// to ega.week_start (DATE column), regardless of BST/GMT.
weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.UTC)
var isClosed bool var isClosed bool
if err := db.Conn.QueryRow(r.Context(), ` if err := db.Conn.QueryRow(r.Context(), `
File diff suppressed because it is too large Load Diff
+15 -14
View File
@@ -8,6 +8,7 @@ import (
"testing" "testing"
"time" "time"
"crussell/clock"
"crussell/db" "crussell/db"
"crussell/testutils" "crussell/testutils"
@@ -26,7 +27,7 @@ func setupDedupTest(t *testing.T, tx db.Querier, ctx context.Context) (string, s
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
now := time.Now() now := clock.Now()
startTime := now.Add(72 * time.Hour) startTime := now.Add(72 * time.Hour)
bookingID := createPendingBooking(t, userID, serviceID, startTime, tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, startTime, tx, ctx)
@@ -102,7 +103,7 @@ func TestProgressBooking_UserMilestoneDedup(t *testing.T) {
// Give user 5 completed bookings // Give user 5 completed bookings
svcID := createTestService(t, 30.00, tx, ctx) svcID := createTestService(t, 30.00, tx, ctx)
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
bid := createPendingBooking(t, userID, svcID, time.Now().Add(-time.Duration(30-i)*24*time.Hour), tx, ctx) bid := createPendingBooking(t, userID, svcID, clock.Now().Add(-time.Duration(30-i)*24*time.Hour), tx, ctx)
insertPaymentForBooking(t, bid, userID, 3000, tx, ctx) insertPaymentForBooking(t, bid, userID, 3000, tx, ctx)
completeBooking(t, bid, ctx) completeBooking(t, bid, ctx)
} }
@@ -144,7 +145,7 @@ func TestNoShowApplyDepositsIfNeeded(t *testing.T) {
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
// Create 2 no-show bookings (confirmed bookings cancelled <24h before start) // Create 2 no-show bookings (confirmed bookings cancelled <24h before start)
now := time.Now() now := clock.Now()
for i := 0; i < 2; i++ { for i := 0; i < 2; i++ {
bid := createPendingBooking(t, userID, serviceID, now.Add(-time.Duration(i)*time.Hour), tx, ctx) bid := createPendingBooking(t, userID, serviceID, now.Add(-time.Duration(i)*time.Hour), tx, ctx)
insertPaymentForBooking(t, bid, userID, 5000, tx, ctx) insertPaymentForBooking(t, bid, userID, 5000, tx, ctx)
@@ -156,7 +157,7 @@ func TestNoShowApplyDepositsIfNeeded(t *testing.T) {
} }
// Run ApplyDepositsIfNeeded // Run ApplyDepositsIfNeeded
applied, err := ApplyDepositsIfNeeded(ctx, userID) applied, err := ApplyDepositsIfNeeded(ctx, tx, userID)
require.NoError(t, err) require.NoError(t, err)
assert.True(t, applied, "Should have applied deposits_required = 3 after 2 no-shows") assert.True(t, applied, "Should have applied deposits_required = 3 after 2 no-shows")
@@ -174,13 +175,13 @@ func TestNoShowSingleNoShowDoesNotTrigger(t *testing.T) {
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
bid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Hour), tx, ctx) bid := createPendingBooking(t, userID, serviceID, clock.Now().Add(-time.Hour), tx, ctx)
insertPaymentForBooking(t, bid, userID, 5000, tx, ctx) insertPaymentForBooking(t, bid, userID, 5000, tx, ctx)
_, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid) _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid)
require.NoError(t, err) require.NoError(t, err)
applied, err := ApplyDepositsIfNeeded(ctx, userID) applied, err := ApplyDepositsIfNeeded(ctx, tx, userID)
require.NoError(t, err) require.NoError(t, err)
assert.False(t, applied, "Single no-show should not trigger deposits_required") assert.False(t, applied, "Single no-show should not trigger deposits_required")
@@ -199,21 +200,21 @@ func TestNoShowOldNoShowsExcluded(t *testing.T) {
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
// Create a no-show more than 6 months ago — should not count // Create a no-show more than 6 months ago — should not count
oldBid := createPendingBooking(t, userID, serviceID, time.Now().Add(-200*24*time.Hour), tx, ctx) oldBid := createPendingBooking(t, userID, serviceID, clock.Now().Add(-200*24*time.Hour), tx, ctx)
insertPaymentForBooking(t, oldBid, userID, 5000, tx, ctx) insertPaymentForBooking(t, oldBid, userID, 5000, tx, ctx)
_, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", oldBid) _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", oldBid)
require.NoError(t, err) require.NoError(t, err)
// Create a recent no-show (within 6 months) // Create a recent no-show (within 6 months)
recentBid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Hour), tx, ctx) recentBid := createPendingBooking(t, userID, serviceID, clock.Now().Add(-time.Hour), tx, ctx)
insertPaymentForBooking(t, recentBid, userID, 5000, tx, ctx) insertPaymentForBooking(t, recentBid, userID, 5000, tx, ctx)
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", recentBid) _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", recentBid)
require.NoError(t, err) require.NoError(t, err)
// Should only count 1 recent no-show, not trigger // Should only count 1 recent no-show, not trigger
applied, err := ApplyDepositsIfNeeded(ctx, userID) applied, err := ApplyDepositsIfNeeded(ctx, tx, userID)
require.NoError(t, err) require.NoError(t, err)
assert.False(t, applied, "1 old + 1 recent = 2 total but only 1 in 6-month window") assert.False(t, applied, "1 old + 1 recent = 2 total but only 1 in 6-month window")
} }
@@ -227,7 +228,7 @@ func TestNoShowForgivenExcluded(t *testing.T) {
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
for i := 0; i < 2; i++ { for i := 0; i < 2; i++ {
bid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Duration(i)*time.Hour), tx, ctx) bid := createPendingBooking(t, userID, serviceID, clock.Now().Add(-time.Duration(i)*time.Hour), tx, ctx)
insertPaymentForBooking(t, bid, userID, 5000, tx, ctx) insertPaymentForBooking(t, bid, userID, 5000, tx, ctx)
_, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid) _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid)
require.NoError(t, err) require.NoError(t, err)
@@ -239,7 +240,7 @@ func TestNoShowForgivenExcluded(t *testing.T) {
} }
} }
applied, err := ApplyDepositsIfNeeded(ctx, userID) applied, err := ApplyDepositsIfNeeded(ctx, tx, userID)
require.NoError(t, err) require.NoError(t, err)
assert.False(t, applied, "1 forgiven + 1 unforgiven = should not trigger") assert.False(t, applied, "1 forgiven + 1 unforgiven = should not trigger")
} }
@@ -263,7 +264,7 @@ func TestThreePaidBookingsClearNoShows(t *testing.T) {
// Create 2 no-show records // Create 2 no-show records
for i := 0; i < 2; i++ { for i := 0; i < 2; i++ {
bid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Duration(i+1)*time.Hour), tx, ctx) bid := createPendingBooking(t, userID, serviceID, clock.Now().Add(-time.Duration(i+1)*time.Hour), tx, ctx)
insertPaymentForBooking(t, bid, userID, 5000, tx, ctx) insertPaymentForBooking(t, bid, userID, 5000, tx, ctx)
_, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid) _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid)
require.NoError(t, err) require.NoError(t, err)
@@ -271,7 +272,7 @@ func TestThreePaidBookingsClearNoShows(t *testing.T) {
// Complete 3 paid bookings — each should decrement deposits_required // Complete 3 paid bookings — each should decrement deposits_required
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
bid := createPendingBooking(t, userID, serviceID, time.Now().Add(time.Duration(i+1)*time.Hour), tx, ctx) bid := createPendingBooking(t, userID, serviceID, clock.Now().Add(time.Duration(i+1)*time.Hour), tx, ctx)
insertPaymentForBooking(t, bid, userID, 5000, tx, ctx) insertPaymentForBooking(t, bid, userID, 5000, tx, ctx)
completeBooking(t, bid, ctx) completeBooking(t, bid, ctx)
} }
@@ -291,6 +292,6 @@ func TestThreePaidBookingsClearNoShows(t *testing.T) {
`, userID).Scan(&forgivenCount) `, userID).Scan(&forgivenCount)
// Try again — should NOT trigger deposits_required again since no-shows are forgiven // Try again — should NOT trigger deposits_required again since no-shows are forgiven
applied, _ := ApplyDepositsIfNeeded(ctx, userID) applied, _ := ApplyDepositsIfNeeded(ctx, tx, userID)
assert.False(t, applied, "Should not trigger: no-shows were forgiven after 3 paid bookings") assert.False(t, applied, "Should not trigger: no-shows were forgiven after 3 paid bookings")
} }
+37 -52
View File
@@ -12,6 +12,7 @@ import (
"testing" "testing"
"time" "time"
"crussell/clock"
"crussell/db" "crussell/db"
"crussell/testutils" "crussell/testutils"
"crussell/handlers/payments" "crussell/handlers/payments"
@@ -142,7 +143,7 @@ func TestRequestEditHandler_NoticePeriod_BlocksPaymentUnder72h(t *testing.T) {
} }
// Booking starting in 2 hours (<48h) // Booking starting in 2 hours (<48h)
soon := time.Now().Add(2 * time.Hour) soon := clock.Now().Add(2 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon)
if err != nil { if err != nil {
t.Fatalf("failed to create booking: %v", err) t.Fatalf("failed to create booking: %v", err)
@@ -191,7 +192,7 @@ func TestRequestEditHandler_NoticePeriod_BlocksNoPaymentUnder24h(t *testing.T) {
} }
// Booking starting in 2 hours (<24h, no payments) // Booking starting in 2 hours (<24h, no payments)
soon := time.Now().Add(2 * time.Hour) soon := clock.Now().Add(2 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon)
if err != nil { if err != nil {
t.Fatalf("failed to create booking: %v", err) t.Fatalf("failed to create booking: %v", err)
@@ -236,7 +237,7 @@ func TestAdminCreateBookingForUser_EvictsPendingReleaseOnOverlap(t *testing.T) {
} }
// Create a booking that will end up in pending_release (deposit not paid). // Create a booking that will end up in pending_release (deposit not paid).
future := time.Now().Add(48 * time.Hour) future := clock.Now().Add(48 * time.Hour)
existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future) existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
if err != nil { if err != nil {
t.Fatalf("failed to create existing booking: %v", err) t.Fatalf("failed to create existing booking: %v", err)
@@ -248,7 +249,8 @@ func TestAdminCreateBookingForUser_EvictsPendingReleaseOnOverlap(t *testing.T) {
t.Fatalf("failed to set status to pending_release: %v", err) t.Fatalf("failed to set status to pending_release: %v", err)
} }
// Admin creates a new booking at the same time — should evict the existing booking. // Admin creates a new booking at the same time — pending_release should be evicted
// to deposit_lapsed, and the new booking should succeed.
body := AdminCreateBookingForUserRequest{ body := AdminCreateBookingForUserRequest{
UserID: userID, UserID: userID,
StartTime: future, StartTime: future,
@@ -262,11 +264,11 @@ func TestAdminCreateBookingForUser_EvictsPendingReleaseOnOverlap(t *testing.T) {
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
}) })
if w.Code != http.StatusCreated && w.Code != http.StatusOK { if w.Code != http.StatusCreated {
t.Fatalf("expected 201/200 for admin create, got %d: %s", w.Code, w.Body.String()) t.Fatalf("expected 201 Created (pending_release was evicted), got %d: %s", w.Code, w.Body.String())
} }
// Verify the old booking was evicted to deposit_lapsed. // Verify the existing booking was evicted to deposit_lapsed.
var newStatus string var newStatus string
err = tx.QueryRow(ctx, err = tx.QueryRow(ctx,
"SELECT status FROM bookings WHERE id = $1", existingBookingID).Scan(&newStatus) "SELECT status FROM bookings WHERE id = $1", existingBookingID).Scan(&newStatus)
@@ -297,7 +299,7 @@ func TestAdminRescheduleBookingHandler_ForgiveNoShow(t *testing.T) {
} }
// Create a confirmed booking far enough away that the reschedule is valid. // Create a confirmed booking far enough away that the reschedule is valid.
future := time.Now().Add(96 * time.Hour) future := clock.Now().Add(96 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
if err != nil { if err != nil {
t.Fatalf("failed to create booking: %v", err) t.Fatalf("failed to create booking: %v", err)
@@ -364,7 +366,7 @@ func TestRequestEditHandler_NoticePeriod_SetsNoShowWarningHeader(t *testing.T) {
} }
// Booking starting in 48 hours (24-72h window, no payments). // Booking starting in 48 hours (24-72h window, no payments).
midRange := time.Now().Add(48 * time.Hour) midRange := clock.Now().Add(48 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, midRange) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, midRange)
if err != nil { if err != nil {
t.Fatalf("failed to create booking: %v", err) t.Fatalf("failed to create booking: %v", err)
@@ -416,7 +418,7 @@ func TestRequestEditHandler_NoticePeriod_AllowsWhenEnoughNotice(t *testing.T) {
} }
// Booking starting in 36 hours (within 48h threshold so auto-approval does not fire) // Booking starting in 36 hours (within 48h threshold so auto-approval does not fire)
bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) bookingTime := clock.Now().Add(36 * time.Hour).Truncate(time.Second)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime)
if err != nil { if err != nil {
t.Fatalf("failed to create booking: %v", err) t.Fatalf("failed to create booking: %v", err)
@@ -458,7 +460,7 @@ func TestDeleteBookingHandler_RefundResponse(t *testing.T) {
} }
// Booking in the far future (full refund expected) // Booking in the far future (full refund expected)
farFuture := time.Now().Add(200 * time.Hour) farFuture := clock.Now().Add(200 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farFuture) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farFuture)
if err != nil { if err != nil {
t.Fatalf("failed to create booking: %v", err) t.Fatalf("failed to create booking: %v", err)
@@ -515,7 +517,7 @@ func TestDeleteBookingHandler_NoRefundForUnder24h(t *testing.T) {
} }
// Booking starting in 1 hour (<24h) // Booking starting in 1 hour (<24h)
soon := time.Now().Add(1 * time.Hour) soon := clock.Now().Add(1 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon)
if err != nil { if err != nil {
t.Fatalf("failed to create booking: %v", err) t.Fatalf("failed to create booking: %v", err)
@@ -576,7 +578,7 @@ func TestAdminCancelBookingHandler_ForgiveFeesFullRefund(t *testing.T) {
} }
// Booking starting in 1 hour (<24h, normally no refund) // Booking starting in 1 hour (<24h, normally no refund)
soon := time.Now().Add(1 * time.Hour) soon := clock.Now().Add(1 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon)
if err != nil { if err != nil {
t.Fatalf("failed to create booking: %v", err) t.Fatalf("failed to create booking: %v", err)
@@ -643,7 +645,7 @@ func TestAdminCancelBookingHandler_NormalRefundOver72h(t *testing.T) {
} }
// Booking starting far in the future (>72h — full refund tier without forgiveness). // Booking starting far in the future (>72h — full refund tier without forgiveness).
farFuture := time.Now().Add(200 * time.Hour) farFuture := clock.Now().Add(200 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farFuture) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farFuture)
if err != nil { if err != nil {
t.Fatalf("failed to create booking: %v", err) t.Fatalf("failed to create booking: %v", err)
@@ -767,7 +769,7 @@ func TestAdminRescheduleBookingHandler_NormalReschedule(t *testing.T) {
} }
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Now().Add(100*time.Hour)) clock.Now().Add(100*time.Hour))
if err != nil { if err != nil {
t.Fatalf("failed to create booking: %v", err) t.Fatalf("failed to create booking: %v", err)
} }
@@ -780,7 +782,7 @@ func TestAdminRescheduleBookingHandler_NormalReschedule(t *testing.T) {
adminToken := jwt.GenerateAdminToken() adminToken := jwt.GenerateAdminToken()
handler := http.HandlerFunc(AdminRescheduleBookingHandler) handler := http.HandlerFunc(AdminRescheduleBookingHandler)
newTime := time.Now().Add(200 * time.Hour) newTime := clock.Now().Add(200 * time.Hour)
w := makeRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/reschedule", map[string]interface{}{ w := makeRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/reschedule", map[string]interface{}{
"start_time": newTime.Format(time.RFC3339), "start_time": newTime.Format(time.RFC3339),
@@ -812,7 +814,7 @@ func TestAdminRescheduleBookingHandler_ForgiveFees_Succeeds(t *testing.T) {
t.Fatalf("failed to create service: %v", err) t.Fatalf("failed to create service: %v", err)
} }
future := time.Now().Add(96 * time.Hour) future := clock.Now().Add(96 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
if err != nil { if err != nil {
t.Fatalf("failed to create booking: %v", err) t.Fatalf("failed to create booking: %v", err)
@@ -867,14 +869,14 @@ func TestAdminRescheduleBookingHandler_MissingAuth_Returns401(t *testing.T) {
t.Fatalf("failed to create service: %v", err) t.Fatalf("failed to create service: %v", err)
} }
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Now().Add(100*time.Hour)) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, clock.Now().Add(100*time.Hour))
if err != nil { if err != nil {
t.Fatalf("failed to create booking: %v", err) t.Fatalf("failed to create booking: %v", err)
} }
// Send request WITHOUT admin auth context. // Send request WITHOUT admin auth context.
w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/"+bookingID+"/reschedule", "/{id}/reschedule", map[string]interface{}{ w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/"+bookingID+"/reschedule", "/{id}/reschedule", map[string]interface{}{
"start_time": time.Now().Add(200 * time.Hour).Format(time.RFC3339), "start_time": clock.Now().Add(200 * time.Hour).Format(time.RFC3339),
}, func(baseCtx context.Context) context.Context { }, func(baseCtx context.Context) context.Context {
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
}) })
@@ -889,7 +891,7 @@ func TestAdminRescheduleBookingHandler_MissingAuth_Returns401(t *testing.T) {
func TestPopulateDepositFields_NegativeAmount_Safeguarded(t *testing.T) { func TestPopulateDepositFields_NegativeAmount_Safeguarded(t *testing.T) {
b := &Booking{ b := &Booking{
TotalAmount: 100, TotalAmount: 100,
StartTime: time.Now(), StartTime: clock.Now(),
} }
// Negative amount should be clamped to 0. // Negative amount should be clamped to 0.
@@ -929,7 +931,7 @@ func TestCreateBooking_DepositAdvanceWindow_BlocksUnder36h(t *testing.T) {
// Use a time 10 hours from now — well within the 36h deposit advance window // Use a time 10 hours from now — well within the 36h deposit advance window
// but past the 1h minimum advance check. // but past the 1h minimum advance check.
nearTime := time.Now().Add(10 * time.Hour).Truncate(time.Second) nearTime := clock.Now().Add(10 * time.Hour).Truncate(time.Second)
req := CreateBookingRequest{ req := CreateBookingRequest{
StartTime: nearTime, StartTime: nearTime,
ServiceIDs: []string{serviceID}, ServiceIDs: []string{serviceID},
@@ -966,13 +968,10 @@ func TestCreateBooking_DepositAdvanceWindow_AllowsOver36h(t *testing.T) {
token := jwt.GenerateUserToken(userID) token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(CreateBookingHandler) handler := http.HandlerFunc(CreateBookingHandler)
london, err := time.LoadLocation("Europe/London") // Use next weekday >48h from now during working hours (well past 36h deposit window).
if err != nil { // Use 13:00 (1pm) to ensure the 36h deposit window from ANY overnight test time
t.Fatalf("Europe/London not available: %v", err) // is always cleared — 36h from midnight UTC Wednesday = 12:00 UTC Thursday.
} farTime := nextWeekday(time.Thursday).Add(13 * time.Hour)
// Use next Wednesday at 10:00 — always >72h from now, well past the 36h window.
farTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour)
req := CreateBookingRequest{ req := CreateBookingRequest{
StartTime: farTime, StartTime: farTime,
ServiceIDs: []string{serviceID}, ServiceIDs: []string{serviceID},
@@ -1009,9 +1008,9 @@ func TestCreateBooking_DepositAdvanceWindow_SkipsWhenNoDepositRequired(t *testin
// Booking within 36h but with no deposits required — should be allowed. // Booking within 36h but with no deposits required — should be allowed.
// Use a time within working hours (midday on tomorrow or next weekday). // Use a time within working hours (midday on tomorrow or next weekday).
midday := time.Now().Truncate(24 * time.Hour).Add(29 * time.Hour).In(londonLocation) midday := clock.Now().Truncate(24 * time.Hour).Add(29 * time.Hour)
if midday.Hour() < 8 || midday.Hour() >= 20 { if midday.Hour() < 8 || midday.Hour() >= 20 {
midday = nextWeekday(time.Now().Weekday(), londonLocation).Add(12 * time.Hour) midday = nextWeekday(clock.Now().Weekday()).Add(12 * time.Hour)
} }
nearTime := midday.Truncate(time.Second) nearTime := midday.Truncate(time.Second)
req := CreateBookingRequest{ req := CreateBookingRequest{
@@ -1051,13 +1050,8 @@ func TestCreateBooking_EvictsPendingReleaseOnOverlap(t *testing.T) {
token := jwt.GenerateUserToken(userID) token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(CreateBookingHandler) handler := http.HandlerFunc(CreateBookingHandler)
london, err := time.LoadLocation("Europe/London")
if err != nil {
t.Fatalf("Europe/London not available: %v", err)
}
// Create a booking far in the future that we'll mark as pending_release. // Create a booking far in the future that we'll mark as pending_release.
farTime := nextWeekday(time.Monday, london).Add(10 * time.Hour) farTime := nextWeekday(time.Monday).Add(10 * time.Hour)
existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farTime) existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farTime)
if err != nil { if err != nil {
t.Fatalf("failed to create existing booking: %v", err) t.Fatalf("failed to create existing booking: %v", err)
@@ -1115,13 +1109,8 @@ func TestCreateBooking_LeftUnchangedWhenNoOverlapWithPendingRelease(t *testing.T
token := jwt.GenerateUserToken(userID) token := jwt.GenerateUserToken(userID)
handler := http.HandlerFunc(CreateBookingHandler) handler := http.HandlerFunc(CreateBookingHandler)
london, err := time.LoadLocation("Europe/London")
if err != nil {
t.Fatalf("Europe/London not available: %v", err)
}
// Create a pending_release booking at time A. // Create a pending_release booking at time A.
timeA := nextWeekday(time.Monday, london).Add(10 * time.Hour) timeA := nextWeekday(time.Monday).Add(10 * time.Hour)
existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, timeA) existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, timeA)
if err != nil { if err != nil {
t.Fatalf("failed to create existing booking: %v", err) t.Fatalf("failed to create existing booking: %v", err)
@@ -1133,7 +1122,7 @@ func TestCreateBooking_LeftUnchangedWhenNoOverlapWithPendingRelease(t *testing.T
} }
// Create a new booking at a different time B that does NOT overlap. // Create a new booking at a different time B that does NOT overlap.
timeB := nextWeekday(time.Tuesday, london).Add(10 * time.Hour) timeB := nextWeekday(time.Tuesday).Add(10 * time.Hour)
req := CreateBookingRequest{ req := CreateBookingRequest{
StartTime: timeB, StartTime: timeB,
ServiceIDs: []string{serviceID}, ServiceIDs: []string{serviceID},
@@ -1174,7 +1163,7 @@ func TestEvictPendingReleaseOverlapping_Basic(t *testing.T) {
} }
// Create a booking far in the future. // Create a booking far in the future.
future := time.Now().Add(72 * time.Hour) future := clock.Now().Add(72 * time.Hour)
existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future) existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
if err != nil { if err != nil {
t.Fatalf("failed to create existing booking: %v", err) t.Fatalf("failed to create existing booking: %v", err)
@@ -1229,7 +1218,7 @@ func TestEvictPendingReleaseOverlapping_PaymentLockGuard(t *testing.T) {
t.Fatalf("failed to create service: %v", err) t.Fatalf("failed to create service: %v", err)
} }
future := time.Now().Add(72 * time.Hour) future := clock.Now().Add(72 * time.Hour)
existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future) existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
if err != nil { if err != nil {
t.Fatalf("failed to create booking: %v", err) t.Fatalf("failed to create booking: %v", err)
@@ -1285,7 +1274,7 @@ func TestEvictPendingReleaseOverlapping_NoOverlap(t *testing.T) {
t.Fatalf("failed to create service: %v", err) t.Fatalf("failed to create service: %v", err)
} }
future := time.Now().Add(72 * time.Hour) future := clock.Now().Add(72 * time.Hour)
existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future) existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
if err != nil { if err != nil {
t.Fatalf("failed to create booking: %v", err) t.Fatalf("failed to create booking: %v", err)
@@ -1341,7 +1330,7 @@ func TestConfirmBooking_EvictsPendingReleaseOnOverlap(t *testing.T) {
} }
// Create a pending_release booking at a far-future time slot. // Create a pending_release booking at a far-future time slot.
future := time.Now().Add(72 * time.Hour) future := clock.Now().Add(72 * time.Hour)
pendingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future) pendingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
if err != nil { if err != nil {
t.Fatalf("failed to create pending booking: %v", err) t.Fatalf("failed to create pending booking: %v", err)
@@ -1409,11 +1398,7 @@ func TestAdminRescheduleBooking_EvictsPendingReleaseOnOverlap(t *testing.T) {
} }
// Create a pending_release booking at a specific time slot. // Create a pending_release booking at a specific time slot.
london, err := time.LoadLocation("Europe/London") slotTime := nextWeekday(time.Wednesday).Add(10 * time.Hour)
if err != nil {
t.Fatalf("Europe/London not available: %v", err)
}
slotTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour)
pendingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, slotTime) pendingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, slotTime)
if err != nil { if err != nil {
+60 -59
View File
@@ -13,6 +13,7 @@ import (
"testing" "testing"
"time" "time"
"crussell/clock"
"crussell/db" "crussell/db"
"crussell/testutils" "crussell/testutils"
"crussell/handlers/payments" "crussell/handlers/payments"
@@ -88,7 +89,7 @@ func createTestService(t *testing.T, price float64, q db.Querier, ctx context.Co
func createTestCampaign(t *testing.T, name, campaignType string, percent float64, milestoneType, milestoneUnit *string, milestoneValue *int, maxRedemptions *int, q db.Querier, ctx context.Context) string { func createTestCampaign(t *testing.T, name, campaignType string, percent float64, milestoneType, milestoneUnit *string, milestoneValue *int, maxRedemptions *int, q db.Querier, ctx context.Context) string {
t.Helper() t.Helper()
var id string var id string
now := time.Now() now := clock.Now()
startDate := now.Add(-24 * time.Hour) startDate := now.Add(-24 * time.Hour)
endDate := now.Add(24 * time.Hour) endDate := now.Add(24 * time.Hour)
@@ -295,7 +296,7 @@ func TestDiscount_Loyalty_FullCycle(t *testing.T) {
assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx)) assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx))
// Apply loyalty redemption manually on a new booking // Apply loyalty redemption manually on a new booking
bookingID := createPendingBooking(t, userID, serviceID, time.Now().AddDate(0, 0, 12), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().AddDate(0, 0, 12), tx, ctx)
applyLoyaltyRedemption(t, bookingID, userID, ctx) applyLoyaltyRedemption(t, bookingID, userID, ctx)
source, amount, exists := getDiscountForBooking(t, bookingID, ctx) source, amount, exists := getDiscountForBooking(t, bookingID, ctx)
@@ -327,7 +328,7 @@ func TestDiscount_Loyalty_ExistingRedemptionApplies(t *testing.T) {
`, userID) `, userID)
require.NoError(t, err) require.NoError(t, err)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
// Apply loyalty redemption manually before completion // Apply loyalty redemption manually before completion
applyLoyaltyRedemption(t, bookingID, userID, ctx) applyLoyaltyRedemption(t, bookingID, userID, ctx)
@@ -364,11 +365,11 @@ func TestDiscount_Stacking_LoyaltyPlusMilestone(t *testing.T) {
serviceID := createTestService(t, 100.00, tx, ctx) serviceID := createTestService(t, 100.00, tx, ctx)
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
startTime := time.Now().AddDate(0, 0, -(i + 10)) startTime := clock.Now().AddDate(0, 0, -(i + 10))
_ = createCompletedBooking(t, userID, serviceID, startTime, 100.00, ctx) _ = createCompletedBooking(t, userID, serviceID, startTime, 100.00, ctx)
} }
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
// Apply loyalty manually, then complete (milestone applies at completion) // Apply loyalty manually, then complete (milestone applies at completion)
applyLoyaltyRedemption(t, bookingID, userID, ctx) applyLoyaltyRedemption(t, bookingID, userID, ctx)
@@ -397,10 +398,10 @@ func TestDiscount_Stacking_LoyaltyPlusAnniversary(t *testing.T) {
serviceID := createTestService(t, 100.00, tx, ctx) serviceID := createTestService(t, 100.00, tx, ctx)
firstStartTime := time.Now().AddDate(0, 0, -400) firstStartTime := clock.Now().AddDate(0, 0, -400)
_ = createCompletedBooking(t, userID, serviceID, firstStartTime, 100.00, ctx) _ = createCompletedBooking(t, userID, serviceID, firstStartTime, 100.00, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
applyLoyaltyRedemption(t, bookingID, userID, ctx) applyLoyaltyRedemption(t, bookingID, userID, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
@@ -430,10 +431,10 @@ func TestDiscount_Stacking_AllThreeTypes(t *testing.T) {
serviceID := createTestService(t, 100.00, tx, ctx) serviceID := createTestService(t, 100.00, tx, ctx)
firstStartTime := time.Now().AddDate(0, 0, -400) firstStartTime := clock.Now().AddDate(0, 0, -400)
_ = createCompletedBooking(t, userID, serviceID, firstStartTime, 100.00, ctx) _ = createCompletedBooking(t, userID, serviceID, firstStartTime, 100.00, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
applyLoyaltyRedemption(t, bookingID, userID, ctx) applyLoyaltyRedemption(t, bookingID, userID, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
@@ -469,14 +470,14 @@ func TestDiscount_Stacking_MultipleMilestones(t *testing.T) {
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
var startTime time.Time var startTime time.Time
if i == 0 { if i == 0 {
startTime = time.Now().AddDate(0, 0, -400) startTime = clock.Now().AddDate(0, 0, -400)
} else { } else {
startTime = time.Now().AddDate(0, 0, -(i * 7)) startTime = clock.Now().AddDate(0, 0, -(i * 7))
} }
_ = createCompletedBooking(t, userID, serviceID, startTime, 100.00, ctx) _ = createCompletedBooking(t, userID, serviceID, startTime, 100.00, ctx)
} }
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
insertInPersonCardPayment(t, bookingID, ctx) insertInPersonCardPayment(t, bookingID, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
@@ -499,7 +500,7 @@ func TestDiscount_Stacking_LoyaltyPlusGlobalMilestone(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
serviceID := createTestService(t, 100.00, tx, ctx) serviceID := createTestService(t, 100.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
applyLoyaltyRedemption(t, bookingID, userID, ctx) applyLoyaltyRedemption(t, bookingID, userID, ctx)
insertInPersonCardPayment(t, bookingID, ctx) insertInPersonCardPayment(t, bookingID, ctx)
@@ -529,7 +530,7 @@ func TestDiscount_Stacking_DiscountAmountsSumCorrectly(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
serviceID := createTestService(t, 200.00, tx, ctx) serviceID := createTestService(t, 200.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
applyLoyaltyRedemption(t, bookingID, userID, ctx) applyLoyaltyRedemption(t, bookingID, userID, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
@@ -573,7 +574,7 @@ func TestDiscount_Stacking_MultiplePaymentRecords(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
serviceID := createTestService(t, 200.00, tx, ctx) serviceID := createTestService(t, 200.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
applyLoyaltyRedemption(t, bookingID, userID, ctx) applyLoyaltyRedemption(t, bookingID, userID, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
@@ -606,7 +607,7 @@ func TestDiscount_Stacking_MultipleBookingDiscountRows(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
serviceID := createTestService(t, 200.00, tx, ctx) serviceID := createTestService(t, 200.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
applyLoyaltyRedemption(t, bookingID, userID, ctx) applyLoyaltyRedemption(t, bookingID, userID, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
@@ -671,13 +672,13 @@ func TestDiscount_Stacking_TimeBasedPlusMilestone(t *testing.T) {
serviceID := createTestService(t, 100.00, tx, ctx) serviceID := createTestService(t, 100.00, tx, ctx)
// 2 prior completed bookings // 2 prior completed bookings
startTime1 := time.Now().AddDate(0, 0, -14) startTime1 := clock.Now().AddDate(0, 0, -14)
_ = createCompletedBooking(t, userID, serviceID, startTime1, 100.00, ctx) _ = createCompletedBooking(t, userID, serviceID, startTime1, 100.00, ctx)
startTime2 := time.Now().AddDate(0, 0, -7) startTime2 := clock.Now().AddDate(0, 0, -7)
_ = createCompletedBooking(t, userID, serviceID, startTime2, 100.00, ctx) _ = createCompletedBooking(t, userID, serviceID, startTime2, 100.00, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
assert.Equal(t, 2, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 2 discount rows") assert.Equal(t, 2, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 2 discount rows")
@@ -709,7 +710,7 @@ func TestDiscount_NoDiscountOnZeroTotal(t *testing.T) {
`, "Free Service", "A free service", 0.00, 60, true, 16).Scan(&serviceID) `, "Free Service", "A free service", 0.00, 60, true, 16).Scan(&serviceID)
require.NoError(t, err) require.NoError(t, err)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
var paymentCount int var paymentCount int
@@ -743,7 +744,7 @@ func TestDiscount_CampaignMaxRedemptions(t *testing.T) {
userID2 := createTestUser(t, 0, tx, ctx) userID2 := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
bookingID1 := createPendingBooking(t, userID1, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID1 := createPendingBooking(t, userID1, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID1, ctx) completeBooking(t, bookingID1, ctx)
var discountCount1 int var discountCount1 int
@@ -756,7 +757,7 @@ func TestDiscount_CampaignMaxRedemptions(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, 1, timesRedeemed) assert.Equal(t, 1, timesRedeemed)
bookingID2 := createPendingBooking(t, userID2, serviceID, time.Now().Add(48*time.Hour), tx, ctx) bookingID2 := createPendingBooking(t, userID2, serviceID, clock.Now().Add(48*time.Hour), tx, ctx)
completeBooking(t, bookingID2, ctx) completeBooking(t, bookingID2, ctx)
var discountCount2 int var discountCount2 int
@@ -768,7 +769,7 @@ func TestDiscount_CampaignMaxRedemptions(t *testing.T) {
func createTestCampaignWithStatus(t *testing.T, name, campaignType string, percent float64, status string, milestoneType, milestoneUnit *string, milestoneValue *int, maxRedemptions *int, q db.Querier, ctx context.Context) string { func createTestCampaignWithStatus(t *testing.T, name, campaignType string, percent float64, status string, milestoneType, milestoneUnit *string, milestoneValue *int, maxRedemptions *int, q db.Querier, ctx context.Context) string {
t.Helper() t.Helper()
var id string var id string
now := time.Now() now := clock.Now()
startDate := now.Add(-24 * time.Hour) startDate := now.Add(-24 * time.Hour)
endDate := now.Add(24 * time.Hour) endDate := now.Add(24 * time.Hour)
@@ -798,7 +799,7 @@ func TestDiscount_ExpiredRedemptionDoesNotApply(t *testing.T) {
`, userID) `, userID)
require.NoError(t, err) require.NoError(t, err)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx) count := getDiscountRowCount(t, bookingID, tx, ctx)
@@ -823,7 +824,7 @@ func TestDiscount_MultiplePendingRedemptions_UsesOldest(t *testing.T) {
`, userID) `, userID)
require.NoError(t, err) require.NoError(t, err)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
// Manual apply-redemption should pick the oldest pending redemption // Manual apply-redemption should pick the oldest pending redemption
applyLoyaltyRedemption(t, bookingID, userID, ctx) applyLoyaltyRedemption(t, bookingID, userID, ctx)
@@ -860,7 +861,7 @@ func TestDiscount_StampCountAboveTen(t *testing.T) {
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
// First booking: stamps 9 → 10, pending redemption auto-created at completion // First booking: stamps 9 → 10, pending redemption auto-created at completion
bookingID1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID1 := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID1, ctx) completeBooking(t, bookingID1, ctx)
backdateBooking(t, bookingID1, 2, ctx) backdateBooking(t, bookingID1, 2, ctx)
@@ -868,7 +869,7 @@ func TestDiscount_StampCountAboveTen(t *testing.T) {
assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx), "Pending redemption should be auto-created") assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx), "Pending redemption should be auto-created")
// Second booking: stamps accumulate to 11 (no auto-deduct) // Second booking: stamps accumulate to 11 (no auto-deduct)
bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour), tx, ctx) bookingID2 := createPendingBooking(t, userID, serviceID, clock.Now().Add(48*time.Hour), tx, ctx)
// Second booking: stamps accumulate to 11 (no auto-deduct) // Second booking: stamps accumulate to 11 (no auto-deduct)
// Apply redemption while booking is still confirmed (not yet completed) // Apply redemption while booking is still confirmed (not yet completed)
@@ -898,7 +899,7 @@ func TestDiscount_RedemptionAppliedBeforeStampIncrement(t *testing.T) {
`, userID) `, userID)
require.NoError(t, err) require.NoError(t, err)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
// Apply redemption manually before completing // Apply redemption manually before completing
applyLoyaltyRedemption(t, bookingID, userID, ctx) applyLoyaltyRedemption(t, bookingID, userID, ctx)
@@ -922,20 +923,20 @@ func TestDiscount_NormalEarn_NoRedemption(t *testing.T) {
userID := createTestUser(t, 5, tx, ctx) userID := createTestUser(t, 5, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
booking1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) booking1 := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, booking1, ctx) completeBooking(t, booking1, ctx)
assert.Equal(t, 6, getStamps(t, userID, ctx), "5 + 1 = 6") assert.Equal(t, 6, getStamps(t, userID, ctx), "5 + 1 = 6")
assert.Equal(t, 0, getDiscountRowCount(t, booking1, tx, ctx), "No discounts applied") assert.Equal(t, 0, getDiscountRowCount(t, booking1, tx, ctx), "No discounts applied")
assert.Equal(t, 0, getPendingRedemptions(t, userID, ctx), "No pending redemption (< 10)") assert.Equal(t, 0, getPendingRedemptions(t, userID, ctx), "No pending redemption (< 10)")
backdateBooking(t, booking1, 2, ctx) backdateBooking(t, booking1, 2, ctx)
booking2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour), tx, ctx) booking2 := createPendingBooking(t, userID, serviceID, clock.Now().Add(48*time.Hour), tx, ctx)
completeBooking(t, booking2, ctx) completeBooking(t, booking2, ctx)
assert.Equal(t, 7, getStamps(t, userID, ctx), "6 + 1 = 7") assert.Equal(t, 7, getStamps(t, userID, ctx), "6 + 1 = 7")
assert.Equal(t, 0, getDiscountRowCount(t, booking2, tx, ctx), "No discounts applied") assert.Equal(t, 0, getDiscountRowCount(t, booking2, tx, ctx), "No discounts applied")
backdateBooking(t, booking2, 2, ctx) backdateBooking(t, booking2, 2, ctx)
booking3 := createPendingBooking(t, userID, serviceID, time.Now().Add(72*time.Hour), tx, ctx) booking3 := createPendingBooking(t, userID, serviceID, clock.Now().Add(72*time.Hour), tx, ctx)
completeBooking(t, booking3, ctx) completeBooking(t, booking3, ctx)
assert.Equal(t, 8, getStamps(t, userID, ctx), "7 + 1 = 8") assert.Equal(t, 8, getStamps(t, userID, ctx), "7 + 1 = 8")
assert.Equal(t, 0, getDiscountRowCount(t, booking3, tx, ctx), "No discounts applied") assert.Equal(t, 0, getDiscountRowCount(t, booking3, tx, ctx), "No discounts applied")
@@ -957,7 +958,7 @@ func TestDiscount_DepositBeforeRedemption_Rejected(t *testing.T) {
assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx)) assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx))
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
// Pay a deposit first (the first real payment) // Pay a deposit first (the first real payment)
_, err = tx.Exec(ctx, ` _, err = tx.Exec(ctx, `
@@ -1000,7 +1001,7 @@ func TestDiscount_RedemptionBeforeDeposit_DiscountLockedIn(t *testing.T) {
`, userID) `, userID)
require.NoError(t, err) require.NoError(t, err)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
// Apply loyalty redemption (simulating online payment checkbox) // Apply loyalty redemption (simulating online payment checkbox)
applyLoyaltyRedemption(t, bookingID, userID, ctx) applyLoyaltyRedemption(t, bookingID, userID, ctx)
@@ -1054,7 +1055,7 @@ func TestDiscount_MultipleEarnCycles(t *testing.T) {
assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx)) assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx))
// Redeem on first booking // Redeem on first booking
booking1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) booking1 := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
applyLoyaltyRedemption(t, booking1, userID, ctx) applyLoyaltyRedemption(t, booking1, userID, ctx)
assert.Equal(t, 0, getStamps(t, userID, ctx)) assert.Equal(t, 0, getStamps(t, userID, ctx))
assert.Equal(t, 0, getPendingRedemptions(t, userID, ctx)) assert.Equal(t, 0, getPendingRedemptions(t, userID, ctx))
@@ -1078,7 +1079,7 @@ func TestDiscount_MultipleEarnCycles(t *testing.T) {
assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx)) assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx))
// Redeem again on a new booking // Redeem again on a new booking
booking2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour), tx, ctx) booking2 := createPendingBooking(t, userID, serviceID, clock.Now().Add(48*time.Hour), tx, ctx)
applyLoyaltyRedemption(t, booking2, userID, ctx) applyLoyaltyRedemption(t, booking2, userID, ctx)
assert.Equal(t, 0, getStamps(t, userID, ctx), "Stamps deducted again") assert.Equal(t, 0, getStamps(t, userID, ctx), "Stamps deducted again")
@@ -1102,7 +1103,7 @@ func TestDiscount_MixedFreeAndPaidServices(t *testing.T) {
INSERT INTO bookings (user_id, start_time, status) INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'confirmed') VALUES ($1, $2, 'confirmed')
RETURNING id RETURNING id
`, userID, time.Now().Add(24*time.Hour)).Scan(&bookingID) `, userID, clock.Now().Add(24*time.Hour)).Scan(&bookingID)
require.NoError(t, err) require.NoError(t, err)
// Insert two booking_services rows: one free, one paid // Insert two booking_services rows: one free, one paid
@@ -1131,7 +1132,7 @@ func TestDiscount_CampaignBoundaryStart(t *testing.T) {
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx) count := getDiscountRowCount(t, bookingID, tx, ctx)
@@ -1152,7 +1153,7 @@ func TestDiscount_CampaignBoundaryEnd(t *testing.T) {
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx) count := getDiscountRowCount(t, bookingID, tx, ctx)
@@ -1173,7 +1174,7 @@ func TestDiscount_CampaignExpiredDoesNotApply(t *testing.T) {
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx) count := getDiscountRowCount(t, bookingID, tx, ctx)
@@ -1188,7 +1189,7 @@ func TestDiscount_CampaignDraftDoesNotApply(t *testing.T) {
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx) count := getDiscountRowCount(t, bookingID, tx, ctx)
@@ -1203,7 +1204,7 @@ func TestDiscount_CampaignCancelledDoesNotApply(t *testing.T) {
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx) count := getDiscountRowCount(t, bookingID, tx, ctx)
@@ -1224,7 +1225,7 @@ func TestDiscount_PriceOverrideRespected(t *testing.T) {
INSERT INTO bookings (user_id, start_time, status) INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'confirmed') VALUES ($1, $2, 'confirmed')
RETURNING id RETURNING id
`, userID, time.Now().Add(24*time.Hour)).Scan(&bookingID) `, userID, clock.Now().Add(24*time.Hour)).Scan(&bookingID)
require.NoError(t, err) require.NoError(t, err)
_, err = tx.Exec(ctx, ` _, err = tx.Exec(ctx, `
@@ -1254,7 +1255,7 @@ func TestDiscount_AnniversaryDedupWithStacking(t *testing.T) {
serviceID := createTestService(t, 60.00, tx, ctx) serviceID := createTestService(t, 60.00, tx, ctx)
// Create a completed booking 400 days ago (> 12 months) // Create a completed booking 400 days ago (> 12 months)
fourHundredDaysAgo := time.Now().AddDate(0, 0, -400) fourHundredDaysAgo := clock.Now().AddDate(0, 0, -400)
var firstBookingID string var firstBookingID string
err := tx.QueryRow(ctx, ` err := tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status) INSERT INTO bookings (user_id, start_time, status)
@@ -1270,14 +1271,14 @@ func TestDiscount_AnniversaryDedupWithStacking(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// First booking after anniversary threshold: should get anniversary + time_based // First booking after anniversary threshold: should get anniversary + time_based
bookingID1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID1 := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID1, ctx) completeBooking(t, bookingID1, ctx)
discounts1 := getAllDiscountsForBooking(t, bookingID1, ctx) discounts1 := getAllDiscountsForBooking(t, bookingID1, ctx)
assert.Equal(t, 2, len(discounts1), "First booking should get anniversary + time_based discounts") assert.Equal(t, 2, len(discounts1), "First booking should get anniversary + time_based discounts")
// Second booking (different day): should only get time_based (anniversary dedup) // Second booking (different day): should only get time_based (anniversary dedup)
bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour), tx, ctx) bookingID2 := createPendingBooking(t, userID, serviceID, clock.Now().Add(48*time.Hour), tx, ctx)
completeBooking(t, bookingID2, ctx) completeBooking(t, bookingID2, ctx)
discounts2 := getAllDiscountsForBooking(t, bookingID2, ctx) discounts2 := getAllDiscountsForBooking(t, bookingID2, ctx)
@@ -1305,19 +1306,19 @@ func TestDiscount_PerUserMilestoneDedupWithStacking(t *testing.T) {
// Create 2 prior completed bookings on different days // Create 2 prior completed bookings on different days
for i := 0; i < 2; i++ { for i := 0; i < 2; i++ {
startTime := time.Now().AddDate(0, 0, -10-i*7) startTime := clock.Now().AddDate(0, 0, -10-i*7)
_ = createCompletedBooking(t, userID, serviceID, startTime, 50.00, ctx) _ = createCompletedBooking(t, userID, serviceID, startTime, 50.00, ctx)
} }
// 3rd booking: milestone + time_based // 3rd booking: milestone + time_based
bookingID3 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID3 := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID3, ctx) completeBooking(t, bookingID3, ctx)
discounts3 := getAllDiscountsForBooking(t, bookingID3, ctx) discounts3 := getAllDiscountsForBooking(t, bookingID3, ctx)
assert.Equal(t, 2, len(discounts3), "3rd booking should get per-user milestone + time_based") assert.Equal(t, 2, len(discounts3), "3rd booking should get per-user milestone + time_based")
// 4th booking (different day): only time_based (milestone dedup) // 4th booking (different day): only time_based (milestone dedup)
bookingID4 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour), tx, ctx) bookingID4 := createPendingBooking(t, userID, serviceID, clock.Now().Add(48*time.Hour), tx, ctx)
completeBooking(t, bookingID4, ctx) completeBooking(t, bookingID4, ctx)
discounts4 := getAllDiscountsForBooking(t, bookingID4, ctx) discounts4 := getAllDiscountsForBooking(t, bookingID4, ctx)
@@ -1346,12 +1347,12 @@ func TestDiscount_GlobalMilestoneMaxRedemptionsWithStacking(t *testing.T) {
// Create 4 completed bookings (different days, user1) // Create 4 completed bookings (different days, user1)
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
startTime := time.Now().AddDate(0, 0, -10-i*7) startTime := clock.Now().AddDate(0, 0, -10-i*7)
_ = createCompletedBooking(t, userID1, serviceID, startTime, 50.00, ctx) _ = createCompletedBooking(t, userID1, serviceID, startTime, 50.00, ctx)
} }
// 5th global booking (user1, different day): milestone + time_based // 5th global booking (user1, different day): milestone + time_based
bookingID5 := createPendingBooking(t, userID1, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID5 := createPendingBooking(t, userID1, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
insertInPersonCardPayment(t, bookingID5, ctx) insertInPersonCardPayment(t, bookingID5, ctx)
completeBooking(t, bookingID5, ctx) completeBooking(t, bookingID5, ctx)
@@ -1359,7 +1360,7 @@ func TestDiscount_GlobalMilestoneMaxRedemptionsWithStacking(t *testing.T) {
assert.Equal(t, 2, len(discounts5), "5th global booking should get milestone + time_based") assert.Equal(t, 2, len(discounts5), "5th global booking should get milestone + time_based")
// 6th global booking (user2, different day): only time_based (max_redemptions reached) // 6th global booking (user2, different day): only time_based (max_redemptions reached)
bookingID6 := createPendingBooking(t, userID2, serviceID, time.Now().Add(48*time.Hour), tx, ctx) bookingID6 := createPendingBooking(t, userID2, serviceID, clock.Now().Add(48*time.Hour), tx, ctx)
insertInPersonCardPayment(t, bookingID6, ctx) insertInPersonCardPayment(t, bookingID6, ctx)
completeBooking(t, bookingID6, ctx) completeBooking(t, bookingID6, ctx)
@@ -1383,7 +1384,7 @@ func TestDiscount_BestTimeBasedCampaignSelected(t *testing.T) {
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 100.00, tx, ctx) serviceID := createTestService(t, 100.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx) count := getDiscountRowCount(t, bookingID, tx, ctx)
@@ -1401,7 +1402,7 @@ func TestDiscount_FirstBookingEarnsStamp(t *testing.T) {
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
assert.Equal(t, 1, getStamps(t, userID, ctx), "First paid booking earns 1 stamp") assert.Equal(t, 1, getStamps(t, userID, ctx), "First paid booking earns 1 stamp")
@@ -1413,7 +1414,7 @@ func TestDiscount_TenStampsCreatesRedemption(t *testing.T) {
userID := createTestUser(t, 9, tx, ctx) userID := createTestUser(t, 9, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
assert.Equal(t, 10, getStamps(t, userID, ctx), "Stamps should reach 10") assert.Equal(t, 10, getStamps(t, userID, ctx), "Stamps should reach 10")
@@ -1463,7 +1464,7 @@ func TestCampaign_ActivateDraft(t *testing.T) {
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx) count := getDiscountRowCount(t, bookingID, tx, ctx)
@@ -1487,7 +1488,7 @@ func TestCampaign_CompleteActive(t *testing.T) {
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx) count := getDiscountRowCount(t, bookingID, tx, ctx)
@@ -1511,7 +1512,7 @@ func TestCampaign_CancelActive(t *testing.T) {
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx) count := getDiscountRowCount(t, bookingID, tx, ctx)
@@ -1535,7 +1536,7 @@ func TestCampaign_RevertToDraft(t *testing.T) {
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx) count := getDiscountRowCount(t, bookingID, tx, ctx)
@@ -1550,7 +1551,7 @@ func TestCampaign_DraftDoesNotApplyDiscounts(t *testing.T) {
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour), tx, ctx) bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx) completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx) count := getDiscountRowCount(t, bookingID, tx, ctx)
+25 -24
View File
@@ -32,6 +32,7 @@ import (
"testing" "testing"
"time" "time"
"crussell/clock"
"crussell/db" "crussell/db"
"crussell/testutils" "crussell/testutils"
"crussell/mw" "crussell/mw"
@@ -71,7 +72,7 @@ func setupEditRequestTest(t *testing.T, ctx context.Context, tx db.Querier) (use
} }
// Use a start time ~36h from now so auto-approval (>=48h) does not fire // Use a start time ~36h from now so auto-approval (>=48h) does not fire
bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) bookingTime := clock.Now().Add(36 * time.Hour).Truncate(time.Second)
bookingID, err = fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime) bookingID, err = fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime)
if err != nil { if err != nil {
t.Fatalf("failed to create test booking: %v", err) t.Fatalf("failed to create test booking: %v", err)
@@ -114,7 +115,7 @@ func setupTwoUserEditRequestTest(t *testing.T, ctx context.Context, tx db.Querie
t.Fatalf("failed to create test service: %v", err) t.Fatalf("failed to create test service: %v", err)
} }
bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) bookingTime := clock.Now().Add(36 * time.Hour).Truncate(time.Second)
bookingID, err = fixtures.CreateTestBookingAtTime(tx, ownerID, serviceID, bookingTime) bookingID, err = fixtures.CreateTestBookingAtTime(tx, ownerID, serviceID, bookingTime)
if err != nil { if err != nil {
t.Fatalf("failed to create test booking: %v", err) t.Fatalf("failed to create test booking: %v", err)
@@ -457,7 +458,7 @@ func TestRequestEditHandler_UpsertBehavior(t *testing.T) {
// Create second edit request (replaces first) // Create second edit request (replaces first)
secondNotes := "Second request: different notes" secondNotes := "Second request: different notes"
newStartTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) newStartTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 10, 0, 0, 0, newStartTime.Location()) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 10, 0, 0, 0, newStartTime.Location())
reqBody2 := map[string]interface{}{ reqBody2 := map[string]interface{}{
"notes": secondNotes, "notes": secondNotes,
@@ -538,7 +539,7 @@ func TestRequestEditHandler_WithServices(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to create second service: %v", err) t.Fatalf("failed to create second service: %v", err)
} }
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
handler := http.HandlerFunc(RequestEditHandler) handler := http.HandlerFunc(RequestEditHandler)
@@ -615,7 +616,7 @@ func TestDeleteEditRequestHandler_Success(t *testing.T) {
_, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx)
_ = serviceID _ = serviceID
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
createHandler := http.HandlerFunc(RequestEditHandler) createHandler := http.HandlerFunc(RequestEditHandler)
@@ -765,7 +766,7 @@ func TestGetMyEditRequestHandler_Success(t *testing.T) {
_, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx)
_ = serviceID _ = serviceID
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
// Create edit request first // Create edit request first
@@ -887,7 +888,7 @@ func TestGetMyEditRequestsHandler_Success(t *testing.T) {
userID, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) userID, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx)
// Create a second booking with edit request (within 48h to avoid auto-approval) // Create a second booking with edit request (within 48h to avoid auto-approval)
booking2Time := time.Now().Add(36 * time.Hour).Truncate(time.Second) booking2Time := clock.Now().Add(36 * time.Hour).Truncate(time.Second)
bookingID2, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, booking2Time) bookingID2, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, booking2Time)
if err != nil { if err != nil {
t.Fatalf("failed to create second booking: %v", err) t.Fatalf("failed to create second booking: %v", err)
@@ -1085,7 +1086,7 @@ func TestAdminGetBookingEditRequestHandler_Success(t *testing.T) {
_ = serviceID _ = serviceID
_ = userID _ = userID
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
// Create edit request // Create edit request
@@ -1151,7 +1152,7 @@ func TestAdminApproveEditRequestHandler_TimeChange(t *testing.T) {
_, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx)
_ = serviceID _ = serviceID
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
// Create edit request // Create edit request
@@ -1234,7 +1235,7 @@ func TestAdminApproveEditRequestHandler_WithServices(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to create second service: %v", err) t.Fatalf("failed to create second service: %v", err)
} }
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
editRequestID := createEditRequestDirectly(t, ctx, tx, bookingID, userID, &newStartTime, []string{serviceID2}, nil) editRequestID := createEditRequestDirectly(t, ctx, tx, bookingID, userID, &newStartTime, []string{serviceID2}, nil)
@@ -1320,7 +1321,7 @@ func TestAdminRejectEditRequestHandler_Success(t *testing.T) {
_, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx)
_ = serviceID _ = serviceID
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
// Create edit request // Create edit request
@@ -1434,7 +1435,7 @@ func TestRequestEditHandler_TimeBlockerCreated(t *testing.T) {
_, _, bookingID, token := setupEditRequestTest(t, ctx, tx) _, _, bookingID, token := setupEditRequestTest(t, ctx, tx)
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
createHandler := http.HandlerFunc(RequestEditHandler) createHandler := http.HandlerFunc(RequestEditHandler)
@@ -1500,10 +1501,10 @@ func TestRequestEditHandler_TimeBlockerReplacedOnUpsert(t *testing.T) {
_, _, bookingID, token := setupEditRequestTest(t, ctx, tx) _, _, bookingID, token := setupEditRequestTest(t, ctx, tx)
time1 := time.Now().Add(48 * time.Hour).Truncate(time.Second) time1 := clock.Now().Add(48 * time.Hour).Truncate(time.Second)
time1 = time.Date(time1.Year(), time1.Month(), time1.Day(), 10, 0, 0, 0, time1.Location()) time1 = time.Date(time1.Year(), time1.Month(), time1.Day(), 10, 0, 0, 0, time1.Location())
time2 := time.Now().Add(72 * time.Hour).Truncate(time.Second) time2 := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
time2 = time.Date(time2.Year(), time2.Month(), time2.Day(), 15, 0, 0, 0, time2.Location()) time2 = time.Date(time2.Year(), time2.Month(), time2.Day(), 15, 0, 0, 0, time2.Location())
handler := http.HandlerFunc(RequestEditHandler) handler := http.HandlerFunc(RequestEditHandler)
@@ -1638,7 +1639,7 @@ func TestAdminApproveEditRequestHandler_OverlapWithBooking(t *testing.T) {
// Create first booking at time T // Create first booking at time T
// Use a start time <48h away so auto-approval doesn't trigger at request- // Use a start time <48h away so auto-approval doesn't trigger at request-
// creation time, allowing us to test the approval-time overlap check. // creation time, allowing us to test the approval-time overlap check.
baseTime := time.Now().Add(40 * time.Hour).Truncate(time.Second) baseTime := clock.Now().Add(40 * time.Hour).Truncate(time.Second)
baseTime = time.Date(baseTime.Year(), baseTime.Month(), baseTime.Day(), 9, 0, 0, 0, baseTime.Location()) baseTime = time.Date(baseTime.Year(), baseTime.Month(), baseTime.Day(), 9, 0, 0, 0, baseTime.Location())
booking1, err := fixtures.CreateTestBooking(tx, userID, serviceID) booking1, err := fixtures.CreateTestBooking(tx, userID, serviceID)
@@ -1699,7 +1700,7 @@ func TestAdminApproveEditRequestHandler_TimeBlockerDeletedOnApprove(t *testing.T
_ = serviceID _ = serviceID
_ = userID _ = userID
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
handler := http.HandlerFunc(RequestEditHandler) handler := http.HandlerFunc(RequestEditHandler)
@@ -1740,7 +1741,7 @@ func TestAdminRejectEditRequestHandler_TimeBlockerDeletedOnReject(t *testing.T)
_, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx)
_ = serviceID _ = serviceID
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
handler := http.HandlerFunc(RequestEditHandler) handler := http.HandlerFunc(RequestEditHandler)
@@ -1786,7 +1787,7 @@ func TestUserCancelBookingHandler_CleansUpEditRequest(t *testing.T) {
_, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx)
_ = serviceID _ = serviceID
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
// Create edit request (creates time_blocker + admin_notification) // Create edit request (creates time_blocker + admin_notification)
@@ -1982,7 +1983,7 @@ func TestGetMyEditRequestHandler_EndTimeCalculation(t *testing.T) {
} }
// Create edit request with time change // Create edit request with time change
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
createHandler := http.HandlerFunc(RequestEditHandler) createHandler := http.HandlerFunc(RequestEditHandler)
@@ -2114,7 +2115,7 @@ func TestAdminGetBookingEditRequestHandler_EnrichedData(t *testing.T) {
_, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx)
_ = serviceID _ = serviceID
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
// Create edit request // Create edit request
@@ -2197,7 +2198,7 @@ func TestGetMyEditRequestHandler_WithOverrides(t *testing.T) {
// Create edit request (time change only — services change blocked for overrides, // Create edit request (time change only — services change blocked for overrides,
// but time change is allowed) // but time change is allowed)
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
createHandler := http.HandlerFunc(RequestEditHandler) createHandler := http.HandlerFunc(RequestEditHandler)
@@ -2265,7 +2266,7 @@ func TestAdminListEditRequestsHandler_Pagination(t *testing.T) {
// Create 4 additional bookings with edit requests (within 48h to avoid auto-approval) // Create 4 additional bookings with edit requests (within 48h to avoid auto-approval)
bookingIDs := []string{bookingID} bookingIDs := []string{bookingID}
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
bookingTime := time.Now().Add(36 * time.Hour).Truncate(time.Second) bookingTime := clock.Now().Add(36 * time.Hour).Truncate(time.Second)
newBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime) newBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime)
if err != nil { if err != nil {
t.Fatalf("failed to create booking %d: %v", i, err) t.Fatalf("failed to create booking %d: %v", i, err)
@@ -2332,10 +2333,10 @@ func TestRequestEditHandler_NotificationUpsertOnReplace(t *testing.T) {
_, _, bookingID, token := setupEditRequestTest(t, ctx, tx) _, _, bookingID, token := setupEditRequestTest(t, ctx, tx)
time1 := time.Now().Add(48 * time.Hour).Truncate(time.Second) time1 := clock.Now().Add(48 * time.Hour).Truncate(time.Second)
time1 = time.Date(time1.Year(), time1.Month(), time1.Day(), 10, 0, 0, 0, time1.Location()) time1 = time.Date(time1.Year(), time1.Month(), time1.Day(), 10, 0, 0, 0, time1.Location())
time2 := time.Now().Add(72 * time.Hour).Truncate(time.Second) time2 := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
time2 = time.Date(time2.Year(), time2.Month(), time2.Day(), 15, 0, 0, 0, time2.Location()) time2 = time.Date(time2.Year(), time2.Month(), time2.Day(), 15, 0, 0, 0, time2.Location())
handler := http.HandlerFunc(RequestEditHandler) handler := http.HandlerFunc(RequestEditHandler)
+115 -54
View File
@@ -3,6 +3,7 @@ package bookings
import ( import (
"context" "context"
"crussell/db" "crussell/db"
"crussell/clock"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"crussell/handlers/notifications" "crussell/handlers/notifications"
"crussell/handlers/payments" "crussell/handlers/payments"
@@ -61,7 +62,7 @@ func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
UPDATE bookings UPDATE bookings
SET status = 'client_cancelled', updated_at = $1 SET status = 'client_cancelled', updated_at = $1
WHERE id = $2 AND user_id = $3 AND status IN ('pending', 'confirmed', 'in_progress') WHERE id = $2 AND user_id = $3 AND status IN ('pending', 'confirmed', 'in_progress')
`, time.Now(), bookingID, userID) `, clock.Now(), bookingID, userID)
if err != nil { if err != nil {
log.Printf("Failed to cancel booking %s: %v", bookingID, err) log.Printf("Failed to cancel booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -145,11 +146,9 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Process refund FIRST, before the cancel transaction. If the refund fails, // Fetch payment info before the transaction (read-only, no side effects).
// the booking stays active and the admin can retry. This mirrors the
// DeleteBookingHandler pattern — the booking status change is independent
// of the refund execution.
var refundResult *payments.RefundCalculationResult var refundResult *payments.RefundCalculationResult
var refundFailed bool
paySvc := payments.NewPaymentService() paySvc := payments.NewPaymentService()
payInfo, payErr := paySvc.GetBookingPaymentInfo(r.Context(), bookingID) payInfo, payErr := paySvc.GetBookingPaymentInfo(r.Context(), bookingID)
if payErr != nil { if payErr != nil {
@@ -159,21 +158,7 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
} }
totalAmount := payInfo.TotalAmount totalAmount := payInfo.TotalAmount
totalPaid := payInfo.TotalPaid totalPaid := payInfo.TotalPaid
if totalPaid > 0 { calculatedRefund := totalPaid > 0 && !forgiveFees
if forgiveFees {
refundResult = &payments.RefundCalculationResult{
TotalPrePaid: totalPaid,
RefundableAmount: totalPaid,
KeptAmount: 0,
Tier: "admin_full_refund",
}
} else {
calc, err := payments.ProcessCancellationRefund(r.Context(), bookingID, totalAmount, totalPaid, payInfo.StartTime, time.Now(), "admin_cancelled", &adminID)
if err == nil {
refundResult = calc
}
}
}
tx, err := db.Conn.Begin(r.Context()) tx, err := db.Conn.Begin(r.Context())
if err != nil { if err != nil {
@@ -183,11 +168,11 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
} }
defer tx.Rollback(r.Context()) defer tx.Rollback(r.Context())
// Get current status and user ID before updating // Get current status and user ID — use FOR UPDATE to lock the row so
// the refund and status change are atomic.
var originalStatus string var originalStatus string
var bookingUserID string var bookingUserID string
err = tx.QueryRow(r.Context(), "SELECT status, user_id FROM bookings WHERE id = $1", bookingID).Scan(&originalStatus, &bookingUserID) if err := tx.QueryRow(r.Context(), "SELECT status, user_id FROM bookings WHERE id = $1 FOR UPDATE", bookingID).Scan(&originalStatus, &bookingUserID); err != nil {
if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not cancellable", http.StatusNotFound) http.Error(w, "Booking not cancellable", http.StatusNotFound)
return return
@@ -201,7 +186,7 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
UPDATE bookings UPDATE bookings
SET status = 'we_cancelled', updated_at = $1 SET status = 'we_cancelled', updated_at = $1
WHERE id = $2 AND status IN ('pending', 'confirmed', 'in_progress') WHERE id = $2 AND status IN ('pending', 'confirmed', 'in_progress')
`, time.Now(), bookingID) `, clock.Now(), bookingID)
if err != nil { if err != nil {
log.Printf("Failed to admin cancel booking %s: %v", bookingID, err) log.Printf("Failed to admin cancel booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -213,6 +198,27 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Status update succeeded — now process the refund in the SAME transaction
// so that a commit failure rolls back both the status change and the refund.
if forgiveFees && totalPaid > 0 {
refundResult = &payments.RefundCalculationResult{
TotalPrePaid: totalPaid,
RefundableAmount: totalPaid,
KeptAmount: 0,
Tier: "admin_full_refund",
}
}
if calculatedRefund {
var calc *payments.RefundCalculationResult
calc, err = payments.ProcessCancellationRefundTx(r.Context(), tx, bookingID, totalAmount, totalPaid, payInfo.StartTime, clock.Now(), "admin_cancelled", &adminID)
if err == nil {
refundResult = calc
} else {
refundFailed = true
log.Printf("ALERT: AdminCancelBookingHandler — ProcessCancellationRefundTx failed for booking %s after status was updated to we_cancelled. Refund was NOT processed. The transaction WILL be committed (cancellation stands, no refund). Error: %v", bookingID, err)
}
}
if forgiveNoShow && bookingUserID != "" { if forgiveNoShow && bookingUserID != "" {
if _, err := tx.Exec(r.Context(), ` if _, err := tx.Exec(r.Context(), `
INSERT INTO forgiven_no_shows (booking_id, forgiven_by) INSERT INTO forgiven_no_shows (booking_id, forgiven_by)
@@ -266,11 +272,24 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
if refundResult != nil && refundResult.RefundableAmount > 0 { // Process pending Square refunds after the transaction commits successfully.
json.NewEncoder(w).Encode(map[string]interface{}{ // This ensures Square API calls only happen if the DB records persist.
"message": "Booking cancelled", if calculatedRefund {
"refund_calculation": refundResult, payments.ProcessPendingSquareRefunds(r.Context(), bookingID, "admin_cancelled")
}) }
if refundFailed || (refundResult != nil && refundResult.RefundableAmount > 0) {
resp := map[string]interface{}{
"message": "Booking cancelled",
}
if refundResult != nil && refundResult.RefundableAmount > 0 {
resp["refund_calculation"] = refundResult
}
if refundFailed {
resp["refund_failed"] = true
resp["warning"] = "Booking was cancelled but refund processing failed — please process refund manually or retry"
}
json.NewEncoder(w).Encode(resp)
return return
} }
@@ -606,13 +625,21 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
// Check if booking time falls within a closed exceptional hours period // Check if booking time falls within a closed exceptional hours period
// Calculate the Monday of the week containing the booking date // Calculate the Monday of the week containing the booking date
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert. // DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
weekday := int((req.StartTime.Weekday() + 6) % 7) localStart := req.StartTime.In(londonLocation)
daysToMonday := int(req.StartTime.Weekday()) weekday := int((localStart.Weekday() + 6) % 7)
daysToMonday := int(localStart.Weekday())
if daysToMonday == 0 { if daysToMonday == 0 {
daysToMonday = 7 // Sunday -> next Monday daysToMonday = 7 // Sunday -> next Monday
} }
weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour) tm := localStart.AddDate(0, 0, -daysToMonday+1)
bookingTime := req.StartTime.Format("15:04:05") // Use UTC midnight so the time.Time has Location=UTC at the London calendar date.
// tm has Location=London (from .In(londonLocation) above), so tm.Year/Month/Day()
// return London calendar values. Creating a UTC midnight of those values produces
// a Location=UTC time at the correct London calendar Monday. pgx's DATE codec
// extracts the calendar date from the time's own location — so this maps correctly
// to ega.week_start (DATE column), regardless of BST/GMT.
weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.UTC)
bookingTime := localStart.Format("15:04:05")
if !req.OutOfHours { if !req.OutOfHours {
// Check if there's an exceptional hours entry that makes this time unavailable // Check if there's an exceptional hours entry that makes this time unavailable
@@ -672,24 +699,35 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
} }
defer tx.Rollback(r.Context()) defer tx.Rollback(r.Context())
// Check for overlapping confirmed/in_progress/completed bookings (inside transaction) // Evict any pending_release bookings that overlap this slot.
var cnt int if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEnd); evictErr != nil {
err = tx.QueryRow(r.Context(), ` log.Printf("Failed to evict pending_release bookings for slot %s: %v", req.StartTime, evictErr)
SELECT COUNT(*) FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed') AND start_time < $2 AND end_time > $1 http.Error(w, "Internal server error", http.StatusInternalServerError)
`, req.StartTime, newEnd).Scan(&cnt) return
}
// Check for overlapping bookings (inside transaction)
overlapRows, err := tx.Query(r.Context(), `
SELECT 1 FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed') AND start_time < $2 AND end_time > $1
FOR UPDATE
`, req.StartTime, newEnd)
if err != nil { if err != nil {
log.Printf("Failed to check overlap: %v", err) log.Printf("Failed to check overlap: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
if cnt > 0 { var overlapCount int
http.Error(w, "Cannot create booking - time slot overlaps with existing booking", http.StatusConflict) for overlapRows.Next() {
overlapCount++
}
overlapRows.Close()
if err := overlapRows.Err(); err != nil {
log.Printf("Overlap row iteration error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
if overlapCount > 0 {
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEnd); evictErr != nil { http.Error(w, "Cannot create booking - time slot overlaps with existing booking", http.StatusConflict)
log.Printf("Failed to evict pending_release bookings (admin create): %v", evictErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
@@ -1321,7 +1359,7 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
// Query payment and timing info (used for validation AND auto-approval later) // Query payment and timing info (used for validation AND auto-approval later)
var hasPayments bool var hasPayments bool
hoursUntilCurrent := currentStartTime.Sub(time.Now()).Hours() hoursUntilCurrent := currentStartTime.Sub(clock.Now()).Hours()
db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND status = 'completed')", bookingID).Scan(&hasPayments) db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND status = 'completed')", bookingID).Scan(&hasPayments)
// Check if booking has discounts (affects auto-approval decisions) // Check if booking has discounts (affects auto-approval decisions)
@@ -1436,6 +1474,14 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
// Quick overlap check — block if slot is taken // Quick overlap check — block if slot is taken
newEnd := req.NewStartTime.Add(time.Duration(durMinutes) * time.Minute) newEnd := req.NewStartTime.Add(time.Duration(durMinutes) * time.Minute)
// Evict any pending_release bookings that overlap this slot.
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, *req.NewStartTime, newEnd); evictErr != nil {
log.Printf("Failed to evict pending_release bookings for slot %s: %v", *req.NewStartTime, evictErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var overlapCount int var overlapCount int
if err := tx.QueryRow(r.Context(), ` if err := tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings SELECT COUNT(*) FROM bookings
@@ -1470,7 +1516,7 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
argNum++ argNum++
} }
setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argNum)) setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argNum))
args = append(args, time.Now()) args = append(args, clock.Now())
argNum++ argNum++
args = append(args, bookingID) args = append(args, bookingID)
query := fmt.Sprintf("UPDATE bookings SET %s WHERE id = $%d", strings.Join(setClauses, ", "), argNum) query := fmt.Sprintf("UPDATE bookings SET %s WHERE id = $%d", strings.Join(setClauses, ", "), argNum)
@@ -1773,6 +1819,14 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
// Check for overlapping bookings if start time is being changed // Check for overlapping bookings if start time is being changed
if newStartTime != nil { if newStartTime != nil {
newEndTime := newStartTime.Add(time.Duration(durationMinutes) * time.Minute) newEndTime := newStartTime.Add(time.Duration(durationMinutes) * time.Minute)
// Evict any pending_release bookings that overlap this slot.
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, *newStartTime, newEndTime); evictErr != nil {
log.Printf("Failed to evict pending_release bookings for slot %s: %v", *newStartTime, evictErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var overlapCount int var overlapCount int
err = tx.QueryRow(r.Context(), ` err = tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings SELECT COUNT(*) FROM bookings
@@ -1812,13 +1866,20 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
} }
// Check working hours (admin gets warning) // Check working hours (admin gets warning)
weekday := int((newStartTime.Weekday() + 6) % 7) // newStartTime from the DB is UTC; convert to London for weekday/daysToMonday
bookingTime := newStartTime.Format("15:04:05") // so BST dates (e.g. 00:30 BST = 23:30 UTC previous day) compute correctly.
daysToMonday := int(newStartTime.Weekday()) localStart := newStartTime.In(londonLocation)
weekday := int((localStart.Weekday() + 6) % 7)
bookingTime := localStart.Format("15:04:05")
daysToMonday := int(localStart.Weekday())
if daysToMonday == 0 { if daysToMonday == 0 {
daysToMonday = 7 daysToMonday = 7 // Sunday -> next Monday
} }
weekStart := newStartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour) tm := localStart.AddDate(0, 0, -daysToMonday+1)
// Use UTC midnight for weekStart so PostgreSQL DATE comparison works
// correctly with TIMESTAMPTZ. London-midnight during BST = 23:00 UTC
// previous day, which would shift the DATE comparison by -1 day.
weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.UTC)
var isClosed bool var isClosed bool
err = tx.QueryRow(r.Context(), ` err = tx.QueryRow(r.Context(), `
@@ -1863,7 +1924,7 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
argNum++ argNum++
} }
setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argNum)) setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argNum))
args = append(args, time.Now()) args = append(args, clock.Now())
argNum++ argNum++
args = append(args, bookingID) args = append(args, bookingID)
@@ -2280,14 +2341,14 @@ func CountUnforgivenNoShows(ctx context.Context, userID string) (int, error) {
// ApplyDepositsIfNeeded checks if user has 2+ unforgiven no-shows // ApplyDepositsIfNeeded checks if user has 2+ unforgiven no-shows
// and applies 3 deposits if so. Returns true if deposits were applied. // and applies 3 deposits if so. Returns true if deposits were applied.
func ApplyDepositsIfNeeded(ctx context.Context, userID string) (bool, error) { func ApplyDepositsIfNeeded(ctx context.Context, q db.Querier, userID string) (bool, error) {
count, err := CountUnforgivenNoShows(ctx, userID) count, err := CountUnforgivenNoShows(ctx, userID)
if err != nil { if err != nil {
return false, err return false, err
} }
if count >= 2 { if count >= 2 {
// Apply 3 deposits // Apply 3 deposits
_, err := db.Conn.Exec(ctx, ` _, err := q.Exec(ctx, `
UPDATE users SET deposits_required = 3 WHERE id = $1 UPDATE users SET deposits_required = 3 WHERE id = $1
`, userID) `, userID)
if err != nil { if err != nil {
+579 -5
View File
@@ -6,11 +6,13 @@ package bookings
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strings" "strings"
"testing" "testing"
"time" "time"
"crussell/clock"
"crussell/db" "crussell/db"
"crussell/mw" "crussell/mw"
"crussell/testutils" "crussell/testutils"
@@ -26,7 +28,7 @@ import (
// Ensures at least 2 weeks out so all time-window checks (deposits, advance) // Ensures at least 2 weeks out so all time-window checks (deposits, advance)
// pass without interference. // pass without interference.
func weekdayTime(weekday time.Weekday, hour int) time.Time { func weekdayTime(weekday time.Weekday, hour int) time.Time {
now := time.Now().UTC() now := clock.Now().UTC()
daysAhead := int(weekday) - int(now.Weekday()) daysAhead := int(weekday) - int(now.Weekday())
if daysAhead <= 0 { if daysAhead <= 0 {
daysAhead += 7 daysAhead += 7
@@ -836,7 +838,7 @@ func TestAdminApproveEditRequest_OverlapWithBooking_Regression(t *testing.T) {
dur := durationMinutes(t, ctx, tx, serviceID) dur := durationMinutes(t, ctx, tx, serviceID)
// Use <48h from now so RequestEditHandler does NOT auto-approve // Use <48h from now so RequestEditHandler does NOT auto-approve
nearTime := time.Now().Add(40 * time.Hour) nearTime := clock.Now().Add(40 * time.Hour)
nearTime = time.Date(nearTime.Year(), nearTime.Month(), nearTime.Day(), nearTime.Hour(), 0, 0, 0, nearTime.Location()) nearTime = time.Date(nearTime.Year(), nearTime.Month(), nearTime.Day(), nearTime.Hour(), 0, 0, 0, nearTime.Location())
switch nearTime.Weekday() { switch nearTime.Weekday() {
case time.Sunday: case time.Sunday:
@@ -1008,13 +1010,15 @@ func TestAdminCreateBooking_OutOfHours_WithoutFlag_Fails(t *testing.T) {
baseTime := weekdayTime(time.Wednesday, 10) baseTime := weekdayTime(time.Wednesday, 10)
// Compute the Monday of the week containing baseTime // Compute the Monday of the week containing baseTime using London timezone
weekStart := baseTime.AddDate(0, 0, -int(baseTime.Weekday())+1) // for the weekday, but UTC midnight for the DATE — matching the handler's pattern.
londonBase := baseTime.In(londonLocation)
weekStart := londonBase.AddDate(0, 0, -int(londonBase.Weekday())+1)
weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, time.UTC) weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, time.UTC)
weekStartStr := weekStart.Format("2006-01-02") weekStartStr := weekStart.Format("2006-01-02")
// Compute weekday in DB format (0=Monday..6=Sunday) // Compute weekday in DB format (0=Monday..6=Sunday)
dbWeekday := int(baseTime.Weekday()) dbWeekday := int(londonBase.Weekday())
if dbWeekday == 0 { if dbWeekday == 0 {
dbWeekday = 6 dbWeekday = 6
} else { } else {
@@ -1262,3 +1266,573 @@ func TestAdminCreateBooking_OverlapWithCancelled_Allowed(t *testing.T) {
t.Errorf("expected 200/201 for overlapping completed booking, got %d. body: %s", w.Code, w.Body.String()) t.Errorf("expected 200/201 for overlapping completed booking, got %d. body: %s", w.Code, w.Body.String())
} }
} }
// TestAdminCreateBooking_Weekday_BST_Boundary verifies that AdminCreateBookingForUserHandler
// uses London timezone for the exceptional-hours weekday lookup (Issues 2+3 fix).
// At 23:30 UTC on a Sunday in BST (= 00:30 BST Monday), the handler should look up
// Monday's exceptional hours, not Sunday's. Sunday's row is deleted here, so without
// the fix the lookup would fail the handler. With the fix (London time weekday=Monday),
// the row exists and the handler succeeds.
func TestAdminCreateBooking_Weekday_BST_Boundary(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Delete Sunday's working hours so a UTC-weekday lookup (Sunday, DB weekday 6) fails.
_, err = tx.Exec(ctx, "DELETE FROM working_hours WHERE weekday = 6")
if err != nil {
t.Fatalf("failed to delete Sunday hours: %v", err)
}
// Book at 23:30 UTC on Sunday (= 00:30 BST Monday). Without London weekday,
// DB weekday = Sunday (6, row deleted). With London weekday = Monday (0, closed via EH).
sunday2330UTC := time.Date(2099, 6, 14, 23, 30, 0, 0, time.UTC)
// Compute weekStart the same way the handler does: from the booking time's
// London weekday, find Monday's date, store as UTC midnight.
bkLondon := sunday2330UTC.In(londonLocation) // 00:30 BST Monday
daysToMonday := int(bkLondon.Weekday()) // Monday in Go = 1
if daysToMonday == 0 {
daysToMonday = 7
}
tm := bkLondon.AddDate(0, 0, -daysToMonday+1)
weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.UTC)
weekStartStr := weekStart.Format("2006-01-02")
var groupID int
err = tx.QueryRow(ctx, `
INSERT INTO exceptional_working_hours_groups (name, description)
VALUES ('Test', '') RETURNING id
`).Scan(&groupID)
if err != nil {
t.Fatalf("failed to create group: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
VALUES ($1, 0, '00:00', '23:59', false)
`, groupID) // Monday (DB weekday 0) closed
if err != nil {
t.Fatalf("failed to seed exceptional hours: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO exceptional_group_applications (group_id, week_start)
VALUES ($1, $2::date)
`, groupID, weekStartStr)
if err != nil {
t.Fatalf("failed to seed application: %v", err)
}
body := AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: sunday2330UTC,
ServiceIDs: []string{serviceID},
}
w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body,
func(baseCtx context.Context) context.Context {
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
})
// The handler must reject the booking (BST boundary). It may use 400 or 409
// depending on whether it hits the working_hours lookup or the EH check first.
// The important thing is it does NOT return 200/500.
if w.Code == http.StatusOK || w.Code == http.StatusCreated {
t.Errorf("expected 4xx rejection at BST boundary, got %d. body: %s", w.Code, w.Body.String())
}
if w.Code == http.StatusInternalServerError {
t.Errorf("unexpected 500 — likely a DB lookup failed due to wrong weekday at BST boundary")
}
}
// ============================================================================
// pending_release eviction tests — every handler that calls
// EvictPendingReleaseOverlapping must be tested for correct eviction.
// ============================================================================
// TestUpdateBookingServices_ExtendEvictsPendingRelease verifies that extending
// a booking's duration into a pending_release slot evicts it (→ deposit_lapsed)
// rather than rejecting the extension.
func TestUpdateBookingServices_ExtendEvictsPendingRelease(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
dur := durationMinutes(t, ctx, tx, serviceID)
baseTime := weekdayTime(time.Wednesday, 10)
// Booking A — starts at 10:00, confirmed
bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
if err != nil {
t.Fatalf("failed to create booking A: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA)
if err != nil {
t.Fatalf("failed to confirm booking A: %v", err)
}
// Booking B — starts adjacent to A, set to pending_release
adjacentTime := baseTime.Add(time.Duration(dur) * time.Minute)
bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, adjacentTime)
if err != nil {
t.Fatalf("failed to create booking B: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", bookingB)
if err != nil {
t.Fatalf("failed to set booking B to pending_release: %v", err)
}
// Add a 30-min service to A — extends A into B's slot
shortSvcID, err := fixtures.CreateTestServiceWithDuration(tx, 30)
if err != nil {
t.Fatalf("failed to create short service: %v", err)
}
w := serveChiHandler(UpdateBookingServicesHandler, "PUT", "/"+bookingA, "/{id}", map[string]interface{}{
"service_ids": []string{serviceID, shortSvcID},
}, func(baseCtx context.Context) context.Context {
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200 after evicting pending_release, got %d. body: %s", w.Code, w.Body.String())
}
// Verify B was evicted to deposit_lapsed
var newStatus string
err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingB).Scan(&newStatus)
if err != nil {
t.Fatalf("failed to query booking B: %v", err)
}
if newStatus != "deposit_lapsed" {
t.Errorf("expected booking B to be evicted to 'deposit_lapsed', got %q", newStatus)
}
}
// TestEditBooking_EvictsPendingReleaseOnOverlap verifies that editing a booking's
// start time into a pending_release slot evicts it rather than blocking the edit.
func TestEditBooking_EvictsPendingReleaseOnOverlap(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = tx.Exec(ctx, "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)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
_ = durationMinutes(t, ctx, tx, serviceID)
baseTime := weekdayTime(time.Wednesday, 10)
// Create the user's own confirmed booking
bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
if err != nil {
t.Fatalf("failed to create booking A: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA)
if err != nil {
t.Fatalf("failed to confirm booking A: %v", err)
}
// Create a pending_release booking 1h later that A will be edited into
pendingStart := baseTime.Add(1 * time.Hour)
bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, pendingStart)
if err != nil {
t.Fatalf("failed to create booking B: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", bookingB)
if err != nil {
t.Fatalf("failed to set booking B to pending_release: %v", err)
}
// Edit booking A's time to overlap B's slot
w := makeRequest(http.HandlerFunc(EditBookingHandler), "PUT",
"/api/bookings/"+bookingA,
map[string]interface{}{
"start_time": pendingStart.Add(-15 * time.Minute).Format(time.RFC3339),
}, token, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 after evicting pending_release, got %d. body: %s", w.Code, w.Body.String())
}
// Verify B was evicted to deposit_lapsed
var newStatus string
err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingB).Scan(&newStatus)
if err != nil {
t.Fatalf("failed to query booking B: %v", err)
}
if newStatus != "deposit_lapsed" {
t.Errorf("expected booking B to be evicted to 'deposit_lapsed', got %q", newStatus)
}
}
// TestRequestEdit_AutoApprove_EvictsPendingRelease verifies that the
// RequestEditHandler auto-approve path evicts overlapping pending_release
// bookings when it changes the booking's start time.
func TestRequestEdit_AutoApprove_EvictsPendingRelease(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = tx.Exec(ctx, "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)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
_ = durationMinutes(t, ctx, tx, serviceID)
// Booking at a far-future time (>48h from now) so auto-approve triggers
farTime := clock.Now().Add(120 * time.Hour).Truncate(time.Second)
farTime = time.Date(farTime.Year(), farTime.Month(), farTime.Day(), 10, 0, 0, 0, farTime.Location())
bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create a pending_release booking at a slightly later time
pendingTime := farTime.Add(1 * time.Hour)
pendingBooking, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, pendingTime)
if err != nil {
t.Fatalf("failed to create pending_release booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", pendingBooking)
if err != nil {
t.Fatalf("failed to set pending_release: %v", err)
}
// Request edit to move A into B's slot — auto-approve should evict B
handler := http.HandlerFunc(RequestEditHandler)
w := makeRequest(handler, "POST", "/api/bookings/"+bookingA+"/edit-request",
map[string]interface{}{
"new_start_time": pendingTime.Format(time.RFC3339),
}, token, ctx)
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("expected 200/201 for auto-approved edit (pending_release evicted), got %d. body: %s", w.Code, w.Body.String())
}
// Verify pending_release was evicted to deposit_lapsed
var newStatus string
err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", pendingBooking).Scan(&newStatus)
if err != nil {
t.Fatalf("failed to query pending booking: %v", err)
}
if newStatus != "deposit_lapsed" {
t.Errorf("expected pending_release booking to be evicted to 'deposit_lapsed', got %q", newStatus)
}
}
// TestCreateBooking_ReservationDoesNotSelfBlock verifies that the reservation
// time_blocker created by the reserve step does NOT block CreateBookingHandler.
// The reservation cleanup must happen BEFORE CheckTimeBlockerOverlap.
func TestCreateBooking_ReservationDoesNotSelfBlock(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = tx.Exec(ctx, "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)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Use a far-future weekday so closing-time and deposit checks pass
future := clock.Now().Add(7 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
// Simulate the reserve step: create a RESERVATION time_blocker at this slot
// using the same description format as ReserveSlotHandler for logged-in users
desc := fmt.Sprintf("RESERVATION:user:%s:%d", userID, clock.Now().UnixNano())
var blockerID string
err = tx.QueryRow(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, $2, $3, $4)
RETURNING id
`, future, 60, desc, userID).Scan(&blockerID)
if err != nil {
t.Fatalf("failed to create reservation time_blocker: %v", err)
}
// Now call CreateBookingHandler — must succeed despite the reservation
w := makeRequest(http.HandlerFunc(CreateBookingHandler), "POST", "/api/bookings",
&CreateBookingRequest{
StartTime: future,
ServiceIDs: []string{serviceID},
}, token, ctx)
if w.Code == http.StatusConflict {
t.Fatalf("reservation time_blocker should NOT self-block CreateBookingHandler: got 409. body: %s", w.Body.String())
}
if w.Code != http.StatusCreated {
t.Fatalf("expected 201 after reservation cleanup, got %d. body: %s", w.Code, w.Body.String())
}
// Verify the reservation was also cleaned up inside the transaction
var remaining int
tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:%' AND start_time = $1", future).Scan(&remaining)
if remaining != 0 {
t.Errorf("expected reservation to be cleaned up, got %d remaining", remaining)
}
}
// TestCreateBooking_ReservationDoesNotSelfBlock_Anonymous verifies that an
// anonymous RESERVATION:anon: time_blocker (created_by = NULL) does NOT block
// CreateBookingHandler. This simulates an anonymous user who reserves a slot,
// then logs in and creates the booking.
func TestCreateBooking_ReservationDoesNotSelfBlock_Anonymous(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = tx.Exec(ctx, "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)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
future := clock.Now().Add(7 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
// Create an ANONYMOUS reservation (created_by = NULL) at this slot.
// This is what ReserveSlotHandler creates for anonymous users.
var blockerID string
err = tx.QueryRow(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, $2, $3, NULL)
RETURNING id
`, future, 60, fmt.Sprintf("RESERVATION:anon:testhash:%d", clock.Now().UnixNano())).Scan(&blockerID)
if err != nil {
t.Fatalf("failed to create anon reservation: %v", err)
}
// User is now logged in — CreateBookingHandler must clear the anon
// reservation via the start_time match.
w := makeRequest(http.HandlerFunc(CreateBookingHandler), "POST", "/api/bookings",
&CreateBookingRequest{
StartTime: future,
ServiceIDs: []string{serviceID},
}, token, ctx)
if w.Code == http.StatusConflict {
t.Fatalf("anonymous reservation should NOT self-block after login: got 409. body: %s", w.Body.String())
}
if w.Code != http.StatusCreated {
t.Fatalf("expected 201 for anon reservation cleanup, got %d. body: %s", w.Code, w.Body.String())
}
// Verify the anon reservation was cleaned up
var remaining int
tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE id = $1", blockerID).Scan(&remaining)
if remaining != 0 {
t.Errorf("expected anonymous reservation to be cleaned up, got %d remaining", remaining)
}
}
// TestCreateBooking_ReservationDoesNotSelfBlock_AnonRemainsIfNoStartMatch
// verifies that an anonymous RESERVATION for a DIFFERENT time slot is NOT
// deleted — only reservations at the exact start_time being booked.
func TestCreateBooking_ReservationDoesNotSelfBlock_AnonRemainsIfNoStartMatch(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = tx.Exec(ctx, "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)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
future := clock.Now().Add(7 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
// Create an anonymous reservation at a DIFFERENT time
differentTime := future.Add(2 * time.Hour)
var blockerID string
err = tx.QueryRow(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, $2, $3, NULL)
RETURNING id
`, differentTime, 60, fmt.Sprintf("RESERVATION:anon:testhash:%d", clock.Now().UnixNano())).Scan(&blockerID)
if err != nil {
t.Fatalf("failed to create anon reservation: %v", err)
}
// Book a DIFFERENT slot — the anon reservation should remain untouched
w := makeRequest(http.HandlerFunc(CreateBookingHandler), "POST", "/api/bookings",
&CreateBookingRequest{
StartTime: future,
ServiceIDs: []string{serviceID},
}, token, ctx)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201 for non-conflicting slot, got %d. body: %s", w.Code, w.Body.String())
}
// Verify the anon reservation at the other time was NOT deleted
var remaining int
tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE id = $1", blockerID).Scan(&remaining)
if remaining != 1 {
t.Errorf("expected anon reservation at different time to remain, got %d", remaining)
}
}
// TestAdminApproveEditRequest_EvictsPendingRelease verifies that approving an
// edit request evicts overlapping pending_release bookings at the new time slot.
func TestAdminApproveEditRequest_EvictsPendingRelease(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = tx.Exec(ctx, "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)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
dur := durationMinutes(t, ctx, tx, serviceID)
// Use a booking <48h from now so RequestEdit does NOT auto-approve
nearTime := clock.Now().Add(40 * time.Hour).Truncate(time.Second)
nearTime = time.Date(nearTime.Year(), nearTime.Month(), nearTime.Day(), 10, 0, 0, 0, nearTime.Location())
if nearTime.Weekday() == time.Sunday {
nearTime = nearTime.AddDate(0, 0, 2)
} else if nearTime.Weekday() == time.Monday {
nearTime = nearTime.AddDate(0, 0, 1)
}
bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, nearTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create an edit request to move A to a new time
newTime := nearTime.Add(2 * time.Hour)
reqHandler := http.HandlerFunc(RequestEditHandler)
w := makeRequest(reqHandler, "POST", "/api/bookings/"+bookingA+"/edit-request",
map[string]interface{}{
"new_start_time": newTime.Format(time.RFC3339),
}, token, ctx)
if w.Code != http.StatusCreated {
t.Fatalf("failed to create edit request: %d. body: %s", w.Code, w.Body.String())
}
// Get the edit request ID
var editRequestID string
err = tx.QueryRow(ctx, "SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingA).Scan(&editRequestID)
if err != nil {
t.Fatalf("failed to get edit request ID: %v", err)
}
// Create a pending_release booking at the NEW target time (overlapping the edit request)
pendingBooking, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, newTime)
if err != nil {
t.Fatalf("failed to create pending_release booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", pendingBooking)
if err != nil {
t.Fatalf("failed to set pending_release: %v", err)
}
_ = dur
// Approve the edit request as admin — should evict the pending_release
approveHandler := http.HandlerFunc(AdminApproveEditRequestHandler)
w = serveAdminHandler(approveHandler, "POST",
"/api/admin/bookings/"+bookingA+"/edit-requests/"+editRequestID+"/approve",
"/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil, ctx)
if w.Code != http.StatusOK && w.Code != http.StatusNoContent {
t.Fatalf("expected 200/204 for approve after evicting pending_release, got %d. body: %s", w.Code, w.Body.String())
}
// Verify pending_release was evicted to deposit_lapsed
var newStatus string
err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", pendingBooking).Scan(&newStatus)
if err != nil {
t.Fatalf("failed to query pending booking: %v", err)
}
if newStatus != "deposit_lapsed" {
t.Errorf("expected pending_release to be evicted to 'deposit_lapsed', got %q", newStatus)
}
}
+83 -28
View File
@@ -10,6 +10,7 @@ import (
"time" "time"
"crussell/auth" "crussell/auth"
"crussell/clock"
"crussell/db" "crussell/db"
"crussell/handlers/scheduling" "crussell/handlers/scheduling"
"crussell/internal/validators" "crussell/internal/validators"
@@ -103,7 +104,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
} }
// e. Validate start_time not in the past // e. Validate start_time not in the past
if req.StartTime.Before(time.Now()) { if req.StartTime.Before(clock.Now()) {
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest) http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
return return
} }
@@ -119,29 +120,14 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
localEnd := localStart.Add(time.Duration(svcDuration) * time.Minute) localEndLondon := localStart.Add(time.Duration(svcDuration) * time.Minute).In(londonLocation)
closeTime, _ := time.Parse("15:04:05", closeStr) if err := checkClosingHours(localEndLondon, closeStr); err != nil {
if localEnd.Hour() > closeTime.Hour() || (localEnd.Hour() == closeTime.Hour() && localEnd.Minute() > closeTime.Minute()) {
http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest) http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
return return
} }
// g. Check existing booking overlap (same query as CreateBookingHandler) // g. Check existing booking overlap (same query as CreateBookingHandler)
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute) endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
var cnt int
if err := db.Conn.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed')
AND start_time < $2
AND end_time > $1
`, req.StartTime, endTime).Scan(&cnt); err != nil {
log.Printf("Failed to check overlap: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if cnt > 0 {
http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict)
return
}
// h. Check time blocker overlap using scheduling.CheckTimeBlockerOverlap // h. Check time blocker overlap using scheduling.CheckTimeBlockerOverlap
blockerOverlap, _, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime) blockerOverlap, _, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime)
@@ -165,6 +151,34 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
} }
defer tx.Rollback(r.Context()) defer tx.Rollback(r.Context())
// Check booking overlap inside transaction (TOCTOU fix)
// pending_release is excluded — those bookings are evicted at creation time.
overlapRows, err := tx.Query(r.Context(), `
SELECT 1 FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed')
AND start_time < $2
AND end_time > $1
FOR UPDATE
`, req.StartTime, endTime)
if err != nil {
log.Printf("Failed to check overlap: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var cnt int
for overlapRows.Next() {
cnt++
}
overlapRows.Close()
if err := overlapRows.Err(); err != nil {
log.Printf("Overlap row iteration error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if cnt > 0 {
http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict)
return
}
// LOGGED IN: Delete existing reservation // LOGGED IN: Delete existing reservation
_, err = tx.Exec(r.Context(), ` _, err = tx.Exec(r.Context(), `
DELETE FROM time_blockers DELETE FROM time_blockers
@@ -177,7 +191,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
} }
// Insert new reservation // Insert new reservation
description := fmt.Sprintf("RESERVATION:user:%s:%d", userID, time.Now().UnixNano()) description := fmt.Sprintf("RESERVATION:user:%s:%d", userID, clock.Now().UnixNano())
err = tx.QueryRow(r.Context(), ` err = tx.QueryRow(r.Context(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
@@ -195,10 +209,22 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
} else { } else {
// ANONYMOUS: Check cap (50 in 10 minutes) // Calculate ipHash from IP address
tenMinutesAgo := time.Now().Add(-10 * time.Minute) ipHash := fmt.Sprintf("%x", md5.Sum([]byte(ip)))[:8]
// ANONYMOUS: Use transaction for atomic rate cap + overlap check + insert
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
// Check anon rate cap inside transaction
tenMinutesAgo := clock.Now().Add(-10 * time.Minute)
var anonCount int var anonCount int
if err := db.Conn.QueryRow(r.Context(), ` if err := tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM time_blockers SELECT COUNT(*) FROM time_blockers
WHERE description LIKE 'RESERVATION:anon:%' AND created_at > $1 WHERE description LIKE 'RESERVATION:anon:%' AND created_at > $1
`, tenMinutesAgo).Scan(&anonCount); err != nil { `, tenMinutesAgo).Scan(&anonCount); err != nil {
@@ -206,18 +232,41 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
if anonCount >= 50 { if anonCount >= 50 {
http.Error(w, "Too many active reservations. Please wait or log in.", http.StatusTooManyRequests) http.Error(w, "Too many active reservations. Please wait or log in.", http.StatusTooManyRequests)
return return
} }
// Calculate ipHash: first 8 chars of md5 hex of the IP string // Check overlap inside transaction
ipHash := fmt.Sprintf("%x", md5.Sum([]byte(ip)))[:8] // pending_release is excluded — those bookings are evicted at creation time.
description := fmt.Sprintf("RESERVATION:anon:%s:%d", ipHash, time.Now().UnixNano()) overlapRows, err := tx.Query(r.Context(), `
SELECT 1 FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed')
AND start_time < $2
AND end_time > $1
FOR UPDATE
`, req.StartTime, endTime)
if err != nil {
log.Printf("Failed to check overlap: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var anonCnt int
for overlapRows.Next() {
anonCnt++
}
overlapRows.Close()
if err := overlapRows.Err(); err != nil {
log.Printf("Overlap row iteration error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if anonCnt > 0 {
http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict)
return
}
// Insert new reservation with created_by = NULL description := fmt.Sprintf("RESERVATION:anon:%s:%d", ipHash, clock.Now().UnixNano())
err = db.Conn.QueryRow(r.Context(), ` err = tx.QueryRow(r.Context(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, $2, $3, NULL) VALUES ($1, $2, $3, NULL)
RETURNING id, created_at RETURNING id, created_at
@@ -227,6 +276,12 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
} }
// k. Calculate expires_at: logged-in = created_at + 1 hour, anon = created_at + 10 minutes // k. Calculate expires_at: logged-in = created_at + 1 hour, anon = created_at + 10 minutes
+180 -47
View File
@@ -13,6 +13,7 @@ import (
"testing" "testing"
"time" "time"
"crussell/clock"
"crussell/db" "crussell/db"
"crussell/handlers/scheduling" "crussell/handlers/scheduling"
"crussell/mw" "crussell/mw"
@@ -77,7 +78,7 @@ func TestReserveSlot_LoggedIn(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to create test service: %v", err) t.Fatalf("failed to create test service: %v", err)
} }
startTime := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) startTime := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
reqBody := ReserveSlotRequest{ reqBody := ReserveSlotRequest{
StartTime: startTime, StartTime: startTime,
@@ -117,7 +118,7 @@ func TestReserveSlot_LoggedIn_ReplacesExisting(t *testing.T) {
serviceIDs := []string{serviceID} serviceIDs := []string{serviceID}
// First reservation // First reservation
startTime1 := time.Now().Add(48 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) startTime1 := clock.Now().Add(48 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
w1 := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ w1 := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
StartTime: startTime1, StartTime: startTime1,
ServiceIDs: serviceIDs, ServiceIDs: serviceIDs,
@@ -127,7 +128,7 @@ func TestReserveSlot_LoggedIn_ReplacesExisting(t *testing.T) {
} }
// Second reservation (should replace first) // Second reservation (should replace first)
startTime2 := time.Now().Add(72 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour) startTime2 := clock.Now().Add(72 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour)
w2 := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ w2 := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
StartTime: startTime2, StartTime: startTime2,
ServiceIDs: serviceIDs, ServiceIDs: serviceIDs,
@@ -159,7 +160,7 @@ func TestReserveSlot_Anonymous(t *testing.T) {
t.Fatalf("failed to create test service: %v", err) t.Fatalf("failed to create test service: %v", err)
} }
serviceIDs := []string{serviceID} serviceIDs := []string{serviceID}
startTime := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) startTime := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
reqBody := ReserveSlotRequest{ reqBody := ReserveSlotRequest{
StartTime: startTime, StartTime: startTime,
@@ -202,7 +203,7 @@ func TestReserveSlot_ValidationErrors(t *testing.T) {
} }
// Missing service_ids // Missing service_ids
startTime := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) startTime := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
w = makeReserveRequest(ctx, "POST", "/api/bookings/reserve", map[string]interface{}{ w = makeReserveRequest(ctx, "POST", "/api/bookings/reserve", map[string]interface{}{
"start_time": startTime, "start_time": startTime,
}, "") }, "")
@@ -212,7 +213,7 @@ func TestReserveSlot_ValidationErrors(t *testing.T) {
// Past start_time // Past start_time
w = makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{ w = makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
StartTime: time.Now().Add(-1 * time.Hour), StartTime: clock.Now().Add(-1 * time.Hour),
ServiceIDs: serviceIDs, ServiceIDs: serviceIDs,
}, "") }, "")
if w.Code != http.StatusBadRequest { if w.Code != http.StatusBadRequest {
@@ -232,7 +233,7 @@ func TestReserveSlot_BlockedByExistingBooking(t *testing.T) {
// Create a fixture user and booking at the same time // Create a fixture user and booking at the same time
userID, _ := fixtures.CreateTestUser(tx) userID, _ := fixtures.CreateTestUser(tx)
bookingStart := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) bookingStart := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
_, err = tx.Exec(ctx, ` _, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, deposit_required) INSERT INTO bookings (user_id, start_time, status, deposit_required)
VALUES ($1, $2, 'confirmed', false) VALUES ($1, $2, 'confirmed', false)
@@ -260,7 +261,7 @@ func TestReserveSlot_BlockedByTimeBlocker(t *testing.T) {
t.Fatalf("failed to create test service: %v", err) t.Fatalf("failed to create test service: %v", err)
} }
blockerStart := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour) blockerStart := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
_, err = tx.Exec(ctx, ` _, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'Admin Blocked', NULL) VALUES ($1, 60, 'Admin Blocked', NULL)
@@ -291,7 +292,7 @@ func TestReserveSlot_DualCleanup(t *testing.T) {
_, err := tx.Exec(ctx, ` _, err := tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by) INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
VALUES ($1, 60, 'RESERVATION:anon:abc12345:1234', $2, NULL) VALUES ($1, 60, 'RESERVATION:anon:abc12345:1234', $2, NULL)
`, time.Now().Add(24*time.Hour), time.Now().Add(-15*time.Minute)) `, clock.Now().Add(24*time.Hour), clock.Now().Add(-15*time.Minute))
if err != nil { if err != nil {
t.Fatalf("failed to create old anon reservation: %v", err) t.Fatalf("failed to create old anon reservation: %v", err)
} }
@@ -300,7 +301,7 @@ func TestReserveSlot_DualCleanup(t *testing.T) {
_, err = tx.Exec(ctx, ` _, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by) INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
VALUES ($1, 60, 'RESERVATION:anon:def67890:1234', $2, NULL) VALUES ($1, 60, 'RESERVATION:anon:def67890:1234', $2, NULL)
`, time.Now().Add(48*time.Hour), time.Now().Add(-5*time.Minute)) `, clock.Now().Add(48*time.Hour), clock.Now().Add(-5*time.Minute))
if err != nil { if err != nil {
t.Fatalf("failed to create recent anon reservation: %v", err) t.Fatalf("failed to create recent anon reservation: %v", err)
} }
@@ -309,7 +310,7 @@ func TestReserveSlot_DualCleanup(t *testing.T) {
_, err = tx.Exec(ctx, fmt.Sprintf(` _, err = tx.Exec(ctx, fmt.Sprintf(`
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by) INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
VALUES ($1, 60, 'RESERVATION:user:%s:1234', $2, $3) VALUES ($1, 60, 'RESERVATION:user:%s:1234', $2, $3)
`, user1ID), time.Now().Add(72*time.Hour), time.Now().Add(-45*time.Minute), user1ID) `, user1ID), clock.Now().Add(72*time.Hour), clock.Now().Add(-45*time.Minute), user1ID)
if err != nil { if err != nil {
t.Fatalf("failed to create old user reservation: %v", err) t.Fatalf("failed to create old user reservation: %v", err)
} }
@@ -318,7 +319,7 @@ func TestReserveSlot_DualCleanup(t *testing.T) {
_, err = tx.Exec(ctx, fmt.Sprintf(` _, err = tx.Exec(ctx, fmt.Sprintf(`
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by) INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
VALUES ($1, 60, 'RESERVATION:user:%s:1234', $2, $3) VALUES ($1, 60, 'RESERVATION:user:%s:1234', $2, $3)
`, user2ID), time.Now().Add(96*time.Hour), time.Now().Add(-2*time.Hour), user2ID) `, user2ID), clock.Now().Add(96*time.Hour), clock.Now().Add(-2*time.Hour), user2ID)
if err != nil { if err != nil {
t.Fatalf("failed to create very old user reservation: %v", err) t.Fatalf("failed to create very old user reservation: %v", err)
} }
@@ -381,8 +382,11 @@ func seedCustomWorkingHours(t *testing.T, ctx context.Context, q db.Querier, hou
// nextWeekday returns the next occurrence of the given weekday (0=Sunday..6=Saturday) // nextWeekday returns the next occurrence of the given weekday (0=Sunday..6=Saturday)
// in the given location, at least 2 days from now to avoid "in the past" rejections. // in the given location, at least 2 days from now to avoid "in the past" rejections.
func nextWeekday(weekday time.Weekday, loc *time.Location) time.Time { func nextWeekday(weekday time.Weekday) time.Time {
now := time.Now().In(loc) // Use clock.Now() (UTC) so the returned time is consistent with
// handler comparisons that use clock.Now() — avoids BST/GMT drift
// when the handler checks deposit advance windows or working hours.
now := clock.Now()
daysAhead := int(weekday) - int(now.Weekday()) daysAhead := int(weekday) - int(now.Weekday())
if daysAhead <= 0 { if daysAhead <= 0 {
daysAhead += 7 daysAhead += 7
@@ -391,7 +395,7 @@ func nextWeekday(weekday time.Weekday, loc *time.Location) time.Time {
daysAhead += 7 daysAhead += 7
} }
next := now.AddDate(0, 0, daysAhead) next := now.AddDate(0, 0, daysAhead)
return time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, next.Location()) return time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, time.UTC)
} }
// TestReserveSlot_WeekdayConversion verifies that Go's time.Weekday (0=Sunday) // TestReserveSlot_WeekdayConversion verifies that Go's time.Weekday (0=Sunday)
@@ -421,12 +425,7 @@ func TestReserveSlot_WeekdayConversion(t *testing.T) {
t.Fatalf("failed to create test service: %v", err) t.Fatalf("failed to create test service: %v", err)
} }
london, err := time.LoadLocation("Europe/London") monday := nextWeekday(time.Monday)
if err != nil {
t.Fatalf("Europe/London not available: %v", err)
}
monday := nextWeekday(time.Monday, london)
tests := []struct { tests := []struct {
name string name string
@@ -484,12 +483,7 @@ func TestReserveSlot_ClosingHoursValidation(t *testing.T) {
t.Fatalf("failed to create test service: %v", err) t.Fatalf("failed to create test service: %v", err)
} }
london, err := time.LoadLocation("Europe/London") monday := nextWeekday(time.Monday)
if err != nil {
t.Fatalf("Europe/London not available: %v", err)
}
monday := nextWeekday(time.Monday, london)
thursday := monday.AddDate(0, 0, 3) thursday := monday.AddDate(0, 0, 3)
tests := []struct { tests := []struct {
@@ -516,9 +510,9 @@ func TestReserveSlot_ClosingHoursValidation(t *testing.T) {
} }
} }
// TestReserveSlot_UTCtoLondonConversion verifies that a UTC timestamp sent // TestReserveSlot_UTCtoLondonConversion verifies that the UTC-to-London
// from the browser is correctly interpreted as London local time for the // conversion correctly validates closing hours against the London wall-clock.
// purpose of working hours lookup. // Test times are in UTC; the handler converts to London time for comparison.
func TestReserveSlot_UTCtoLondonConversion(t *testing.T) { func TestReserveSlot_UTCtoLondonConversion(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
@@ -544,23 +538,17 @@ func TestReserveSlot_UTCtoLondonConversion(t *testing.T) {
t.Fatalf("failed to create test service: %v", err) t.Fatalf("failed to create test service: %v", err)
} }
london, err := time.LoadLocation("Europe/London") thursday := nextWeekday(time.Thursday)
if err != nil { thursday1730BST := thursday.Add(17*time.Hour + 30*time.Minute)
t.Fatalf("Europe/London not available: %v", err) thursday1930BST := thursday.Add(19*time.Hour + 30*time.Minute)
}
thursday := nextWeekday(time.Thursday, london)
// Convert to UTC for the request (frontend sends UTC)
thursday1730BST := thursday.Add(17*time.Hour + 30*time.Minute).In(london).UTC()
thursday1930BST := thursday.Add(19*time.Hour + 30*time.Minute).In(london).UTC()
tests := []struct { tests := []struct {
name string name string
startTime time.Time startTime time.Time
expectCode int expectCode int
}{ }{
{"17:30 BST Thursday (within 20:00 close)", thursday1730BST, http.StatusCreated}, {"17:30 UTC = 18:30 BST Thursday (within 20:00 close)", thursday1730BST, http.StatusCreated},
{"19:30 BST Thursday (past 20:00 close)", thursday1930BST, http.StatusBadRequest}, {"19:30 UTC = 20:30 BST Thursday (past 20:00 close)", thursday1930BST, http.StatusBadRequest},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -580,6 +568,112 @@ func TestReserveSlot_UTCtoLondonConversion(t *testing.T) {
// TestReserveSlot_DifferentClosingPerDay verifies that each day's closing // TestReserveSlot_DifferentClosingPerDay verifies that each day's closing
// time is used independently — a late-booking on a late-closing day should // time is used independently — a late-booking on a late-closing day should
// succeed while the same time on an early-closing day should fail. // succeed while the same time on an early-closing day should fail.
// TestReserveSlot_BlockedByExistingBooking_LoggedIn verifies that a logged-in
// user's reservation is rejected with 409 when the slot overlaps an existing
// booking. This tests the hasAuth transaction path with FOR UPDATE locking.
func TestReserveSlot_BlockedByExistingBooking_LoggedIn(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateUserToken(userID)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Create an existing booking at the same time slot
bookingStart := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
_, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, deposit_required)
VALUES ($1, $2, 'confirmed', false)
`, userID, bookingStart)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Try to reserve the same slot as logged-in user — goes through hasAuth tx path
w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
StartTime: bookingStart,
ServiceIDs: []string{serviceID},
}, token)
if w.Code != http.StatusConflict {
t.Errorf("logged-in overlap: expected 409, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestReserveSlot_AdjacentBooking_Allowed verifies that reserving a slot
// adjacent to (but not overlapping) an existing booking is allowed for
// both logged-in and anonymous users. This is the negative test for the
// overlap check — the FOR UPDATE locking should not block non-conflicting slots.
func TestReserveSlot_AdjacentBooking_Allowed(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateUserToken(userID)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Use a far-future weekday so closing-time and deposit checks pass
thursday := nextWeekday(time.Thursday).Add(21 * 24 * time.Hour)
bookingStart := time.Date(thursday.Year(), thursday.Month(), thursday.Day(), 10, 0, 0, 0, time.UTC)
svcDuration := durationMinutes(t, ctx, tx, serviceID)
bookingEnd := bookingStart.Add(time.Duration(svcDuration) * time.Minute)
// Create an existing booking at [bookingStart, bookingEnd)
_, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, deposit_required)
VALUES ($1, $2, 'confirmed', false)
`, userID, bookingStart)
if err != nil {
t.Fatalf("failed to create existing booking: %v", err)
}
// Try to reserve a slot that starts exactly when the existing booking ends
// This is ADJACENT (no overlap) — must be allowed.
adjacentStart := bookingEnd
adjacentEnd := adjacentStart.Add(time.Duration(svcDuration) * time.Minute)
_ = adjacentEnd // for documentation
w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
StartTime: adjacentStart,
ServiceIDs: []string{serviceID},
}, token)
if w.Code != http.StatusCreated {
t.Errorf("logged-in adjacent: expected 201 (adjacent, no overlap), got %d. body: %s", w.Code, w.Body.String())
}
// Also test anonymous user with the same adjacent slot
// Use a fresh time slot (don't reuse the now-booked one, since an anon
// reservation is a time_blocker not a booking, and doesn't conflict)
anonStart := adjacentStart.Add(2 * time.Hour)
_, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, deposit_required)
VALUES ($1, $2, 'confirmed', false)
`, userID, anonStart)
if err != nil {
t.Fatalf("failed to create booking for anon test: %v", err)
}
anonAdjacent := anonStart.Add(time.Duration(svcDuration) * time.Minute)
w2 := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
StartTime: anonAdjacent,
ServiceIDs: []string{serviceID},
}, "")
if w2.Code != http.StatusCreated {
t.Errorf("anon adjacent: expected 201 (adjacent, no overlap), got %d. body: %s", w2.Code, w2.Body.String())
}
}
func TestReserveSlot_DifferentClosingPerDay(t *testing.T) { func TestReserveSlot_DifferentClosingPerDay(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
@@ -605,12 +699,7 @@ func TestReserveSlot_DifferentClosingPerDay(t *testing.T) {
t.Fatalf("failed to create test service: %v", err) t.Fatalf("failed to create test service: %v", err)
} }
london, err := time.LoadLocation("Europe/London") wednesday := nextWeekday(time.Wednesday)
if err != nil {
t.Fatalf("Europe/London not available: %v", err)
}
wednesday := nextWeekday(time.Wednesday, london)
thursday := wednesday.AddDate(0, 0, 1) thursday := wednesday.AddDate(0, 0, 1)
tests := []struct { tests := []struct {
@@ -635,3 +724,47 @@ func TestReserveSlot_DifferentClosingPerDay(t *testing.T) {
}) })
} }
} }
// TestReserveSlot_PendingRelease_DoesNotBlock verifies that a pending_release
// booking does NOT block the reserve endpoint. pending_release bookings are
// evictable — eviction happens at creation time (CreateBookingHandler), not
// during the temporary reservation step.
func TestReserveSlot_PendingRelease_DoesNotBlock(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
token := jwt.GenerateUserToken(userID)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
future := clock.Now().Add(7 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
// Create a pending_release booking at this time slot
_, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, deposit_required)
VALUES ($1, $2, 'pending_release', false)
`, userID, future)
if err != nil {
t.Fatalf("failed to create pending_release booking: %v", err)
}
// Reserve the same slot — should succeed because pending_release
// does not block reservations (eviction happens at creation time).
w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
StartTime: future,
ServiceIDs: []string{serviceID},
}, token)
if w.Code == http.StatusConflict {
t.Errorf("pending_release should NOT block reservation it is evictable at creation time, got 409")
}
if w.Code != http.StatusCreated {
t.Errorf("expected 201 for slot with only pending_release overlap, got %d. body: %s", w.Code, w.Body.String())
}
}