diff --git a/backend/handlers/bookings/admin_reserve.go b/backend/handlers/bookings/admin_reserve.go index 0af0392..2caeda5 100644 --- a/backend/handlers/bookings/admin_reserve.go +++ b/backend/handlers/bookings/admin_reserve.go @@ -30,6 +30,7 @@ type AdminReserveSlotRequest struct { TTLMinutes int `json:"ttl_minutes"` // 15 for both walk-in and call-in ReservationType string `json:"reservation_type"` // "walkin" or "callin" DurationMinutes int `json:"duration_minutes"` // explicit duration for walk-in (ignored for call-in) + OutOfHours bool `json:"out_of_hours"` } // AdminReserveSlotResponse represents the response for admin slot reservation @@ -106,26 +107,29 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) { } } - localStart := req.StartTime.In(londonLocation) - // DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert. - weekday := int((localStart.Weekday() + 6) % 7) - var closeStr string - if err := db.Conn.QueryRow(r.Context(), `SELECT end_time FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - http.Error(w, "Not open on this day", http.StatusBadRequest) + endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute) + + if !req.OutOfHours { + localStart := req.StartTime.In(londonLocation) + // DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert. + weekday := int((localStart.Weekday() + 6) % 7) + var closeStr string + if err := db.Conn.QueryRow(r.Context(), `SELECT end_time FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "Not open on this day", http.StatusBadRequest) + return + } + log.Printf("Failed to get hours: %v", err) + http.Error(w, "Could not verify hours", http.StatusInternalServerError) return } - log.Printf("Failed to get hours: %v", err) - http.Error(w, "Could not verify hours", http.StatusInternalServerError) - return - } - 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()) { - http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest) - 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()) { + http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest) + return + } } var cnt int diff --git a/backend/handlers/bookings/admin_reserve_test.go b/backend/handlers/bookings/admin_reserve_test.go index 7706473..12a8d3c 100644 --- a/backend/handlers/bookings/admin_reserve_test.go +++ b/backend/handlers/bookings/admin_reserve_test.go @@ -511,3 +511,194 @@ func TestAdminReserveSlot_WalkIn_PastStart(t *testing.T) { t.Errorf("expected body to contain 'past', got %s", body) } } + +// TestAdminReserveSlot_OutOfHours_CallIn_Success verifies that an admin can +// successfully create a call-in reservation with out_of_hours=true, bypassing +// the closing hours check even when booking outside normal hours. +func TestAdminReserveSlot_OutOfHours_CallIn_Success(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + defer fixtures.DeleteUser(tx, adminID) + + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + defer fixtures.DeleteService(tx, serviceID) + + // Baseline test DB has 08:00-20:00 hours. Book 19:30 + 60min = 20:30 (> 20:00 closing) + // Without out_of_hours this would fail; with out_of_hours=true it should succeed. + tomorrow := time.Now().Add(24 * time.Hour) + lateBooking := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 19, 30, 0, 0, tomorrow.Location()) + + req := AdminReserveSlotRequest{ + ReservationType: "callin", + StartTime: lateBooking, + ServiceIDs: []string{serviceID}, + TTLMinutes: 15, + OutOfHours: true, + } + + handler := http.HandlerFunc(AdminReserveSlotHandler) + w := makeAdminReserveRequest(handler, req, adminID, ctx) + + if w.Code != http.StatusCreated { + t.Errorf("expected status 201 for out-of-hours reservation, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp AdminReserveSlotResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if resp.StartTime.Format("15:04") != "19:30" { + t.Errorf("expected start time 19:30, got %s", resp.StartTime.Format("15:04")) + } +} + +// TestAdminReserveSlot_OutOfHours_WithoutFlag_Fails verifies that an admin +// attempting to book a slot that ends after closing hours WITHOUT the +// out_of_hours flag is rejected, ensuring the flag is required. +func TestAdminReserveSlot_OutOfHours_WithoutFlag_Fails(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + defer fixtures.DeleteUser(tx, adminID) + + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + defer fixtures.DeleteService(tx, serviceID) + + // Baseline test DB has 08:00-20:00 hours. Book 19:30 + 60min = 20:30 (> 20:00 closing) + // Without out_of_hours this should be rejected. + tomorrow := time.Now().Add(24 * time.Hour) + lateBooking := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 19, 30, 0, 0, tomorrow.Location()) + + req := AdminReserveSlotRequest{ + ReservationType: "callin", + StartTime: lateBooking, + ServiceIDs: []string{serviceID}, + TTLMinutes: 15, + // OutOfHours intentionally NOT set (defaults to false) + } + + handler := http.HandlerFunc(AdminReserveSlotHandler) + w := makeAdminReserveRequest(handler, req, adminID, ctx) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 when booking beyond closing hours without out_of_hours flag, got %d. body: %s", + w.Code, w.Body.String()) + } +} + +// TestAdminReserveSlot_OutOfHours_WalkIn_Success verifies that an admin can +// successfully create a walk-in reservation with out_of_hours=true, bypassing +// the closing hours check and using explicit duration. +func TestAdminReserveSlot_OutOfHours_WalkIn_Success(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + defer fixtures.DeleteUser(tx, adminID) + + // Baseline test DB has 08:00-20:00 hours. Book walk-in 19:30 + 60min = 20:30 (> 20:00 closing) + // Without out_of_hours this would fail; with out_of_hours=true it should succeed. + tomorrow := time.Now().Add(24 * time.Hour) + lateBooking := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 19, 30, 0, 0, tomorrow.Location()) + + req := AdminReserveSlotRequest{ + ReservationType: "walkin", + StartTime: lateBooking, + DurationMinutes: 60, + TTLMinutes: 15, + OutOfHours: true, + } + + handler := http.HandlerFunc(AdminReserveSlotHandler) + w := makeAdminReserveRequest(handler, req, adminID, ctx) + + if w.Code != http.StatusCreated { + t.Errorf("expected status 201 for out-of-hours walk-in reservation, got %d. body: %s", + w.Code, w.Body.String()) + } + + var resp AdminReserveSlotResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if resp.DurationMinutes != 60 { + t.Errorf("expected duration 60, got %d", resp.DurationMinutes) + } + + // Verify reservation time_blocker was created + var desc string + err = tx.QueryRow(ctx, + "SELECT description FROM time_blockers WHERE description LIKE 'RESERVATION:admin:walkin:%'", + ).Scan(&desc) + if err != nil { + t.Errorf("failed to query time_blocker: %v", err) + } + if !strings.HasPrefix(desc, "RESERVATION:admin:walkin:") { + t.Errorf("expected RESERVATION:admin:walkin: prefix, got %s", desc) + } +} + +// TestAdminReserveSlot_OutOfHours_TimeBlockerBlocks verifies that even when +// out_of_hours=true is set, a time blocker at the requested time still causes +// the reservation to be rejected with 409 Conflict. +func TestAdminReserveSlot_OutOfHours_TimeBlockerBlocks(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + defer fixtures.DeleteUser(tx, adminID) + + // Create a time blocker at a specific future time + tomorrow := time.Now().Add(24 * time.Hour) + blockerStart := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 14, 0, 0, 0, tomorrow.Location()) + _, err = tx.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'Admin Blocked Time', NULL) + `, blockerStart) + if err != nil { + t.Fatalf("failed to create time blocker: %v", err) + } + + // Try reserving during the blocked time with out_of_hours=true + req := AdminReserveSlotRequest{ + ReservationType: "walkin", + StartTime: blockerStart, + DurationMinutes: 30, + TTLMinutes: 15, + OutOfHours: true, + } + + handler := http.HandlerFunc(AdminReserveSlotHandler) + w := makeAdminReserveRequest(handler, req, adminID, ctx) + + if w.Code != http.StatusConflict { + t.Errorf("expected 409 Conflict when blocker overlaps with out_of_hours reservation, got %d. body: %s", + w.Code, w.Body.String()) + } +} + +// TestAdminReserveSlot_SlotOverlap tests that a reservation fails when the +// requested time slot overlaps with an existing booking.