feat: Square payment integration, booking flow redesign, and timezone/weekday fixes

- Add Square payment integration (mock + handlers + UI): terminal/online payments,
  refunds, tips, saved cards, webhooks. Build-tagged dev/prod clients.
- Redesign booking flow: Step 4 conditional (deposit only), Step 5 confirmation
  screen with booking ID, auto-submit on transition.
- Redesign schedule modal: 2x3 button grid with Pay Deposit/Pay Early logic.
- Add deposit warning banner at Step 1 for users with outstanding deposits.
- Fix weekday conversion bug: Go 0=Sunday vs DB 0=Monday mismatch in 6 locations.
- Fix timezone bug: UTC vs London time in closing hours validation.
- Fix frontend error parsing: plain text backend errors now displayed correctly.
- Fix crypto.randomUUID fallback for environments without Web Crypto.
- Add 7 new regression tests: closing hours, advance check, active booking limit,
  weekday conversion, UTC/London, deposit snapshot, exceptional hours.
- Fix 3 flaky tests: dynamic dates instead of fixed, no-show timing.
This commit is contained in:
2026-05-23 11:29:34 +01:00
parent bcd5ed2bd9
commit 2e1ab9d745
31 changed files with 6155 additions and 229 deletions
+201 -5
View File
@@ -1223,21 +1223,23 @@ func TestAdminBookings_Create_DuringHolidayHours_Rejected(t *testing.T) {
defer db.DB.Exec(context.Background(), "DELETE FROM exceptional_working_hours_groups WHERE id = $1", groupID)
// Add closed hours for targetDate (closed all day)
// DB convention: 0=Monday..6=Sunday; Go: 0=Sunday..6=Saturday. Convert.
dbWeekday := (int(targetDate.Weekday()) + 6) % 7
_, err = db.DB.Exec(context.Background(), `
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
VALUES ($1, $2, $3, $4, $5)
`, groupID, int(targetDate.Weekday()), "00:00:00", "23:59:59", false)
`, groupID, dbWeekday, "00:00:00", "23:59:59", false)
if err != nil {
t.Fatalf("failed to create holiday hours: %v", err)
}
// Apply the group to the week containing targetDate
// Must use Monday of that week (matching handler logic)
targetWeekday := int(targetDate.Weekday())
if targetWeekday == 0 {
targetWeekday = 7 // Sunday -> 7
daysToMonday := int(targetDate.Weekday())
if daysToMonday == 0 {
daysToMonday = 7
}
mondayOfWeek := targetDate.AddDate(0, 0, -targetWeekday+1)
mondayOfWeek := targetDate.AddDate(0, 0, -daysToMonday+1)
_, err = db.DB.Exec(context.Background(), `
INSERT INTO exceptional_group_applications (group_id, week_start)
VALUES ($1, $2)
@@ -2660,3 +2662,197 @@ func TestAdminBookings_Create_WalkInGuestUser(t *testing.T) {
t.Errorf("expected booking user_id = %s, got %s", guestID, foundUserID)
}
}
// =============================================================================
// Exceptional Hours Tests
// =============================================================================
// TestAdminUpdateBooking_ClosedExceptionalHours_WarningOnly verifies that an admin can
// update a booking's time to fall within a closed exceptional hours period, receiving
// a warning but proceeding with the update.
func TestAdminUpdateBooking_ClosedExceptionalHours_WarningOnly(t *testing.T) {
resetTestData(t)
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
originalTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
_, err = db.DB.Exec(context.Background(),
"UPDATE bookings SET start_time = $1 WHERE id = $2", originalTime, bookingID)
if err != nil {
t.Fatalf("failed to update booking time: %v", err)
}
targetDate := time.Now().Add(5 * 24 * time.Hour).Truncate(24 * time.Hour)
var groupID int
err = db.DB.QueryRow(context.Background(), `
INSERT INTO exceptional_working_hours_groups (name, description)
VALUES ($1, $2)
RETURNING id
`, "Holiday Closure", "Test holiday").Scan(&groupID)
if err != nil {
t.Fatalf("failed to create holiday group: %v", err)
}
// DB convention: 0=Monday..6=Sunday; Go: 0=Sunday..6=Saturday. Convert.
dbWeekday := (int(targetDate.Weekday()) + 6) % 7
_, err = db.DB.Exec(context.Background(), `
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
VALUES ($1, $2, $3, $4, $5)
`, groupID, dbWeekday, "00:00:00", "23:59:59", false)
if err != nil {
t.Fatalf("failed to create holiday hours: %v", err)
}
daysToMonday := int(targetDate.Weekday())
if daysToMonday == 0 {
daysToMonday = 7
}
mondayOfWeek := targetDate.AddDate(0, 0, -daysToMonday+1)
_, err = db.DB.Exec(context.Background(), `
INSERT INTO exceptional_group_applications (group_id, week_start)
VALUES ($1, $2)
`, groupID, mondayOfWeek)
if err != nil {
t.Fatalf("failed to create holiday application: %v", err)
}
targetTime := targetDate.Add(14 * time.Hour).Truncate(time.Second)
req := bookings.EditBookingRequest{
StartTime: targetTime,
}
handler := http.HandlerFunc(bookings.AdminEditBookingHandler)
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var response map[string]interface{}
if err := parseResponseBody(w, &response); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
warnings, ok := response["warnings"].([]interface{})
if !ok || len(warnings) == 0 {
t.Error("expected warnings array with at least one warning about working hours")
} else {
warningStr, ok := warnings[0].(string)
if !ok {
t.Errorf("expected warning to be a string, got: %v", warnings[0])
} else if !bytes.Contains([]byte(warningStr), []byte("working hours")) {
t.Errorf("expected warning to mention 'working hours', got: %s", warningStr)
}
}
var updatedStartTime time.Time
err = db.DB.QueryRow(context.Background(),
"SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&updatedStartTime)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if !updatedStartTime.Equal(targetTime) {
t.Errorf("expected booking start_time %v, got %v", targetTime, updatedStartTime)
}
}
// TestAdminCreateBookingForUser_ClosedExceptionalHours_Rejected verifies that an admin
// cannot create a booking for a user during hours marked as closed in the exceptional
// working hours (holiday) system.
func TestAdminCreateBookingForUser_ClosedExceptionalHours_Rejected(t *testing.T) {
resetTestData(t)
adminID, err := fixtures.CreateTestAdminUser(db.DB)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
defer fixtures.DeleteUser(db.DB, adminID)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
targetDate := time.Now().Add(5 * 24 * time.Hour).Truncate(24 * time.Hour)
var groupID int
err = db.DB.QueryRow(context.Background(), `
INSERT INTO exceptional_working_hours_groups (name, description)
VALUES ($1, $2)
RETURNING id
`, "Holiday Closure", "Test holiday").Scan(&groupID)
if err != nil {
t.Fatalf("failed to create holiday group: %v", err)
}
// DB convention: 0=Monday..6=Sunday; Go: 0=Sunday..6=Saturday. Convert.
dbWeekday := (int(targetDate.Weekday()) + 6) % 7
_, err = db.DB.Exec(context.Background(), `
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
VALUES ($1, $2, $3, $4, $5)
`, groupID, dbWeekday, "00:00:00", "23:59:59", false)
if err != nil {
t.Fatalf("failed to create holiday hours: %v", err)
}
daysToMonday := int(targetDate.Weekday())
if daysToMonday == 0 {
daysToMonday = 7
}
mondayOfWeek := targetDate.AddDate(0, 0, -daysToMonday+1)
_, err = db.DB.Exec(context.Background(), `
INSERT INTO exceptional_group_applications (group_id, week_start)
VALUES ($1, $2)
`, groupID, mondayOfWeek)
if err != nil {
t.Fatalf("failed to create holiday application: %v", err)
}
targetTime := targetDate.Add(14 * time.Hour).Truncate(time.Second)
req := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: targetTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req)
if w.Code != http.StatusConflict {
t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String())
}
if !bytes.Contains(w.Body.Bytes(), []byte("holiday hours")) {
t.Errorf("expected error message to mention 'holiday hours', got: %s", w.Body.String())
}
}