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>
705 lines
22 KiB
Go
705 lines
22 KiB
Go
//go:build test && dev
|
|
// +build test,dev
|
|
|
|
package bookings
|
|
|
|
// Package bookings contains tests for admin booking reservation endpoints.
|
|
//
|
|
// Test Coverage:
|
|
// - AdminReserveSlotHandler: POST /api/admin/bookings/reserve - Admin slot reservation (walk-in or call-in)
|
|
//
|
|
// Tests cover walk-in and call-in reservation types, validation, slot overlaps, and replacement behavior.
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/testutils"
|
|
"crussell/mw"
|
|
"crussell/testutils/fixtures"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// makeAdminReserveRequest creates a request with admin context for admin reserve slot handler
|
|
// It sets mw.UserIDKey and mw.UserRoleKey to "admin" in the context
|
|
func makeAdminReserveRequest(handler http.Handler, body interface{}, adminID string, requestCtx ...context.Context) *httptest.ResponseRecorder {
|
|
var req *http.Request
|
|
if body != nil {
|
|
bodyBytes, _ := json.Marshal(body)
|
|
req = httptest.NewRequest("POST", "/api/admin/bookings/reserve", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
} else {
|
|
req = httptest.NewRequest("POST", "/api/admin/bookings/reserve", nil)
|
|
}
|
|
|
|
baseCtx := req.Context()
|
|
if len(requestCtx) > 0 {
|
|
baseCtx = requestCtx[0]
|
|
}
|
|
|
|
rctx := chi.NewRouteContext()
|
|
ctx := context.WithValue(baseCtx, chi.RouteCtxKey, rctx)
|
|
|
|
ctx = context.WithValue(ctx, mw.UserIDKey, adminID)
|
|
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
|
|
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
// =============================================================================
|
|
// Walk-in Reservation Tests
|
|
// =============================================================================
|
|
|
|
// TestAdminReserveSlot_WalkIn_Success tests that an admin can successfully
|
|
// create a walk-in reservation with a valid duration. The test verifies
|
|
// the reservation is created in the database with the correct duration.
|
|
func TestAdminReserveSlot_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)
|
|
|
|
tomorrow := time.Now().Add(24 * time.Hour)
|
|
now := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 12, 0, 0, 0, tomorrow.Location())
|
|
req := AdminReserveSlotRequest{
|
|
ReservationType: "walkin",
|
|
StartTime: now,
|
|
DurationMinutes: 30,
|
|
TTLMinutes: 15,
|
|
}
|
|
|
|
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
|
w := makeAdminReserveRequest(handler, req, adminID, ctx)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Parse response and verify duration
|
|
var resp AdminReserveSlotResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if resp.DurationMinutes != 30 {
|
|
t.Errorf("expected duration 30, got %d", resp.DurationMinutes)
|
|
}
|
|
|
|
// Verify time_blocker was created with correct description pattern
|
|
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 description to start with 'RESERVATION:admin:walkin:', got %s", desc)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Call-in Reservation Tests
|
|
// =============================================================================
|
|
|
|
// TestAdminReserveSlot_CallIn_Success tests that an admin can successfully
|
|
// create a call-in reservation with valid service IDs. The test verifies
|
|
// the reservation duration matches the service duration.
|
|
func TestAdminReserveSlot_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)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(tx, serviceID)
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE services SET duration_minutes = 30 WHERE id = $1", serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to update service duration: %v", err)
|
|
}
|
|
|
|
tomorrow := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
|
tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location())
|
|
|
|
req := AdminReserveSlotRequest{
|
|
UserID: &userID,
|
|
ReservationType: "callin",
|
|
StartTime: tomorrow,
|
|
ServiceIDs: []string{serviceID},
|
|
TTLMinutes: 15,
|
|
}
|
|
|
|
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
|
w := makeAdminReserveRequest(handler, req, adminID, ctx)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Parse response and verify duration matches service (30 min)
|
|
var resp AdminReserveSlotResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if resp.DurationMinutes != 30 {
|
|
t.Errorf("expected duration 30, got %d", resp.DurationMinutes)
|
|
}
|
|
|
|
// Verify time_blocker was created with correct description pattern
|
|
var desc string
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT description FROM time_blockers WHERE description LIKE 'RESERVATION:admin:callin:%'",
|
|
).Scan(&desc)
|
|
if err != nil {
|
|
t.Errorf("failed to query time_blocker: %v", err)
|
|
}
|
|
|
|
if !strings.HasPrefix(desc, "RESERVATION:admin:callin:") {
|
|
t.Errorf("expected description to start with 'RESERVATION:admin:callin:', got %s", desc)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Validation Tests - Missing Duration
|
|
// =============================================================================
|
|
|
|
// TestAdminReserveSlot_WalkIn_MissingDuration tests that walk-in reservations
|
|
// fail with HTTP 400 when duration_minutes is missing or zero.
|
|
func TestAdminReserveSlot_WalkIn_MissingDuration(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)
|
|
|
|
now := time.Now()
|
|
req := AdminReserveSlotRequest{
|
|
ReservationType: "walkin",
|
|
StartTime: now,
|
|
TTLMinutes: 15,
|
|
}
|
|
|
|
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
|
w := makeAdminReserveRequest(handler, req, adminID, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify error message mentions duration_minutes
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, "duration_minutes") {
|
|
t.Errorf("expected body to contain 'duration_minutes', got %s", body)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Validation Tests - Missing Services
|
|
// =============================================================================
|
|
|
|
// TestAdminReserveSlot_CallIn_MissingServices tests that call-in reservations
|
|
// fail with HTTP 400 when service_ids is empty.
|
|
func TestAdminReserveSlot_CallIn_MissingServices(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)
|
|
|
|
tomorrow := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
|
tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location())
|
|
|
|
req := AdminReserveSlotRequest{
|
|
ReservationType: "callin",
|
|
StartTime: tomorrow,
|
|
ServiceIDs: []string{},
|
|
TTLMinutes: 15,
|
|
}
|
|
|
|
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
|
w := makeAdminReserveRequest(handler, req, adminID, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify error message mentions service
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, "service") {
|
|
t.Errorf("expected body to contain 'service', got %s", body)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Validation Tests - Invalid Reservation Type
|
|
// =============================================================================
|
|
|
|
// TestAdminReserveSlot_InvalidReservationType tests that reservations
|
|
// fail with HTTP 400 when reservation_type is invalid.
|
|
func TestAdminReserveSlot_InvalidReservationType(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)
|
|
|
|
req := AdminReserveSlotRequest{
|
|
ReservationType: "invalid",
|
|
StartTime: time.Now(),
|
|
DurationMinutes: 30,
|
|
}
|
|
|
|
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
|
w := makeAdminReserveRequest(handler, req, adminID, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify error message mentions valid types
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, "walkin") && !strings.Contains(body, "callin") {
|
|
t.Errorf("expected body to contain 'walkin' or 'callin', got %s", body)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Slot Overlap Tests
|
|
// =============================================================================
|
|
|
|
// TestAdminReserveSlot_SlotOverlap tests that a reservation fails
|
|
// with HTTP 409 when the slot overlaps with an existing booking.
|
|
func TestAdminReserveSlot_SlotOverlap(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
|
|
|
|
// Create test admin user (for the booking)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, adminID)
|
|
|
|
// Create test regular user
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(tx, serviceID)
|
|
|
|
// Set deposits_required=0 for test user
|
|
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set deposits_required: %v", err)
|
|
}
|
|
|
|
tomorrow := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
|
tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location())
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(tx, bookingID)
|
|
|
|
_, err = tx.Exec(ctx,
|
|
"UPDATE bookings SET start_time = $1, status = 'confirmed' WHERE id = $2",
|
|
tomorrow, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to update booking time: %v", err)
|
|
}
|
|
|
|
// Now try to reserve a call-in that overlaps (tomorrow 10:15 - 15 min after start)
|
|
overlapTime := tomorrow.Add(15 * time.Minute)
|
|
|
|
req := AdminReserveSlotRequest{
|
|
ReservationType: "callin",
|
|
StartTime: overlapTime,
|
|
ServiceIDs: []string{serviceID},
|
|
TTLMinutes: 15,
|
|
}
|
|
|
|
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
|
w := makeAdminReserveRequest(handler, req, adminID, ctx)
|
|
|
|
// Should return 409 Conflict due to overlap
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Reservation Replacement Tests
|
|
// =============================================================================
|
|
|
|
// TestAdminReserveSlot_ReplacesExisting tests that reserving twice
|
|
// on the same admin replaces the previous reservation.
|
|
func TestAdminReserveSlot_ReplacesExisting(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)
|
|
|
|
tomorrow := time.Now().Add(24 * time.Hour)
|
|
now := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 12, 0, 0, 0, tomorrow.Location())
|
|
|
|
req := AdminReserveSlotRequest{
|
|
ReservationType: "walkin",
|
|
StartTime: now,
|
|
DurationMinutes: 30,
|
|
TTLMinutes: 15,
|
|
}
|
|
|
|
// First reservation
|
|
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
|
w := makeAdminReserveRequest(handler, req, adminID, ctx)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var firstResp AdminReserveSlotResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &firstResp); err != nil {
|
|
t.Fatalf("failed to parse first response: %v", err)
|
|
}
|
|
|
|
// Count reservations before second request
|
|
var countBefore int
|
|
var qerr error
|
|
qerr = tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%'",
|
|
).Scan(&countBefore)
|
|
if qerr != nil {
|
|
t.Fatalf("failed to count reservations: %v", qerr)
|
|
}
|
|
|
|
// Second reservation (same admin, new time)
|
|
laterTime := now.Add(1 * time.Hour)
|
|
req2 := AdminReserveSlotRequest{
|
|
ReservationType: "walkin",
|
|
StartTime: laterTime,
|
|
DurationMinutes: 45,
|
|
TTLMinutes: 15,
|
|
}
|
|
|
|
w = makeAdminReserveRequest(handler, req2, adminID, ctx)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var secondResp AdminReserveSlotResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &secondResp); err != nil {
|
|
t.Fatalf("failed to parse second response: %v", err)
|
|
}
|
|
|
|
// Count reservations after second request - should still be 1
|
|
var countAfter int
|
|
qerr = tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%'",
|
|
).Scan(&countAfter)
|
|
if qerr != nil {
|
|
t.Fatalf("failed to count reservations: %v", qerr)
|
|
}
|
|
|
|
// Old one should be deleted, new one exists (id should be different)
|
|
if countAfter != 1 {
|
|
t.Errorf("expected 1 reservation after replacement, got %d", countAfter)
|
|
}
|
|
|
|
if firstResp.ID == secondResp.ID {
|
|
t.Errorf("expected new reservation ID to be different from old one")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Past Start Time Tests
|
|
// =============================================================================
|
|
|
|
// TestAdminReserveSlot_WalkIn_PastStart tests that walk-in reservations
|
|
func TestAdminReserveSlot_WalkIn_PastStart(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)
|
|
|
|
pastTime := time.Now().Add(-5 * time.Minute)
|
|
req := AdminReserveSlotRequest{
|
|
ReservationType: "walkin",
|
|
StartTime: pastTime,
|
|
DurationMinutes: 30,
|
|
TTLMinutes: 15,
|
|
}
|
|
|
|
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
|
w := makeAdminReserveRequest(handler, req, adminID, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify error message mentions the past time limit
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, "past") {
|
|
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.
|