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:
@@ -3,6 +3,7 @@ package bookings
|
||||
import (
|
||||
"context"
|
||||
"crussell/db"
|
||||
"crussell/clock"
|
||||
"crussell/handlers/notifications"
|
||||
"crussell/handlers/payments"
|
||||
"crussell/handlers/scheduling"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -28,7 +30,7 @@ import (
|
||||
var londonLocation = func() *time.Location {
|
||||
loc, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
panic("Europe/London timezone not available")
|
||||
panic("failed to load Europe/London timezone: " + err.Error())
|
||||
}
|
||||
return loc
|
||||
}()
|
||||
@@ -395,12 +397,13 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if req.StartDate != nil {
|
||||
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 {
|
||||
http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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++
|
||||
}
|
||||
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)
|
||||
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++
|
||||
}
|
||||
|
||||
@@ -760,13 +766,15 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if req.StartDate != nil {
|
||||
addCountWhere(fmt.Sprintf("b.start_time >= $%d", cp))
|
||||
st, _ := time.ParseInLocation("2006-01-02", *req.StartDate, londonLocation)
|
||||
countArgs = append(countArgs, time.Date(st.Year(), st.Month(), st.Day(), 0, 0, 0, 0, londonLocation))
|
||||
st, _ := time.Parse("2006-01-02", *req.StartDate)
|
||||
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 {
|
||||
addCountWhere(fmt.Sprintf("b.start_time <= $%d", cp))
|
||||
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 {
|
||||
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)
|
||||
|
||||
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
|
||||
err := db.Conn.QueryRow(r.Context(), `
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
SELECT COUNT(*) FROM bookings
|
||||
WHERE id != $1
|
||||
AND status IN ('confirmed', 'pending', 'in_progress', 'completed')
|
||||
AND start_time < $3
|
||||
AND end_time > $2
|
||||
`, bookingID, startTime, newEndTime).Scan(&overlapCount)
|
||||
if err != nil {
|
||||
`, bookingID, startTime, newEndTime).Scan(&overlapCount); err != nil {
|
||||
log.Printf("Failed to check overlap: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -1451,14 +1473,6 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
log.Printf("Failed to delete booking services for %s: %v", bookingID, err)
|
||||
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
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -2179,13 +2193,30 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
|
||||
localEnd := localStart.Add(time.Duration(svcDuration) * time.Minute)
|
||||
closeTime, _ := time.Parse("15:04:05", closeStr)
|
||||
if localEnd.Hour() > closeTime.Hour() || (localEnd.Hour() == closeTime.Hour() && localEnd.Minute() > closeTime.Minute()) {
|
||||
localEndLondon := localStart.Add(time.Duration(svcDuration) * time.Minute).In(londonLocation)
|
||||
if err := checkClosingHours(localEndLondon, closeStr); err != nil {
|
||||
http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
|
||||
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())
|
||||
if err != nil {
|
||||
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 != "" {
|
||||
needsApproval = true
|
||||
} else {
|
||||
london, _ := time.LoadLocation("Europe/London")
|
||||
now := time.Now().In(london)
|
||||
bookingDay := req.StartTime.In(london)
|
||||
if now.Year() == bookingDay.Year() && now.YearDay() == bookingDay.YearDay() {
|
||||
londonNow := clock.Now().In(londonLocation)
|
||||
londonBookingDay := req.StartTime.In(londonLocation)
|
||||
if londonNow.Year() == londonBookingDay.Year() && londonNow.YearDay() == londonBookingDay.YearDay() {
|
||||
needsApproval = true
|
||||
}
|
||||
}
|
||||
@@ -2371,7 +2401,7 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Start time is required", http.StatusBadRequest)
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -2392,16 +2422,6 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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())
|
||||
if err != nil {
|
||||
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())
|
||||
|
||||
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.
|
||||
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEndTime); evictErr != nil {
|
||||
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
|
||||
}
|
||||
|
||||
localStart := req.StartTime.In(londonLocation)
|
||||
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
|
||||
weekday := int((req.StartTime.Weekday() + 6) % 7)
|
||||
bookingTime := req.StartTime.Format("15:04:05")
|
||||
daysToMonday := int(req.StartTime.Weekday())
|
||||
weekday := int((localStart.Weekday() + 6) % 7)
|
||||
bookingTime := localStart.Format("15:04:05")
|
||||
daysToMonday := int(localStart.Weekday())
|
||||
if daysToMonday == 0 {
|
||||
daysToMonday = 7
|
||||
}
|
||||
tm := req.StartTime.AddDate(0, 0, -daysToMonday+1)
|
||||
weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, tm.Location())
|
||||
tm := localStart.AddDate(0, 0, -daysToMonday+1)
|
||||
// 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
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
@@ -2547,6 +2584,36 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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(¤tStatus); 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
|
||||
booking.User = &UserSummary{}
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
@@ -2568,6 +2635,9 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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.
|
||||
var patchTestIDs []string
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
if hasInPersonPayment {
|
||||
@@ -2783,6 +2853,10 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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 {
|
||||
var matches bool
|
||||
elapsed := time.Since(firstVisitDate)
|
||||
@@ -2813,7 +2887,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
`, c.id); err != nil {
|
||||
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 {
|
||||
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 {
|
||||
@@ -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())
|
||||
if err != nil {
|
||||
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())
|
||||
|
||||
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 {
|
||||
log.Printf("Failed to evict pending_release bookings on confirm: %v", evictErr)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
@@ -3127,7 +3202,7 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var calcErr error
|
||||
refundResult, calcErr = payments.ProcessCancellationRefund(
|
||||
r.Context(), bookingID, payInfo.TotalAmount, payInfo.TotalPaid,
|
||||
startTime, time.Now(), "client_cancelled", nil,
|
||||
startTime, clock.Now(), "client_cancelled", nil,
|
||||
)
|
||||
if calcErr != nil {
|
||||
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" {
|
||||
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
|
||||
|
||||
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 {
|
||||
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.
|
||||
if originalStatus == "confirmed" && startTime.Sub(time.Now()).Hours() < 24 && !(req.ForgiveNoShow != nil && *req.ForgiveNoShow) {
|
||||
if applied, err := ApplyDepositsIfNeeded(r.Context(), userID); err != nil {
|
||||
// Only apply for future bookings — past confirmed bookings should not
|
||||
// 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)
|
||||
} else if applied {
|
||||
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)
|
||||
totalPrice += price
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Row iteration error in GetBookingICalHandler: %v", err)
|
||||
}
|
||||
|
||||
serviceList := strings.Join(services, ", ")
|
||||
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 {
|
||||
uid := fmt.Sprintf("booking-%d@crussell.com", time.Now().UnixNano())
|
||||
dtstamp := time.Now().UTC().Format("20060102T150405Z")
|
||||
dtstart := start.Format("20060102T150405")
|
||||
dtend := end.Format("20060102T150405")
|
||||
uid := fmt.Sprintf("booking-%d@crussell.com", clock.Now().UnixNano())
|
||||
dtstamp := clock.Now().UTC().Format("20060102T150405Z")
|
||||
dtstart := start.UTC().Format("20060102T150405Z")
|
||||
dtend := end.UTC().Format("20060102T150405Z")
|
||||
|
||||
sanitizedServiceList := sanitizeICS(serviceList)
|
||||
sanitizedStatus := sanitizeICS(status)
|
||||
@@ -3812,7 +3899,12 @@ func GetBookingsByDateRangeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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(), `
|
||||
SELECT
|
||||
@@ -4015,7 +4107,7 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Start time is required", http.StatusBadRequest)
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -4087,14 +4179,21 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
weekday := int((req.StartTime.Weekday() + 6) % 7)
|
||||
bookingTime := req.StartTime.Format("15:04:05")
|
||||
daysToMonday := int(req.StartTime.Weekday())
|
||||
localStart := req.StartTime.In(londonLocation)
|
||||
weekday := int((localStart.Weekday() + 6) % 7)
|
||||
bookingTime := localStart.Format("15:04:05")
|
||||
daysToMonday := int(localStart.Weekday())
|
||||
if daysToMonday == 0 {
|
||||
daysToMonday = 7
|
||||
}
|
||||
tm := req.StartTime.AddDate(0, 0, -daysToMonday+1)
|
||||
weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, tm.Location())
|
||||
tm := localStart.AddDate(0, 0, -daysToMonday+1)
|
||||
// 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
|
||||
if err := db.Conn.QueryRow(r.Context(), `
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"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)
|
||||
serviceID := createTestService(t, 50.00, tx, ctx)
|
||||
|
||||
now := time.Now()
|
||||
now := clock.Now()
|
||||
startTime := now.Add(72 * time.Hour)
|
||||
bookingID := createPendingBooking(t, userID, serviceID, startTime, tx, ctx)
|
||||
|
||||
@@ -102,7 +103,7 @@ func TestProgressBooking_UserMilestoneDedup(t *testing.T) {
|
||||
// Give user 5 completed bookings
|
||||
svcID := createTestService(t, 30.00, tx, ctx)
|
||||
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)
|
||||
completeBooking(t, bid, ctx)
|
||||
}
|
||||
@@ -144,7 +145,7 @@ func TestNoShowApplyDepositsIfNeeded(t *testing.T) {
|
||||
serviceID := createTestService(t, 50.00, tx, ctx)
|
||||
|
||||
// Create 2 no-show bookings (confirmed bookings cancelled <24h before start)
|
||||
now := time.Now()
|
||||
now := clock.Now()
|
||||
for i := 0; i < 2; i++ {
|
||||
bid := createPendingBooking(t, userID, serviceID, now.Add(-time.Duration(i)*time.Hour), tx, ctx)
|
||||
insertPaymentForBooking(t, bid, userID, 5000, tx, ctx)
|
||||
@@ -156,7 +157,7 @@ func TestNoShowApplyDepositsIfNeeded(t *testing.T) {
|
||||
}
|
||||
|
||||
// Run ApplyDepositsIfNeeded
|
||||
applied, err := ApplyDepositsIfNeeded(ctx, userID)
|
||||
applied, err := ApplyDepositsIfNeeded(ctx, tx, userID)
|
||||
require.NoError(t, err)
|
||||
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)
|
||||
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)
|
||||
|
||||
_, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid)
|
||||
require.NoError(t, err)
|
||||
|
||||
applied, err := ApplyDepositsIfNeeded(ctx, userID)
|
||||
applied, err := ApplyDepositsIfNeeded(ctx, tx, userID)
|
||||
require.NoError(t, err)
|
||||
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)
|
||||
|
||||
// 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)
|
||||
|
||||
_, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", oldBid)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 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)
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", recentBid)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should only count 1 recent no-show, not trigger
|
||||
applied, err := ApplyDepositsIfNeeded(ctx, userID)
|
||||
applied, err := ApplyDepositsIfNeeded(ctx, tx, userID)
|
||||
require.NoError(t, err)
|
||||
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)
|
||||
|
||||
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)
|
||||
_, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid)
|
||||
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)
|
||||
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
|
||||
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)
|
||||
_, err := tx.Exec(ctx, "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid)
|
||||
require.NoError(t, err)
|
||||
@@ -271,7 +272,7 @@ func TestThreePaidBookingsClearNoShows(t *testing.T) {
|
||||
|
||||
// Complete 3 paid bookings — each should decrement deposits_required
|
||||
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)
|
||||
completeBooking(t, bid, ctx)
|
||||
}
|
||||
@@ -291,6 +292,6 @@ func TestThreePaidBookingsClearNoShows(t *testing.T) {
|
||||
`, userID).Scan(&forgivenCount)
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/testutils"
|
||||
"crussell/handlers/payments"
|
||||
@@ -142,7 +143,7 @@ func TestRequestEditHandler_NoticePeriod_BlocksPaymentUnder72h(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
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)
|
||||
soon := time.Now().Add(2 * time.Hour)
|
||||
soon := clock.Now().Add(2 * time.Hour)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon)
|
||||
if err != nil {
|
||||
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).
|
||||
future := time.Now().Add(48 * time.Hour)
|
||||
future := clock.Now().Add(48 * time.Hour)
|
||||
existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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{
|
||||
UserID: userID,
|
||||
StartTime: future,
|
||||
@@ -262,11 +264,11 @@ func TestAdminCreateBookingForUser_EvictsPendingReleaseOnOverlap(t *testing.T) {
|
||||
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
|
||||
})
|
||||
|
||||
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 201/200 for admin create, got %d: %s", w.Code, w.Body.String())
|
||||
if w.Code != http.StatusCreated {
|
||||
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
|
||||
err = tx.QueryRow(ctx,
|
||||
"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.
|
||||
future := time.Now().Add(96 * time.Hour)
|
||||
future := clock.Now().Add(96 * time.Hour)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
|
||||
if err != nil {
|
||||
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).
|
||||
midRange := time.Now().Add(48 * time.Hour)
|
||||
midRange := clock.Now().Add(48 * time.Hour)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, midRange)
|
||||
if err != nil {
|
||||
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)
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
farFuture := time.Now().Add(200 * time.Hour)
|
||||
farFuture := clock.Now().Add(200 * time.Hour)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farFuture)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
@@ -515,7 +517,7 @@ func TestDeleteBookingHandler_NoRefundForUnder24h(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
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)
|
||||
soon := time.Now().Add(1 * time.Hour)
|
||||
soon := clock.Now().Add(1 * time.Hour)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon)
|
||||
if err != nil {
|
||||
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).
|
||||
farFuture := time.Now().Add(200 * time.Hour)
|
||||
farFuture := clock.Now().Add(200 * time.Hour)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farFuture)
|
||||
if err != nil {
|
||||
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,
|
||||
time.Now().Add(100*time.Hour))
|
||||
clock.Now().Add(100*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
@@ -780,7 +782,7 @@ func TestAdminRescheduleBookingHandler_NormalReschedule(t *testing.T) {
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
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{}{
|
||||
"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)
|
||||
}
|
||||
|
||||
future := time.Now().Add(96 * time.Hour)
|
||||
future := clock.Now().Add(96 * time.Hour)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
|
||||
// Send request WITHOUT admin auth context.
|
||||
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 {
|
||||
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) {
|
||||
b := &Booking{
|
||||
TotalAmount: 100,
|
||||
StartTime: time.Now(),
|
||||
StartTime: clock.Now(),
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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{
|
||||
StartTime: nearTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
@@ -966,13 +968,10 @@ func TestCreateBooking_DepositAdvanceWindow_AllowsOver36h(t *testing.T) {
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
handler := http.HandlerFunc(CreateBookingHandler)
|
||||
|
||||
london, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
t.Fatalf("Europe/London not available: %v", err)
|
||||
}
|
||||
|
||||
// Use next Wednesday at 10:00 — always >72h from now, well past the 36h window.
|
||||
farTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour)
|
||||
// Use next weekday >48h from now during working hours (well past 36h deposit window).
|
||||
// Use 13:00 (1pm) to ensure the 36h deposit window from ANY overnight test time
|
||||
// is always cleared — 36h from midnight UTC Wednesday = 12:00 UTC Thursday.
|
||||
farTime := nextWeekday(time.Thursday).Add(13 * time.Hour)
|
||||
req := CreateBookingRequest{
|
||||
StartTime: farTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
@@ -1009,9 +1008,9 @@ func TestCreateBooking_DepositAdvanceWindow_SkipsWhenNoDepositRequired(t *testin
|
||||
|
||||
// Booking within 36h but with no deposits required — should be allowed.
|
||||
// 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 {
|
||||
midday = nextWeekday(time.Now().Weekday(), londonLocation).Add(12 * time.Hour)
|
||||
midday = nextWeekday(clock.Now().Weekday()).Add(12 * time.Hour)
|
||||
}
|
||||
nearTime := midday.Truncate(time.Second)
|
||||
req := CreateBookingRequest{
|
||||
@@ -1051,13 +1050,8 @@ func TestCreateBooking_EvictsPendingReleaseOnOverlap(t *testing.T) {
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
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.
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create existing booking: %v", err)
|
||||
@@ -1115,13 +1109,8 @@ func TestCreateBooking_LeftUnchangedWhenNoOverlapWithPendingRelease(t *testing.T
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
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.
|
||||
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)
|
||||
if err != nil {
|
||||
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.
|
||||
timeB := nextWeekday(time.Tuesday, london).Add(10 * time.Hour)
|
||||
timeB := nextWeekday(time.Tuesday).Add(10 * time.Hour)
|
||||
req := CreateBookingRequest{
|
||||
StartTime: timeB,
|
||||
ServiceIDs: []string{serviceID},
|
||||
@@ -1174,7 +1163,7 @@ func TestEvictPendingReleaseOverlapping_Basic(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
future := time.Now().Add(72 * time.Hour)
|
||||
future := clock.Now().Add(72 * time.Hour)
|
||||
existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
future := time.Now().Add(72 * time.Hour)
|
||||
future := clock.Now().Add(72 * time.Hour)
|
||||
existingBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
|
||||
if err != nil {
|
||||
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.
|
||||
future := time.Now().Add(72 * time.Hour)
|
||||
future := clock.Now().Add(72 * time.Hour)
|
||||
pendingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
|
||||
if err != nil {
|
||||
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.
|
||||
london, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
t.Fatalf("Europe/London not available: %v", err)
|
||||
}
|
||||
slotTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour)
|
||||
slotTime := nextWeekday(time.Wednesday).Add(10 * time.Hour)
|
||||
|
||||
pendingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, slotTime)
|
||||
if err != nil {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/testutils"
|
||||
"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 {
|
||||
t.Helper()
|
||||
var id string
|
||||
now := time.Now()
|
||||
now := clock.Now()
|
||||
startDate := 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))
|
||||
|
||||
// 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)
|
||||
|
||||
source, amount, exists := getDiscountForBooking(t, bookingID, ctx)
|
||||
@@ -327,7 +328,7 @@ func TestDiscount_Loyalty_ExistingRedemptionApplies(t *testing.T) {
|
||||
`, userID)
|
||||
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
|
||||
applyLoyaltyRedemption(t, bookingID, userID, ctx)
|
||||
@@ -364,11 +365,11 @@ func TestDiscount_Stacking_LoyaltyPlusMilestone(t *testing.T) {
|
||||
serviceID := createTestService(t, 100.00, tx, ctx)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
applyLoyaltyRedemption(t, bookingID, userID, ctx)
|
||||
@@ -397,10 +398,10 @@ func TestDiscount_Stacking_LoyaltyPlusAnniversary(t *testing.T) {
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
completeBooking(t, bookingID, ctx)
|
||||
@@ -430,10 +431,10 @@ func TestDiscount_Stacking_AllThreeTypes(t *testing.T) {
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
completeBooking(t, bookingID, ctx)
|
||||
@@ -469,14 +470,14 @@ func TestDiscount_Stacking_MultipleMilestones(t *testing.T) {
|
||||
for i := 0; i < 4; i++ {
|
||||
var startTime time.Time
|
||||
if i == 0 {
|
||||
startTime = time.Now().AddDate(0, 0, -400)
|
||||
startTime = clock.Now().AddDate(0, 0, -400)
|
||||
} 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)
|
||||
}
|
||||
|
||||
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)
|
||||
completeBooking(t, bookingID, ctx)
|
||||
|
||||
@@ -499,7 +500,7 @@ func TestDiscount_Stacking_LoyaltyPlusGlobalMilestone(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
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)
|
||||
insertInPersonCardPayment(t, bookingID, ctx)
|
||||
@@ -529,7 +530,7 @@ func TestDiscount_Stacking_DiscountAmountsSumCorrectly(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
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)
|
||||
completeBooking(t, bookingID, ctx)
|
||||
@@ -573,7 +574,7 @@ func TestDiscount_Stacking_MultiplePaymentRecords(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
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)
|
||||
completeBooking(t, bookingID, ctx)
|
||||
@@ -606,7 +607,7 @@ func TestDiscount_Stacking_MultipleBookingDiscountRows(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
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)
|
||||
completeBooking(t, bookingID, ctx)
|
||||
@@ -671,13 +672,13 @@ func TestDiscount_Stacking_TimeBasedPlusMilestone(t *testing.T) {
|
||||
serviceID := createTestService(t, 100.00, tx, ctx)
|
||||
|
||||
// 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)
|
||||
|
||||
startTime2 := time.Now().AddDate(0, 0, -7)
|
||||
startTime2 := clock.Now().AddDate(0, 0, -7)
|
||||
_ = 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)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
var paymentCount int
|
||||
@@ -743,7 +744,7 @@ func TestDiscount_CampaignMaxRedemptions(t *testing.T) {
|
||||
userID2 := createTestUser(t, 0, 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)
|
||||
|
||||
var discountCount1 int
|
||||
@@ -756,7 +757,7 @@ func TestDiscount_CampaignMaxRedemptions(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
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)
|
||||
|
||||
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 {
|
||||
t.Helper()
|
||||
var id string
|
||||
now := time.Now()
|
||||
now := clock.Now()
|
||||
startDate := now.Add(-24 * time.Hour)
|
||||
endDate := now.Add(24 * time.Hour)
|
||||
|
||||
@@ -798,7 +799,7 @@ func TestDiscount_ExpiredRedemptionDoesNotApply(t *testing.T) {
|
||||
`, userID)
|
||||
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)
|
||||
|
||||
count := getDiscountRowCount(t, bookingID, tx, ctx)
|
||||
@@ -823,7 +824,7 @@ func TestDiscount_MultiplePendingRedemptions_UsesOldest(t *testing.T) {
|
||||
`, userID)
|
||||
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
|
||||
applyLoyaltyRedemption(t, bookingID, userID, ctx)
|
||||
@@ -860,7 +861,7 @@ func TestDiscount_StampCountAboveTen(t *testing.T) {
|
||||
serviceID := createTestService(t, 50.00, tx, ctx)
|
||||
|
||||
// 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)
|
||||
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")
|
||||
|
||||
// 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)
|
||||
// Apply redemption while booking is still confirmed (not yet completed)
|
||||
@@ -898,7 +899,7 @@ func TestDiscount_RedemptionAppliedBeforeStampIncrement(t *testing.T) {
|
||||
`, userID)
|
||||
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
|
||||
applyLoyaltyRedemption(t, bookingID, userID, ctx)
|
||||
@@ -922,20 +923,20 @@ func TestDiscount_NormalEarn_NoRedemption(t *testing.T) {
|
||||
userID := createTestUser(t, 5, 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)
|
||||
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, getPendingRedemptions(t, userID, ctx), "No pending redemption (< 10)")
|
||||
|
||||
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)
|
||||
assert.Equal(t, 7, getStamps(t, userID, ctx), "6 + 1 = 7")
|
||||
assert.Equal(t, 0, getDiscountRowCount(t, booking2, tx, ctx), "No discounts applied")
|
||||
|
||||
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)
|
||||
assert.Equal(t, 8, getStamps(t, userID, ctx), "7 + 1 = 8")
|
||||
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))
|
||||
|
||||
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)
|
||||
_, err = tx.Exec(ctx, `
|
||||
@@ -1000,7 +1001,7 @@ func TestDiscount_RedemptionBeforeDeposit_DiscountLockedIn(t *testing.T) {
|
||||
`, userID)
|
||||
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)
|
||||
applyLoyaltyRedemption(t, bookingID, userID, ctx)
|
||||
@@ -1054,7 +1055,7 @@ func TestDiscount_MultipleEarnCycles(t *testing.T) {
|
||||
assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx))
|
||||
|
||||
// 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)
|
||||
assert.Equal(t, 0, getStamps(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))
|
||||
|
||||
// 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)
|
||||
|
||||
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)
|
||||
VALUES ($1, $2, 'confirmed')
|
||||
RETURNING id
|
||||
`, userID, time.Now().Add(24*time.Hour)).Scan(&bookingID)
|
||||
`, userID, clock.Now().Add(24*time.Hour)).Scan(&bookingID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 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)
|
||||
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)
|
||||
|
||||
count := getDiscountRowCount(t, bookingID, tx, ctx)
|
||||
@@ -1152,7 +1153,7 @@ func TestDiscount_CampaignBoundaryEnd(t *testing.T) {
|
||||
userID := createTestUser(t, 0, 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)
|
||||
|
||||
count := getDiscountRowCount(t, bookingID, tx, ctx)
|
||||
@@ -1173,7 +1174,7 @@ func TestDiscount_CampaignExpiredDoesNotApply(t *testing.T) {
|
||||
userID := createTestUser(t, 0, 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)
|
||||
|
||||
count := getDiscountRowCount(t, bookingID, tx, ctx)
|
||||
@@ -1188,7 +1189,7 @@ func TestDiscount_CampaignDraftDoesNotApply(t *testing.T) {
|
||||
userID := createTestUser(t, 0, 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)
|
||||
|
||||
count := getDiscountRowCount(t, bookingID, tx, ctx)
|
||||
@@ -1203,7 +1204,7 @@ func TestDiscount_CampaignCancelledDoesNotApply(t *testing.T) {
|
||||
userID := createTestUser(t, 0, 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)
|
||||
|
||||
count := getDiscountRowCount(t, bookingID, tx, ctx)
|
||||
@@ -1224,7 +1225,7 @@ func TestDiscount_PriceOverrideRespected(t *testing.T) {
|
||||
INSERT INTO bookings (user_id, start_time, status)
|
||||
VALUES ($1, $2, 'confirmed')
|
||||
RETURNING id
|
||||
`, userID, time.Now().Add(24*time.Hour)).Scan(&bookingID)
|
||||
`, userID, clock.Now().Add(24*time.Hour)).Scan(&bookingID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
@@ -1254,7 +1255,7 @@ func TestDiscount_AnniversaryDedupWithStacking(t *testing.T) {
|
||||
serviceID := createTestService(t, 60.00, tx, ctx)
|
||||
|
||||
// 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
|
||||
err := tx.QueryRow(ctx, `
|
||||
INSERT INTO bookings (user_id, start_time, status)
|
||||
@@ -1270,14 +1271,14 @@ func TestDiscount_AnniversaryDedupWithStacking(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// 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)
|
||||
|
||||
discounts1 := getAllDiscountsForBooking(t, bookingID1, ctx)
|
||||
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)
|
||||
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)
|
||||
|
||||
discounts2 := getAllDiscountsForBooking(t, bookingID2, ctx)
|
||||
@@ -1305,19 +1306,19 @@ func TestDiscount_PerUserMilestoneDedupWithStacking(t *testing.T) {
|
||||
|
||||
// Create 2 prior completed bookings on different days
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
discounts3 := getAllDiscountsForBooking(t, bookingID3, ctx)
|
||||
assert.Equal(t, 2, len(discounts3), "3rd booking should get per-user milestone + time_based")
|
||||
|
||||
// 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)
|
||||
|
||||
discounts4 := getAllDiscountsForBooking(t, bookingID4, ctx)
|
||||
@@ -1346,12 +1347,12 @@ func TestDiscount_GlobalMilestoneMaxRedemptionsWithStacking(t *testing.T) {
|
||||
|
||||
// Create 4 completed bookings (different days, user1)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
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")
|
||||
|
||||
// 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)
|
||||
completeBooking(t, bookingID6, ctx)
|
||||
|
||||
@@ -1383,7 +1384,7 @@ func TestDiscount_BestTimeBasedCampaignSelected(t *testing.T) {
|
||||
userID := createTestUser(t, 0, 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)
|
||||
|
||||
count := getDiscountRowCount(t, bookingID, tx, ctx)
|
||||
@@ -1401,7 +1402,7 @@ func TestDiscount_FirstBookingEarnsStamp(t *testing.T) {
|
||||
userID := createTestUser(t, 0, 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)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
count := getDiscountRowCount(t, bookingID, tx, ctx)
|
||||
@@ -1487,7 +1488,7 @@ func TestCampaign_CompleteActive(t *testing.T) {
|
||||
userID := createTestUser(t, 0, 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)
|
||||
|
||||
count := getDiscountRowCount(t, bookingID, tx, ctx)
|
||||
@@ -1511,7 +1512,7 @@ func TestCampaign_CancelActive(t *testing.T) {
|
||||
userID := createTestUser(t, 0, 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)
|
||||
|
||||
count := getDiscountRowCount(t, bookingID, tx, ctx)
|
||||
@@ -1535,7 +1536,7 @@ func TestCampaign_RevertToDraft(t *testing.T) {
|
||||
userID := createTestUser(t, 0, 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)
|
||||
|
||||
count := getDiscountRowCount(t, bookingID, tx, ctx)
|
||||
@@ -1550,7 +1551,7 @@ func TestCampaign_DraftDoesNotApplyDiscounts(t *testing.T) {
|
||||
userID := createTestUser(t, 0, 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)
|
||||
|
||||
count := getDiscountRowCount(t, bookingID, tx, ctx)
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/testutils"
|
||||
"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
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
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())
|
||||
reqBody2 := map[string]interface{}{
|
||||
"notes": secondNotes,
|
||||
@@ -538,7 +539,7 @@ func TestRequestEditHandler_WithServices(t *testing.T) {
|
||||
if err != nil {
|
||||
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())
|
||||
|
||||
handler := http.HandlerFunc(RequestEditHandler)
|
||||
@@ -615,7 +616,7 @@ func TestDeleteEditRequestHandler_Success(t *testing.T) {
|
||||
_, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx)
|
||||
_ = 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())
|
||||
|
||||
createHandler := http.HandlerFunc(RequestEditHandler)
|
||||
@@ -765,7 +766,7 @@ func TestGetMyEditRequestHandler_Success(t *testing.T) {
|
||||
_, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx)
|
||||
_ = 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())
|
||||
|
||||
// Create edit request first
|
||||
@@ -887,7 +888,7 @@ func TestGetMyEditRequestsHandler_Success(t *testing.T) {
|
||||
userID, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx)
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create second booking: %v", err)
|
||||
@@ -1085,7 +1086,7 @@ func TestAdminGetBookingEditRequestHandler_Success(t *testing.T) {
|
||||
_ = serviceID
|
||||
_ = 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())
|
||||
|
||||
// Create edit request
|
||||
@@ -1151,7 +1152,7 @@ func TestAdminApproveEditRequestHandler_TimeChange(t *testing.T) {
|
||||
_, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx)
|
||||
_ = 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())
|
||||
|
||||
// Create edit request
|
||||
@@ -1234,7 +1235,7 @@ func TestAdminApproveEditRequestHandler_WithServices(t *testing.T) {
|
||||
if err != nil {
|
||||
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())
|
||||
|
||||
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
|
||||
|
||||
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())
|
||||
|
||||
// Create edit request
|
||||
@@ -1434,7 +1435,7 @@ func TestRequestEditHandler_TimeBlockerCreated(t *testing.T) {
|
||||
|
||||
_, _, 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())
|
||||
|
||||
createHandler := http.HandlerFunc(RequestEditHandler)
|
||||
@@ -1500,10 +1501,10 @@ func TestRequestEditHandler_TimeBlockerReplacedOnUpsert(t *testing.T) {
|
||||
|
||||
_, _, 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())
|
||||
|
||||
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())
|
||||
|
||||
handler := http.HandlerFunc(RequestEditHandler)
|
||||
@@ -1638,7 +1639,7 @@ func TestAdminApproveEditRequestHandler_OverlapWithBooking(t *testing.T) {
|
||||
// Create first booking at time T
|
||||
// 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.
|
||||
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())
|
||||
|
||||
booking1, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
||||
@@ -1699,7 +1700,7 @@ func TestAdminApproveEditRequestHandler_TimeBlockerDeletedOnApprove(t *testing.T
|
||||
_ = serviceID
|
||||
_ = 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())
|
||||
|
||||
handler := http.HandlerFunc(RequestEditHandler)
|
||||
@@ -1740,7 +1741,7 @@ func TestAdminRejectEditRequestHandler_TimeBlockerDeletedOnReject(t *testing.T)
|
||||
_, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx)
|
||||
_ = 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())
|
||||
|
||||
handler := http.HandlerFunc(RequestEditHandler)
|
||||
@@ -1786,7 +1787,7 @@ func TestUserCancelBookingHandler_CleansUpEditRequest(t *testing.T) {
|
||||
_, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx)
|
||||
_ = 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())
|
||||
|
||||
// Create edit request (creates time_blocker + admin_notification)
|
||||
@@ -1982,7 +1983,7 @@ func TestGetMyEditRequestHandler_EndTimeCalculation(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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())
|
||||
|
||||
createHandler := http.HandlerFunc(RequestEditHandler)
|
||||
@@ -2114,7 +2115,7 @@ func TestAdminGetBookingEditRequestHandler_EnrichedData(t *testing.T) {
|
||||
_, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx)
|
||||
_ = 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())
|
||||
|
||||
// Create edit request
|
||||
@@ -2197,7 +2198,7 @@ func TestGetMyEditRequestHandler_WithOverrides(t *testing.T) {
|
||||
|
||||
// Create edit request (time change only — services change blocked for overrides,
|
||||
// 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())
|
||||
|
||||
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)
|
||||
bookingIDs := []string{bookingID}
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
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())
|
||||
|
||||
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())
|
||||
|
||||
handler := http.HandlerFunc(RequestEditHandler)
|
||||
|
||||
@@ -3,6 +3,7 @@ package bookings
|
||||
import (
|
||||
"context"
|
||||
"crussell/db"
|
||||
"crussell/clock"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"crussell/handlers/notifications"
|
||||
"crussell/handlers/payments"
|
||||
@@ -61,7 +62,7 @@ func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
UPDATE bookings
|
||||
SET status = 'client_cancelled', updated_at = $1
|
||||
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 {
|
||||
log.Printf("Failed to cancel booking %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
@@ -145,11 +146,9 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Process refund FIRST, before the cancel transaction. If the refund fails,
|
||||
// the booking stays active and the admin can retry. This mirrors the
|
||||
// DeleteBookingHandler pattern — the booking status change is independent
|
||||
// of the refund execution.
|
||||
// Fetch payment info before the transaction (read-only, no side effects).
|
||||
var refundResult *payments.RefundCalculationResult
|
||||
var refundFailed bool
|
||||
paySvc := payments.NewPaymentService()
|
||||
payInfo, payErr := paySvc.GetBookingPaymentInfo(r.Context(), bookingID)
|
||||
if payErr != nil {
|
||||
@@ -159,21 +158,7 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
totalAmount := payInfo.TotalAmount
|
||||
totalPaid := payInfo.TotalPaid
|
||||
if totalPaid > 0 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
calculatedRefund := totalPaid > 0 && !forgiveFees
|
||||
|
||||
tx, err := db.Conn.Begin(r.Context())
|
||||
if err != nil {
|
||||
@@ -183,11 +168,11 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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 bookingUserID string
|
||||
err = tx.QueryRow(r.Context(), "SELECT status, user_id FROM bookings WHERE id = $1", bookingID).Scan(&originalStatus, &bookingUserID)
|
||||
if err != nil {
|
||||
if err := tx.QueryRow(r.Context(), "SELECT status, user_id FROM bookings WHERE id = $1 FOR UPDATE", bookingID).Scan(&originalStatus, &bookingUserID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Booking not cancellable", http.StatusNotFound)
|
||||
return
|
||||
@@ -201,7 +186,7 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
UPDATE bookings
|
||||
SET status = 'we_cancelled', updated_at = $1
|
||||
WHERE id = $2 AND status IN ('pending', 'confirmed', 'in_progress')
|
||||
`, time.Now(), bookingID)
|
||||
`, clock.Now(), bookingID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to admin cancel booking %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
@@ -213,6 +198,27 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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 _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO forgiven_no_shows (booking_id, forgiven_by)
|
||||
@@ -266,11 +272,24 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if refundResult != nil && refundResult.RefundableAmount > 0 {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": "Booking cancelled",
|
||||
"refund_calculation": refundResult,
|
||||
})
|
||||
// Process pending Square refunds after the transaction commits successfully.
|
||||
// This ensures Square API calls only happen if the DB records persist.
|
||||
if calculatedRefund {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -606,13 +625,21 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if booking time falls within a closed exceptional hours period
|
||||
// Calculate the Monday of the week containing the booking date
|
||||
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
|
||||
weekday := int((req.StartTime.Weekday() + 6) % 7)
|
||||
daysToMonday := int(req.StartTime.Weekday())
|
||||
localStart := req.StartTime.In(londonLocation)
|
||||
weekday := int((localStart.Weekday() + 6) % 7)
|
||||
daysToMonday := int(localStart.Weekday())
|
||||
if daysToMonday == 0 {
|
||||
daysToMonday = 7 // Sunday -> next Monday
|
||||
}
|
||||
weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
|
||||
bookingTime := req.StartTime.Format("15:04:05")
|
||||
tm := localStart.AddDate(0, 0, -daysToMonday+1)
|
||||
// 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 {
|
||||
// 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())
|
||||
|
||||
// Check for overlapping confirmed/in_progress/completed bookings (inside transaction)
|
||||
var cnt int
|
||||
err = tx.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, newEnd).Scan(&cnt)
|
||||
// Evict any pending_release bookings that overlap this slot.
|
||||
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEnd); evictErr != nil {
|
||||
log.Printf("Failed to evict pending_release bookings for slot %s: %v", req.StartTime, evictErr)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
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 {
|
||||
log.Printf("Failed to check overlap: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if cnt > 0 {
|
||||
http.Error(w, "Cannot create booking - time slot overlaps with existing booking", http.StatusConflict)
|
||||
var overlapCount int
|
||||
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
|
||||
}
|
||||
|
||||
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEnd); evictErr != nil {
|
||||
log.Printf("Failed to evict pending_release bookings (admin create): %v", evictErr)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
if overlapCount > 0 {
|
||||
http.Error(w, "Cannot create booking - time slot overlaps with existing booking", http.StatusConflict)
|
||||
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)
|
||||
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)
|
||||
|
||||
// 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
|
||||
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
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
SELECT COUNT(*) FROM bookings
|
||||
@@ -1470,7 +1516,7 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
||||
argNum++
|
||||
}
|
||||
setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argNum))
|
||||
args = append(args, time.Now())
|
||||
args = append(args, clock.Now())
|
||||
argNum++
|
||||
args = append(args, bookingID)
|
||||
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
|
||||
if newStartTime != nil {
|
||||
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
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
SELECT COUNT(*) FROM bookings
|
||||
@@ -1812,13 +1866,20 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Check working hours (admin gets warning)
|
||||
weekday := int((newStartTime.Weekday() + 6) % 7)
|
||||
bookingTime := newStartTime.Format("15:04:05")
|
||||
daysToMonday := int(newStartTime.Weekday())
|
||||
// newStartTime from the DB is UTC; convert to London for weekday/daysToMonday
|
||||
// so BST dates (e.g. 00:30 BST = 23:30 UTC previous day) compute correctly.
|
||||
localStart := newStartTime.In(londonLocation)
|
||||
weekday := int((localStart.Weekday() + 6) % 7)
|
||||
bookingTime := localStart.Format("15:04:05")
|
||||
daysToMonday := int(localStart.Weekday())
|
||||
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
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
@@ -1863,7 +1924,7 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
argNum++
|
||||
}
|
||||
setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argNum))
|
||||
args = append(args, time.Now())
|
||||
args = append(args, clock.Now())
|
||||
argNum++
|
||||
|
||||
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
|
||||
// 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)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if count >= 2 {
|
||||
// Apply 3 deposits
|
||||
_, err := db.Conn.Exec(ctx, `
|
||||
_, err := q.Exec(ctx, `
|
||||
UPDATE users SET deposits_required = 3 WHERE id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,11 +6,13 @@ package bookings
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/mw"
|
||||
"crussell/testutils"
|
||||
@@ -26,7 +28,7 @@ import (
|
||||
// Ensures at least 2 weeks out so all time-window checks (deposits, advance)
|
||||
// pass without interference.
|
||||
func weekdayTime(weekday time.Weekday, hour int) time.Time {
|
||||
now := time.Now().UTC()
|
||||
now := clock.Now().UTC()
|
||||
daysAhead := int(weekday) - int(now.Weekday())
|
||||
if daysAhead <= 0 {
|
||||
daysAhead += 7
|
||||
@@ -836,7 +838,7 @@ func TestAdminApproveEditRequest_OverlapWithBooking_Regression(t *testing.T) {
|
||||
dur := durationMinutes(t, ctx, tx, serviceID)
|
||||
|
||||
// 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())
|
||||
switch nearTime.Weekday() {
|
||||
case time.Sunday:
|
||||
@@ -1008,13 +1010,15 @@ func TestAdminCreateBooking_OutOfHours_WithoutFlag_Fails(t *testing.T) {
|
||||
|
||||
baseTime := weekdayTime(time.Wednesday, 10)
|
||||
|
||||
// Compute the Monday of the week containing baseTime
|
||||
weekStart := baseTime.AddDate(0, 0, -int(baseTime.Weekday())+1)
|
||||
// Compute the Monday of the week containing baseTime using London timezone
|
||||
// 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)
|
||||
weekStartStr := weekStart.Format("2006-01-02")
|
||||
|
||||
// Compute weekday in DB format (0=Monday..6=Sunday)
|
||||
dbWeekday := int(baseTime.Weekday())
|
||||
dbWeekday := int(londonBase.Weekday())
|
||||
if dbWeekday == 0 {
|
||||
dbWeekday = 6
|
||||
} 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())
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"crussell/auth"
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/handlers/scheduling"
|
||||
"crussell/internal/validators"
|
||||
@@ -103,7 +104,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
@@ -119,29 +120,14 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
localEnd := localStart.Add(time.Duration(svcDuration) * time.Minute)
|
||||
closeTime, _ := time.Parse("15:04:05", closeStr)
|
||||
if localEnd.Hour() > closeTime.Hour() || (localEnd.Hour() == closeTime.Hour() && localEnd.Minute() > closeTime.Minute()) {
|
||||
localEndLondon := localStart.Add(time.Duration(svcDuration) * time.Minute).In(londonLocation)
|
||||
if err := checkClosingHours(localEndLondon, closeStr); err != nil {
|
||||
http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// g. Check existing booking overlap (same query as CreateBookingHandler)
|
||||
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
|
||||
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())
|
||||
|
||||
// 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
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
DELETE FROM time_blockers
|
||||
@@ -177,7 +191,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 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(), `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
@@ -195,10 +209,22 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// ANONYMOUS: Check cap (50 in 10 minutes)
|
||||
tenMinutesAgo := time.Now().Add(-10 * time.Minute)
|
||||
// Calculate ipHash from IP address
|
||||
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
|
||||
if err := db.Conn.QueryRow(r.Context(), `
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
SELECT COUNT(*) FROM time_blockers
|
||||
WHERE description LIKE 'RESERVATION:anon:%' AND created_at > $1
|
||||
`, 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)
|
||||
return
|
||||
}
|
||||
|
||||
if anonCount >= 50 {
|
||||
http.Error(w, "Too many active reservations. Please wait or log in.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate ipHash: first 8 chars of md5 hex of the IP string
|
||||
ipHash := fmt.Sprintf("%x", md5.Sum([]byte(ip)))[:8]
|
||||
description := fmt.Sprintf("RESERVATION:anon:%s:%d", ipHash, time.Now().UnixNano())
|
||||
// Check overlap inside transaction
|
||||
// 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 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
|
||||
err = db.Conn.QueryRow(r.Context(), `
|
||||
description := fmt.Sprintf("RESERVATION:anon:%s:%d", ipHash, clock.Now().UnixNano())
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
||||
VALUES ($1, $2, $3, NULL)
|
||||
RETURNING id, created_at
|
||||
@@ -227,6 +276,12 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
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
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/handlers/scheduling"
|
||||
"crussell/mw"
|
||||
@@ -77,7 +78,7 @@ func TestReserveSlot_LoggedIn(t *testing.T) {
|
||||
if err != nil {
|
||||
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{
|
||||
StartTime: startTime,
|
||||
@@ -117,7 +118,7 @@ func TestReserveSlot_LoggedIn_ReplacesExisting(t *testing.T) {
|
||||
serviceIDs := []string{serviceID}
|
||||
|
||||
// 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{
|
||||
StartTime: startTime1,
|
||||
ServiceIDs: serviceIDs,
|
||||
@@ -127,7 +128,7 @@ func TestReserveSlot_LoggedIn_ReplacesExisting(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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{
|
||||
StartTime: startTime2,
|
||||
ServiceIDs: serviceIDs,
|
||||
@@ -159,7 +160,7 @@ func TestReserveSlot_Anonymous(t *testing.T) {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
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{
|
||||
StartTime: startTime,
|
||||
@@ -202,7 +203,7 @@ func TestReserveSlot_ValidationErrors(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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{}{
|
||||
"start_time": startTime,
|
||||
}, "")
|
||||
@@ -212,7 +213,7 @@ func TestReserveSlot_ValidationErrors(t *testing.T) {
|
||||
|
||||
// Past start_time
|
||||
w = makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
|
||||
StartTime: time.Now().Add(-1 * time.Hour),
|
||||
StartTime: clock.Now().Add(-1 * time.Hour),
|
||||
ServiceIDs: serviceIDs,
|
||||
}, "")
|
||||
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
|
||||
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, `
|
||||
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||
VALUES ($1, $2, 'confirmed', false)
|
||||
@@ -260,7 +261,7 @@ func TestReserveSlot_BlockedByTimeBlocker(t *testing.T) {
|
||||
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, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
||||
VALUES ($1, 60, 'Admin Blocked', NULL)
|
||||
@@ -291,7 +292,7 @@ func TestReserveSlot_DualCleanup(t *testing.T) {
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
|
||||
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 {
|
||||
t.Fatalf("failed to create old anon reservation: %v", err)
|
||||
}
|
||||
@@ -300,7 +301,7 @@ func TestReserveSlot_DualCleanup(t *testing.T) {
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
|
||||
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 {
|
||||
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(`
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
|
||||
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 {
|
||||
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(`
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
|
||||
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 {
|
||||
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)
|
||||
// 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 {
|
||||
now := time.Now().In(loc)
|
||||
func nextWeekday(weekday time.Weekday) time.Time {
|
||||
// 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())
|
||||
if daysAhead <= 0 {
|
||||
daysAhead += 7
|
||||
@@ -391,7 +395,7 @@ func nextWeekday(weekday time.Weekday, loc *time.Location) time.Time {
|
||||
daysAhead += 7
|
||||
}
|
||||
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)
|
||||
@@ -421,12 +425,7 @@ func TestReserveSlot_WeekdayConversion(t *testing.T) {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
|
||||
london, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
t.Fatalf("Europe/London not available: %v", err)
|
||||
}
|
||||
|
||||
monday := nextWeekday(time.Monday, london)
|
||||
monday := nextWeekday(time.Monday)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -484,12 +483,7 @@ func TestReserveSlot_ClosingHoursValidation(t *testing.T) {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
|
||||
london, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
t.Fatalf("Europe/London not available: %v", err)
|
||||
}
|
||||
|
||||
monday := nextWeekday(time.Monday, london)
|
||||
monday := nextWeekday(time.Monday)
|
||||
thursday := monday.AddDate(0, 0, 3)
|
||||
|
||||
tests := []struct {
|
||||
@@ -516,9 +510,9 @@ func TestReserveSlot_ClosingHoursValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestReserveSlot_UTCtoLondonConversion verifies that a UTC timestamp sent
|
||||
// from the browser is correctly interpreted as London local time for the
|
||||
// purpose of working hours lookup.
|
||||
// TestReserveSlot_UTCtoLondonConversion verifies that the UTC-to-London
|
||||
// conversion correctly validates closing hours against the London wall-clock.
|
||||
// Test times are in UTC; the handler converts to London time for comparison.
|
||||
func TestReserveSlot_UTCtoLondonConversion(t *testing.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)
|
||||
}
|
||||
|
||||
london, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
t.Fatalf("Europe/London not available: %v", err)
|
||||
}
|
||||
|
||||
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()
|
||||
thursday := nextWeekday(time.Thursday)
|
||||
thursday1730BST := thursday.Add(17*time.Hour + 30*time.Minute)
|
||||
thursday1930BST := thursday.Add(19*time.Hour + 30*time.Minute)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
startTime time.Time
|
||||
expectCode int
|
||||
}{
|
||||
{"17:30 BST Thursday (within 20:00 close)", thursday1730BST, http.StatusCreated},
|
||||
{"19:30 BST Thursday (past 20:00 close)", thursday1930BST, http.StatusBadRequest},
|
||||
{"17:30 UTC = 18:30 BST Thursday (within 20:00 close)", thursday1730BST, http.StatusCreated},
|
||||
{"19:30 UTC = 20:30 BST Thursday (past 20:00 close)", thursday1930BST, http.StatusBadRequest},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -580,6 +568,112 @@ func TestReserveSlot_UTCtoLondonConversion(t *testing.T) {
|
||||
// TestReserveSlot_DifferentClosingPerDay verifies that each day's closing
|
||||
// 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.
|
||||
// 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) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
@@ -605,12 +699,7 @@ func TestReserveSlot_DifferentClosingPerDay(t *testing.T) {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
|
||||
london, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
t.Fatalf("Europe/London not available: %v", err)
|
||||
}
|
||||
|
||||
wednesday := nextWeekday(time.Wednesday, london)
|
||||
wednesday := nextWeekday(time.Wednesday)
|
||||
thursday := wednesday.AddDate(0, 0, 1)
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user