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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user