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:
@@ -1651,9 +1651,8 @@ func TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3(t *testing.T) {
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Create booking with start_time = now + 12 hours (< 24h notice)
|
||||
soonTime := time.Now().Add(12 * time.Hour).Truncate(time.Second)
|
||||
soonTime = time.Date(soonTime.Year(), soonTime.Month(), soonTime.Day(), 10, 0, 0, 0, soonTime.Location())
|
||||
// Create booking with start_time = now + 23 hours (< 24h notice, > 1h advance)
|
||||
soonTime := time.Now().Add(23 * time.Hour).Truncate(time.Second)
|
||||
|
||||
bookingReq := CreateBookingRequest{
|
||||
StartTime: soonTime,
|
||||
@@ -1818,9 +1817,8 @@ func TestDeleteBooking_NoShowWithForgiveness_NoPenalty(t *testing.T) {
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Create booking with start_time = now + 12 hours (< 24h notice)
|
||||
soonTime := time.Now().Add(12 * time.Hour).Truncate(time.Second)
|
||||
soonTime = time.Date(soonTime.Year(), soonTime.Month(), soonTime.Day(), 10, 0, 0, 0, soonTime.Location())
|
||||
// Create booking with start_time = now + 23 hours (< 24h notice, > 1h advance)
|
||||
soonTime := time.Now().Add(23 * time.Hour).Truncate(time.Second)
|
||||
|
||||
bookingReq := CreateBookingRequest{
|
||||
StartTime: soonTime,
|
||||
@@ -1903,8 +1901,7 @@ func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) {
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// === First booking: no-show ===
|
||||
soonTime1 := time.Now().Add(12 * time.Hour).Truncate(time.Second)
|
||||
soonTime1 = time.Date(soonTime1.Year(), soonTime1.Month(), soonTime1.Day(), 10, 0, 0, 0, soonTime1.Location())
|
||||
soonTime1 := time.Now().Add(23 * time.Hour).Truncate(time.Second)
|
||||
|
||||
bookingReq1 := CreateBookingRequest{
|
||||
StartTime: soonTime1,
|
||||
@@ -1944,10 +1941,7 @@ func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) {
|
||||
}
|
||||
|
||||
// === Second booking: no-show ===
|
||||
// Need to wait a bit or create with different time to avoid conflict
|
||||
// Use tomorrow + 12 hours
|
||||
soonTime2 := time.Now().Add(36 * time.Hour).Truncate(time.Second)
|
||||
soonTime2 = time.Date(soonTime2.Year(), soonTime2.Month(), soonTime2.Day(), 10, 0, 0, 0, soonTime2.Location())
|
||||
soonTime2 := time.Now().Add(47 * time.Hour).Truncate(time.Second)
|
||||
|
||||
bookingReq2 := CreateBookingRequest{
|
||||
StartTime: soonTime2,
|
||||
@@ -4149,8 +4143,9 @@ func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) {
|
||||
|
||||
// Calculate week start for the booking target date
|
||||
targetDate := time.Now().Add(96 * time.Hour)
|
||||
weekday := int(targetDate.Weekday())
|
||||
daysToMonday := weekday
|
||||
// DB convention: 0=Monday..6=Sunday; Go: 0=Sunday..6=Saturday. Convert.
|
||||
dbWeekday := (int(targetDate.Weekday()) + 6) % 7
|
||||
daysToMonday := int(targetDate.Weekday())
|
||||
if daysToMonday == 0 {
|
||||
daysToMonday = 7
|
||||
}
|
||||
@@ -4169,7 +4164,7 @@ func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) {
|
||||
_, err = db.DB.Exec(context.Background(),
|
||||
`INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
|
||||
VALUES ($1, $2, '08:00:00', '20:00:00', false)`,
|
||||
groupID, weekday)
|
||||
groupID, dbWeekday)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create closed exceptional hours: %v", err)
|
||||
}
|
||||
@@ -4841,3 +4836,356 @@ func TestCreateBooking_Notifications_NoPendingBookingWithoutNotes(t *testing.T)
|
||||
t.Errorf("expected 0 pending_booking notifications for booking without notes, got %d", pendingCount)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Closing Hours, Advance Check, and Active Booking Limit Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestCreateBooking_ClosingHoursValidation(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
london, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load London timezone: %v", err)
|
||||
}
|
||||
|
||||
hours := []struct {
|
||||
weekday int
|
||||
startTime string
|
||||
endTime string
|
||||
isOpen bool
|
||||
}{
|
||||
{0, "08:00", "17:00", true},
|
||||
{1, "08:00", "20:00", true},
|
||||
{2, "08:00", "20:00", true},
|
||||
{3, "08:00", "20:00", true},
|
||||
{4, "08:00", "20:00", true},
|
||||
{5, "08:00", "20:00", true},
|
||||
{6, "08:00", "20:00", true},
|
||||
}
|
||||
seedCustomWorkingHours(t, hours)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE services SET duration_minutes = 60 WHERE id = $1", serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set service duration: %v", err)
|
||||
}
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
thursday := nextWeekday(time.Thursday, london)
|
||||
thursdayStart := time.Date(thursday.Year(), thursday.Month(), thursday.Day(), 17, 30, 0, 0, london)
|
||||
req1 := CreateBookingRequest{
|
||||
StartTime: thursdayStart,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(CreateBookingHandler)
|
||||
w1 := makeRequest(handler, "POST", "/api/bookings", req1, token)
|
||||
|
||||
if w1.Code != http.StatusCreated {
|
||||
t.Errorf("Thursday 17:30+60min should succeed (ends 18:30 < 20:00), got %d. body: %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
monday := nextWeekday(time.Monday, london)
|
||||
mondayStart := time.Date(monday.Year(), monday.Month(), monday.Day(), 16, 30, 0, 0, london)
|
||||
req2 := CreateBookingRequest{
|
||||
StartTime: mondayStart,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
w2 := makeRequest(handler, "POST", "/api/bookings", req2, token)
|
||||
|
||||
if w2.Code != http.StatusBadRequest {
|
||||
t.Errorf("Monday 16:30+60min should fail (ends 17:30 > 17:00), got %d. body: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
if !bytes.Contains(w2.Body.Bytes(), []byte("closing")) {
|
||||
t.Errorf("expected error about closing hours, got: %s", w2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBooking_OneHourAdvanceCheck(t *testing.T) {
|
||||
resetTestData(t)
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
handler := http.HandlerFunc(CreateBookingHandler)
|
||||
|
||||
soonTime := time.Now().Add(30 * time.Minute).Truncate(time.Second)
|
||||
req1 := CreateBookingRequest{
|
||||
StartTime: soonTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
w1 := makeRequest(handler, "POST", "/api/bookings", req1, token)
|
||||
|
||||
if w1.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400 for 30-min advance booking, got %d. body: %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
if !bytes.Contains(w1.Body.Bytes(), []byte("at least 1 hour")) {
|
||||
t.Errorf("expected error about 1 hour advance, got: %s", w1.Body.String())
|
||||
}
|
||||
|
||||
aheadTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
aheadTime = time.Date(aheadTime.Year(), aheadTime.Month(), aheadTime.Day(), 10, 0, 0, 0, aheadTime.Location())
|
||||
req2 := CreateBookingRequest{
|
||||
StartTime: aheadTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
w2 := makeRequest(handler, "POST", "/api/bookings", req2, token)
|
||||
|
||||
if w2.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201 for 2h+ advance booking, got %d. body: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBooking_ActiveBookingLimit(t *testing.T) {
|
||||
resetTestData(t)
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
handler := http.HandlerFunc(CreateBookingHandler)
|
||||
|
||||
firstTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
firstTime = time.Date(firstTime.Year(), firstTime.Month(), firstTime.Day(), 10, 0, 0, 0, firstTime.Location())
|
||||
req1 := CreateBookingRequest{
|
||||
StartTime: firstTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
w1 := makeRequest(handler, "POST", "/api/bookings", req1, token)
|
||||
|
||||
if w1.Code != http.StatusCreated {
|
||||
t.Fatalf("expected first booking to succeed, got %d. body: %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
var booking1 Booking
|
||||
if err := parseResponseBody(w1, &booking1); err != nil {
|
||||
t.Fatalf("failed to parse first booking: %v", err)
|
||||
}
|
||||
|
||||
secondTime := time.Now().Add(96 * time.Hour).Truncate(time.Second)
|
||||
secondTime = time.Date(secondTime.Year(), secondTime.Month(), secondTime.Day(), 14, 0, 0, 0, secondTime.Location())
|
||||
req2 := CreateBookingRequest{
|
||||
StartTime: secondTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
w2 := makeRequest(handler, "POST", "/api/bookings", req2, token)
|
||||
|
||||
if w2.Code != http.StatusConflict {
|
||||
t.Errorf("expected status 409 for second booking with active booking, got %d. body: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
if !bytes.Contains(w2.Body.Bytes(), []byte("active booking")) {
|
||||
t.Errorf("expected error about active booking, got: %s", w2.Body.String())
|
||||
}
|
||||
|
||||
_, err = db.DB.Exec(context.Background(),
|
||||
"UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", booking1.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to cancel first booking: %v", err)
|
||||
}
|
||||
|
||||
w3 := makeRequest(handler, "POST", "/api/bookings", req2, token)
|
||||
|
||||
if w3.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201 after cancelling active booking, got %d. body: %s", w3.Code, w3.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextWeekdayHelper(t *testing.T) {
|
||||
london, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
t.Fatalf("Europe/London not available: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
weekday time.Weekday
|
||||
}{
|
||||
{"Monday", time.Monday},
|
||||
{"Tuesday", time.Tuesday},
|
||||
{"Wednesday", time.Wednesday},
|
||||
{"Thursday", time.Thursday},
|
||||
{"Friday", time.Friday},
|
||||
{"Saturday", time.Saturday},
|
||||
{"Sunday", time.Sunday},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := nextWeekday(tt.weekday, london)
|
||||
|
||||
if result.Weekday() != tt.weekday {
|
||||
t.Errorf("expected weekday %s, got %s", tt.weekday, result.Weekday())
|
||||
}
|
||||
|
||||
now := time.Now().In(london)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, london)
|
||||
resultDay := time.Date(result.Year(), result.Month(), result.Day(), 0, 0, 0, 0, london)
|
||||
daysDiff := int(resultDay.Sub(today).Hours() / 24)
|
||||
if daysDiff < 2 {
|
||||
t.Errorf("expected result to be at least 2 calendar days ahead, got %d", daysDiff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBooking_DepositSnapshot(t *testing.T) {
|
||||
resetTestData(t)
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
london, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
t.Fatalf("Europe/London not available: %v", err)
|
||||
}
|
||||
bookingTime := nextWeekday(time.Monday, london).Add(10 * time.Hour)
|
||||
|
||||
req := CreateBookingRequest{
|
||||
StartTime: bookingTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(CreateBookingHandler)
|
||||
w := makeRequest(handler, "POST", "/api/bookings", req, token)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var booking Booking
|
||||
if err := parseResponseBody(w, &booking); err != nil {
|
||||
t.Fatalf("failed to parse booking response: %v", err)
|
||||
}
|
||||
|
||||
if !booking.DepositRequired {
|
||||
t.Error("expected deposit_required=true on first booking")
|
||||
}
|
||||
|
||||
var depositRequired bool
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT deposit_required FROM bookings WHERE id = $1", booking.ID).Scan(&depositRequired)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query booking: %v", err)
|
||||
}
|
||||
if !depositRequired {
|
||||
t.Error("expected deposit_required=true in DB for first booking")
|
||||
}
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update deposits_required: %v", err)
|
||||
}
|
||||
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT deposit_required FROM bookings WHERE id = $1", booking.ID).Scan(&depositRequired)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query booking after user update: %v", err)
|
||||
}
|
||||
if !depositRequired {
|
||||
t.Error("expected booking deposit_required to remain true after user change")
|
||||
}
|
||||
|
||||
bookingTime2 := nextWeekday(time.Tuesday, london).Add(10 * time.Hour)
|
||||
req2 := CreateBookingRequest{
|
||||
StartTime: bookingTime2,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
w2 := makeRequest(handler, "POST", "/api/bookings", req2, token)
|
||||
if w2.Code != http.StatusCreated {
|
||||
t.Fatalf("expected status 201 for second booking, got %d. body: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
var booking2 Booking
|
||||
if err := parseResponseBody(w2, &booking2); err != nil {
|
||||
t.Fatalf("failed to parse second booking response: %v", err)
|
||||
}
|
||||
|
||||
if booking2.DepositRequired {
|
||||
t.Error("expected deposit_required=false on second booking after user deposits_required=0")
|
||||
}
|
||||
|
||||
var depositRequired2 bool
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT deposit_required FROM bookings WHERE id = $1", booking2.ID).Scan(&depositRequired2)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query second booking: %v", err)
|
||||
}
|
||||
if depositRequired2 {
|
||||
t.Error("expected second booking deposit_required=false in DB")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user