fix(bookings): add request struct validation and fix patch test notice logic

Add validators.Validate.Struct() calls across booking handlers. Fix patch test notice period check to compare against booking start time (not current time) and fix expiry check similarly. Update test to match new error message.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-05-31 18:49:07 +01:00
co-authored by Sisyphus
parent 304a54c283
commit 577cceb304
3 changed files with 46 additions and 9 deletions
+34 -4
View File
@@ -912,6 +912,10 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid request", http.StatusBadRequest) http.Error(w, "Invalid request", http.StatusBadRequest)
return return
} }
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(req.ServiceIDs) == 0 { if len(req.ServiceIDs) == 0 {
http.Error(w, "At least one service is required", http.StatusBadRequest) http.Error(w, "At least one service is required", http.StatusBadRequest)
@@ -1216,6 +1220,10 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Search query 'q' is required", http.StatusBadRequest) http.Error(w, "Search query 'q' is required", http.StatusBadRequest)
return return
} }
if len(query) > 200 {
http.Error(w, "search query too long", http.StatusBadRequest)
return
}
page, perPage := 1, 10 page, perPage := 1, 10
if pageStr := r.URL.Query().Get("page"); pageStr != "" { if pageStr := r.URL.Query().Get("page"); pageStr != "" {
@@ -1492,6 +1500,11 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
isGuest = true isGuest = true
} }
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.StartTime.IsZero() { if req.StartTime.IsZero() {
http.Error(w, "Start time is required", http.StatusBadRequest) http.Error(w, "Start time is required", http.StatusBadRequest)
return return
@@ -1566,15 +1579,16 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
} }
eligibleFrom := testedAt.Add(time.Duration(noticeHours) * time.Hour) eligibleFrom := testedAt.Add(time.Duration(noticeHours) * time.Hour)
if time.Now().Before(eligibleFrom) { if req.StartTime.Before(eligibleFrom) {
hoursLeft := time.Until(eligibleFrom).Hours() hoursLeft := eligibleFrom.Sub(req.StartTime).Hours()
http.Error(w, fmt.Sprintf("You must wait %.0f hours after your patch test before booking this service.", hoursLeft), http.StatusBadRequest) http.Error(w, fmt.Sprintf("Booking time is before the %.0f hour notice period after patch test. Earliest booking: %s", hoursLeft, eligibleFrom.Format("2006-01-02 15:04")), http.StatusBadRequest)
return return
} }
var expiryMonths int var expiryMonths int
if err := db.DB.QueryRow(r.Context(), `SELECT expiry_months FROM patch_tests WHERE id = $1`, patchTestID).Scan(&expiryMonths); err == nil { if err := db.DB.QueryRow(r.Context(), `SELECT expiry_months FROM patch_tests WHERE id = $1`, patchTestID).Scan(&expiryMonths); err == nil {
if time.Now().After(testedAt.AddDate(0, expiryMonths, 0)) { expiresAt := testedAt.AddDate(0, expiryMonths, 0)
if req.StartTime.After(expiresAt) {
http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest) http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest)
return return
} }
@@ -1777,6 +1791,10 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid request", http.StatusBadRequest) http.Error(w, "Invalid request", http.StatusBadRequest)
return return
} }
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.StartTime.IsZero() { if req.StartTime.IsZero() {
http.Error(w, "Start time is required", http.StatusBadRequest) http.Error(w, "Start time is required", http.StatusBadRequest)
@@ -2220,6 +2238,10 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid request", http.StatusBadRequest) http.Error(w, "Invalid request", http.StatusBadRequest)
return return
} }
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for _, override := range req.ServiceOverrides { for _, override := range req.ServiceOverrides {
if override.OverridePrice != nil && *override.OverridePrice < 0 { if override.OverridePrice != nil && *override.OverridePrice < 0 {
@@ -2434,6 +2456,10 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid request", http.StatusBadRequest) http.Error(w, "Invalid request", http.StatusBadRequest)
return return
} }
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
allowed := map[string]bool{ allowed := map[string]bool{
"client_cancelled": true, "we_cancelled": true, "re-schedule": true, "no_show": true, "client_cancelled": true, "we_cancelled": true, "re-schedule": true, "no_show": true,
} }
@@ -3291,6 +3317,10 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid request", http.StatusBadRequest) http.Error(w, "Invalid request", http.StatusBadRequest)
return return
} }
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.StartTime.IsZero() { if req.StartTime.IsZero() {
http.Error(w, "Start time is required", http.StatusBadRequest) http.Error(w, "Start time is required", http.StatusBadRequest)
+8 -5
View File
@@ -3608,9 +3608,12 @@ func TestBookings_Create_PatchTestRequired_WithinNoticePeriod(t *testing.T) {
token := jwt.GenerateUserToken(userID) token := jwt.GenerateUserToken(userID)
// Try to book within notice period (24h required, but only 1h passed) // Try to book within notice period (24h required, but only 1h passed).
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) // Booking start_time must be before testedAt + noticeHours to trigger this.
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) // Use a booking time in the future (passes 1-hour advance check) but before
// eligibleFrom (testedAt + 24h = now + 23h).
futureTime := time.Now().Add(2 * time.Hour).Truncate(time.Second)
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), futureTime.Hour(), 0, 0, 0, futureTime.Location())
req := CreateBookingRequest{ req := CreateBookingRequest{
StartTime: futureTime, StartTime: futureTime,
ServiceIDs: []string{serviceID}, ServiceIDs: []string{serviceID},
@@ -3623,8 +3626,8 @@ func TestBookings_Create_PatchTestRequired_WithinNoticePeriod(t *testing.T) {
t.Errorf("expected status 400 for within notice period, got %d. body: %s", w.Code, w.Body.String()) t.Errorf("expected status 400 for within notice period, got %d. body: %s", w.Code, w.Body.String())
} }
if !bytes.Contains(w.Body.Bytes(), []byte("wait")) { if !bytes.Contains(w.Body.Bytes(), []byte("notice period")) {
t.Errorf("expected error message about waiting, got: %s", w.Body.String()) t.Errorf("expected error message about notice period, got: %s", w.Body.String())
} }
} }
+4
View File
@@ -440,6 +440,10 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid request", http.StatusBadRequest) http.Error(w, "Invalid request", http.StatusBadRequest)
return return
} }
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Extract idempotency key from header // Extract idempotency key from header
idempotencyKey := r.Header.Get("Idempotency-Key") idempotencyKey := r.Header.Get("Idempotency-Key")