From 7698ca636b90b262d0f4b0edf6335dfaf5a6b7bd Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 22 Jun 2026 12:54:52 +0100 Subject: [PATCH] feat(bookings): support out_of_hours flag in admin reserve handler Add OutOfHours field to AdminReserveSlotRequest. When true, skip the closing hours check allowing admin to reserve slots outside normal business hours. Add tests for call-in, walk-in, without-flag failure, and time blocker interaction. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/bookings/admin_reserve.go | 38 ++-- .../handlers/bookings/admin_reserve_test.go | 191 ++++++++++++++++++ 2 files changed, 212 insertions(+), 17 deletions(-) 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.