Files
Crussell/backend/handlers/admin/bookings_test.go
T
popertotsandSisyphus e4b9003439 refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns
Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:50 +01:00

3963 lines
124 KiB
Go

//go:build test
// +build test
package admin
// Package admin contains tests for admin booking management endpoints.
//
// Test Coverage:
// - GetAllAdminBookingsHandler: GET /api/admin/bookings - List all bookings with filters
// - GetAllAdminBookingsHandler Pagination: per_page cap up to 500, fallback to 10
// - SearchAdminBookingsHandler: POST /api/admin/bookings/search - Search bookings
// - GetAdminBookingHandler: GET /api/admin/bookings/{id} - Get booking details
// - AdminCreateBookingForUserHandler: POST /api/admin/bookings - Create booking for user
// - ProgressBookingHandler: PUT /api/admin/bookings/{id}/progress - Update booking status
// - ConfirmBookingHandler: POST /api/admin/bookings/{id}/confirm - Confirm booking
// - AdminCancelBookingHandler: POST /api/admin/bookings/{id}/cancel - Cancel booking
// - AdminListEditRequestsHandler: GET /api/admin/bookings/edit-requests - List edit requests
// - AdminApproveEditRequestHandler: POST /api/admin/bookings/{id}/approve-edit - Approve edit
// - AdminRejectEditRequestHandler: POST /api/admin/bookings/{id}/reject-edit - Reject edit
//
// Authentication: All endpoints require admin role (403 for non-admins).
import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/testutils"
"crussell/handlers/bookings"
"crussell/mw"
"crussell/testutils/fixtures"
"github.com/go-chi/chi/v5"
)
// =============================================================================
// List Admin Bookings Tests
// =============================================================================
// TestAdminBookings_List verifies that an admin can list all bookings in the
// system with pagination support.
func TestAdminBookings_List(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Insert name history for the user
_, err = tx.Exec(ctx, `
INSERT INTO name_history (user_id, previous_first_name, previous_last_name)
VALUES ($1, 'OldFirst', 'OldLast')
`, userID)
if err != nil {
t.Fatalf("failed to insert name_history: %v", err)
}
_, err = fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking 1: %v", err)
}
_, err = fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking 2: %v", err)
}
handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 2 {
t.Errorf("expected 2 bookings, got %d", len(resp.Bookings))
}
if resp.Total != 2 {
t.Errorf("expected total 2, got %d", resp.Total)
}
// Verify name history appears in booking user info
for _, b := range resp.Bookings {
if b.User == nil {
continue
}
if b.User.PreviousFirstName == nil || *b.User.PreviousFirstName != "OldFirst" {
t.Errorf("expected previousFirstName 'OldFirst' in booking, got %v", b.User.PreviousFirstName)
}
if b.User.PreviousLastName == nil || *b.User.PreviousLastName != "OldLast" {
t.Errorf("expected previousLastName 'OldLast' in booking, got %v", b.User.PreviousLastName)
}
}
}
// TestAdminBookings_List_PerPageCap tests that per_page parameter up to 500
// is accepted (was previously capped at 100, causing per_page=500 to fall
// back to default 10 and miss bookings on page 2+).
func TestAdminBookings_List_PerPageCap(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Create 3 bookings so we can verify per_page=500 returns all of them
for i := 0; i < 3; i++ {
_, err = fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking %d: %v", i+1, err)
}
}
// Test per_page=500 (should be accepted and return all 3 bookings)
handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings?per_page=500", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 3 {
t.Errorf("expected 3 bookings with per_page=500, got %d (per_page was likely rejected)", len(resp.Bookings))
}
if resp.PerPage != 500 {
t.Errorf("expected per_page=500 in response, got %d", resp.PerPage)
}
if resp.Total != 3 {
t.Errorf("expected total 3, got %d", resp.Total)
}
// Test per_page=600 (should be rejected and fall back to default 10)
w2 := makeAdminRequest(handler, "GET", "/api/admin/bookings?per_page=600", nil, ctx)
if w2.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w2.Code)
}
var resp2 bookings.BookingListResponse
if err := parseResponseBody(w2, &resp2); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp2.PerPage != 10 {
t.Errorf("expected per_page defaults to 10 when >500, got %d", resp2.PerPage)
}
}
// TestAdminBookings_List_FilterByStatus tests that an admin can filter
// bookings by status (e.g., pending, confirmed, completed).
func TestAdminBookings_List_FilterByStatus(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
_, err = fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings?status=pending", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp bookings.BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 1 {
t.Errorf("expected 1 pending booking, got %d", len(resp.Bookings))
}
w = makeAdminRequest(handler, "GET", "/api/admin/bookings?status=completed", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 0 {
t.Errorf("expected 0 bookings for completed status, got %d", len(resp.Bookings))
}
}
// =============================================================================
// Admin Create Booking Tests
// =============================================================================
// TestAdminBookings_Create verifies that an admin can create a booking
// on behalf of a user. The booking is created with 'confirmed' status.
func TestAdminBookings_Create(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
futureTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
req := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: futureTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
// Parse response with new format {"booking": {...}, "warnings": [...]}
var response map[string]interface{}
if err := parseResponseBody(w, &response); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
bookingData, ok := response["booking"].(map[string]interface{})
if !ok {
t.Fatal("expected booking in response")
}
status, ok := bookingData["status"].(string)
if !ok {
t.Fatal("expected status in booking")
}
if status != "confirmed" {
t.Errorf("expected status 'confirmed', got %s", status)
}
var count int
err = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM bookings WHERE user_id = $1", userID).Scan(&count)
if err != nil {
t.Errorf("failed to query bookings: %v", err)
}
if count != 1 {
t.Errorf("expected 1 booking, got %d", count)
}
}
// TestAdminBookings_Create_InvalidInput verifies that admin booking
// creation fails with HTTP 400 when required fields (userID, startTime, serviceIDs)
// are missing or invalid.
func TestAdminBookings_Create_InvalidInput(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
tests := []struct {
name string
req bookings.AdminCreateBookingForUserRequest
}{
{
name: "missing user ID",
req: bookings.AdminCreateBookingForUserRequest{
StartTime: clock.Now().Add(72 * time.Hour),
ServiceIDs: []string{"some-service-id"},
},
},
{
name: "missing start time",
req: bookings.AdminCreateBookingForUserRequest{
UserID: userID,
ServiceIDs: []string{"some-service-id"},
},
},
{
name: "missing service IDs",
req: bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: clock.Now().Add(72 * time.Hour),
},
},
{
name: "empty service IDs",
req: bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: clock.Now().Add(72 * time.Hour),
ServiceIDs: []string{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", tt.req, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
})
}
}
// =============================================================================
// Search Admin Bookings Tests
// =============================================================================
// TestAdminBookings_Search tests that an admin can search bookings by
// notes, customer name, or other text fields.
func TestAdminBookings_Search(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
_, err = tx.Exec(ctx,
"UPDATE bookings SET notes = 'Test booking for search' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to update booking notes: %v", err)
}
handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=Test", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 1 {
t.Errorf("expected 1 booking, got %d", len(resp.Bookings))
}
}
// TestAdminBookings_Search_MissingQuery verifies that searching without
// a query parameter returns HTTP 400 Bad Request.
func TestAdminBookings_Search_MissingQuery(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search", nil, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
}
// =============================================================================
// Get Single Admin Booking Tests
// =============================================================================
// TestAdminBookings_Get verifies that an admin can retrieve a single booking by ID and that
// the response includes populated user and services relationships.
func TestAdminBookings_Get(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
handler := http.HandlerFunc(bookings.GetAdminBookingHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var booking bookings.Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if booking.ID != bookingID {
t.Errorf("expected booking ID %s, got %s", bookingID, booking.ID)
}
if booking.User == nil {
t.Error("expected user to be populated")
}
if len(booking.Services) != 1 {
t.Errorf("expected 1 service, got %d", len(booking.Services))
}
}
// TestAdminBookings_Get_NotFound verifies that requesting a non-existent booking returns 404.
func TestAdminBookings_Get_NotFound(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
handler := http.HandlerFunc(bookings.GetAdminBookingHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/nonexistent-id", nil, ctx)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d", w.Code)
}
}
// =============================================================================
// Get User's Bookings Tests (Admin view)
// =============================================================================
// TestAdminBookings_GetUserBookings verifies that an admin can retrieve all bookings for a specific user.
func TestAdminBookings_GetUserBookings(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
_, err = fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking 1: %v", err)
}
_, err = fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking 2: %v", err)
}
handler := http.HandlerFunc(bookings.GetAllBookingsByUserHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/user/"+userID, nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 2 {
t.Errorf("expected 2 bookings, got %d", len(resp.Bookings))
}
if resp.Total != 2 {
t.Errorf("expected total 2, got %d", resp.Total)
}
}
// =============================================================================
// Progress Booking Tests
// =============================================================================
// TestAdminBookings_Progress verifies that an admin can change a booking's status (e.g., pending to confirmed),
// and that the status is correctly updated in both the response and database.
func TestAdminBookings_Progress(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
req := bookings.ProgressBookingRequest{
Status: "confirmed",
}
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", req, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var booking bookings.Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if booking.Status != "confirmed" {
t.Errorf("expected status 'confirmed', got %s", booking.Status)
}
var dbStatus string
err = tx.QueryRow(ctx,
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&dbStatus)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if dbStatus != "confirmed" {
t.Errorf("expected status 'confirmed' in DB, got %s", dbStatus)
}
}
// TestAdminBookings_Progress_InvalidStatus verifies that providing an invalid status value returns 400 Bad Request.
func TestAdminBookings_Progress_InvalidStatus(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
req := bookings.ProgressBookingRequest{
Status: "invalid_status",
}
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", req, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
}
// TestAdminBookings_Progress_NotFound verifies that attempting to progress a non-existent booking returns 404.
func TestAdminBookings_Progress_NotFound(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
req := bookings.ProgressBookingRequest{
Status: "confirmed",
}
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/nonexistent-id/progress", req, ctx)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d", w.Code)
}
}
// =============================================================================
// Confirm Booking Tests
// =============================================================================
// TestAdminBookings_Confirm verifies that an admin can confirm a pending booking, updating its status to 'confirmed'.
func TestAdminBookings_Confirm(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
req := bookings.ConfirmBookingRequest{}
handler := http.HandlerFunc(bookings.ConfirmBookingHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/confirm", req, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var booking bookings.Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if booking.Status != "confirmed" {
t.Errorf("expected status 'confirmed', got %s", booking.Status)
}
}
// TestAdminBookings_Confirm_AlreadyConfirmed verifies idempotency - attempting to confirm
// an already-confirmed booking returns 404 Not Found.
func TestAdminBookings_Confirm_AlreadyConfirmed(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to update booking status: %v", err)
}
req := bookings.ConfirmBookingRequest{}
handler := http.HandlerFunc(bookings.ConfirmBookingHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/confirm", req, ctx)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d", w.Code)
}
}
// =============================================================================
// Cancel Booking Tests
// =============================================================================
// TestAdminBookings_Cancel verifies that an admin can cancel a booking, updating its status to 'we_cancelled'.
func TestAdminBookings_Cancel(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to update booking status: %v", err)
}
handler := http.HandlerFunc(bookings.AdminCancelBookingHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx)
if w.Code != http.StatusNoContent {
t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String())
}
var dbStatus string
err = tx.QueryRow(ctx,
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&dbStatus)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if dbStatus != "we_cancelled" {
t.Errorf("expected status 'we_cancelled' in DB, got %s", dbStatus)
}
}
// TestAdminBookings_Cancel_NotFound verifies that attempting to cancel a non-existent booking returns 404.
func TestAdminBookings_Cancel_NotFound(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
handler := http.HandlerFunc(bookings.AdminCancelBookingHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings/nonexistent-id/cancel", nil, ctx)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d", w.Code)
}
}
// TestAdminBookings_Cancel_PendingStatus verifies that admin cancellations of pending bookings
// do NOT create admin notifications (pending cancellations don't require staff attention).
func TestAdminBookings_Cancel_PendingStatus(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
// Booking stays in 'pending' status (no confirmation)
handler := http.HandlerFunc(bookings.AdminCancelBookingHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx)
if w.Code != http.StatusNoContent {
t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String())
}
// Verify status changed to we_cancelled
var dbStatus string
err = tx.QueryRow(ctx,
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&dbStatus)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if dbStatus != "we_cancelled" {
t.Errorf("expected status 'we_cancelled' in DB, got %s", dbStatus)
}
// Verify NO admin notification was created for pending cancellations
var notifCount int
err = tx.QueryRow(ctx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1`,
bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query notifications: %v", err)
}
if notifCount != 0 {
t.Errorf("expected 0 admin notifications for pending cancel, got %d", notifCount)
}
}
// TestAdminBookings_Cancel_ConfirmedCreatesNotification verifies that cancelling a confirmed
// booking creates an admin notification for staff awareness.
func TestAdminBookings_Cancel_ConfirmedCreatesNotification(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
// Confirm the booking
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
handler := http.HandlerFunc(bookings.AdminCancelBookingHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx)
if w.Code != http.StatusNoContent {
t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String())
}
// Verify status changed to we_cancelled
var dbStatus string
err = tx.QueryRow(ctx,
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&dbStatus)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if dbStatus != "we_cancelled" {
t.Errorf("expected status 'we_cancelled' in DB, got %s", dbStatus)
}
// Verify admin notification WAS created for confirmed->cancelled
var notifCount int
err = tx.QueryRow(ctx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'cancelled_booking'`,
bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query notifications: %v", err)
}
if notifCount != 1 {
t.Errorf("expected 1 admin notification for confirmed cancel, got %d", notifCount)
}
}
// TestAdminBookings_Cancel_InProgressStatus verifies cancellation of in-progress bookings.
func TestAdminBookings_Cancel_InProgressStatus(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
// Set booking to in-progress status
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set in_progress status: %v", err)
}
handler := http.HandlerFunc(bookings.AdminCancelBookingHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx)
if w.Code != http.StatusNoContent {
t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String())
}
// Verify status changed to we_cancelled
var dbStatus string
err = tx.QueryRow(ctx,
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&dbStatus)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if dbStatus != "we_cancelled" {
t.Errorf("expected status 'we_cancelled' in DB, got %s", dbStatus)
}
}
// TestAdminBookings_Cancel_AlreadyCancelledRejectsCancellation verifies that attempting to
// cancel an already-cancelled booking returns 404 Not Found (idempotency guard).
func TestAdminBookings_Cancel_AlreadyCancelledRejectsCancellation(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
// Set to already cancelled
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'we_cancelled' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set we_cancelled status: %v", err)
}
// Try to cancel again
handler := http.HandlerFunc(bookings.AdminCancelBookingHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404 for already-cancelled booking, got %d", w.Code)
}
}
// TestAdminBookings_Cancel_CompletedRejectsCancellation verifies that attempting to cancel
// a completed booking returns 404 Not Found (cannot cancel finished appointments).
func TestAdminBookings_Cancel_CompletedRejectsCancellation(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
// Set to completed
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'completed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set completed status: %v", err)
}
// Try to cancel
handler := http.HandlerFunc(bookings.AdminCancelBookingHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404 for completed booking, got %d", w.Code)
}
}
// =============================================================================
// Non-Admin Tests
// =============================================================================
// TestAdminBookings_NonAdmin verifies that regular users receive 403 Forbidden when attempting
// to access any admin booking endpoints (list, get, create, progress, confirm, cancel, search).
func TestAdminBookings_NonAdmin(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
w := makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.GetAllAdminBookingsHandler)), "GET", "/api/admin/bookings", nil, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("LIST: expected status 403, got %d", w.Code)
}
req := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: clock.Now().Add(72 * time.Hour),
ServiceIDs: []string{serviceID},
}
w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)), "POST", "/api/admin/bookings", req, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("CREATE: expected status 403, got %d", w.Code)
}
w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.SearchAdminBookingsHandler)), "GET", "/api/admin/bookings/search?q=test", nil, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("SEARCH: expected status 403, got %d", w.Code)
}
w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.GetAdminBookingHandler)), "GET", "/api/admin/bookings/"+bookingID, nil, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("GET: expected status 403, got %d", w.Code)
}
progressReq := bookings.ProgressBookingRequest{Status: "confirmed"}
w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.ProgressBookingHandler)), "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("PROGRESS: expected status 403, got %d", w.Code)
}
confirmReq := bookings.ConfirmBookingRequest{}
w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.ConfirmBookingHandler)), "POST", "/api/admin/bookings/"+bookingID+"/confirm", confirmReq, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("CONFIRM: expected status 403, got %d", w.Code)
}
w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.AdminCancelBookingHandler)), "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("CANCEL: expected status 403, got %d", w.Code)
}
}
// =============================================================================
// Admin Booking Holiday Conflict Tests
// =============================================================================
// TestAdminBookings_Create_DuringHolidayHours_Rejected verifies that an admin cannot create a booking
// during hours marked as closed in the exceptional working hours (holiday) system.
func TestAdminBookings_Create_DuringHolidayHours_Rejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Create an exceptional (holiday) hours group for a fixed date (Thursday)
// Using absolute date - no timezone conversions
targetDate := time.Date(2026, 2, 26, 0, 0, 0, 0, time.UTC) // Thursday Feb 26, 2026
var groupID int
err = tx.QueryRow(ctx, `
INSERT INTO exceptional_working_hours_groups (name, description)
VALUES ($1, $2)
RETURNING id
`, "Holiday Closure", "Test holiday").Scan(&groupID)
if err != nil {
t.Fatalf("failed to create holiday group: %v", err)
}
// Add closed hours for targetDate (closed all day)
// DB convention: 0=Monday..6=Sunday; Go: 0=Sunday..6=Saturday. Convert.
dbWeekday := (int(targetDate.Weekday()) + 6) % 7
_, err = tx.Exec(ctx, `
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
VALUES ($1, $2, $3, $4, $5)
`, groupID, dbWeekday, "00:00:00", "23:59:59", false)
if err != nil {
t.Fatalf("failed to create holiday hours: %v", err)
}
// Apply the group to the week containing targetDate
// Must use Monday of that week (matching handler logic)
daysToMonday := int(targetDate.Weekday())
if daysToMonday == 0 {
daysToMonday = 7
}
mondayOfWeek := targetDate.AddDate(0, 0, -daysToMonday+1)
_, err = tx.Exec(ctx, `
INSERT INTO exceptional_group_applications (group_id, week_start)
VALUES ($1, $2)
`, groupID, mondayOfWeek)
if err != nil {
t.Fatalf("failed to create holiday application: %v", err)
}
// Try to create booking during holiday - should fail
targetTime := targetDate.Add(14 * time.Hour).Truncate(time.Second) // 2 PM on targetDate
req := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: targetTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx)
// Should be rejected (400 or 409 depending on implementation)
if w.Code == http.StatusCreated {
t.Errorf("expected booking to be rejected during holiday hours, but got 201")
}
}
// =============================================================================
// Search Edge Case Tests
// =============================================================================
// TestAdminBookings_Search_CaseInsensitive verifies that the admin booking search is case-insensitive,
// matching booking notes regardless of uppercase/lowercase differences.
func TestAdminBookings_Search_CaseInsensitive(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
// Update notes with mixed case
_, err = tx.Exec(ctx,
"UPDATE bookings SET notes = 'TestBooking With MixedCase' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to update booking notes: %v", err)
}
handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler)
// Test 1: Uppercase search should find mixed case notes
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=TESTBOOKING", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp bookings.BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 1 {
t.Errorf("expected 1 booking for uppercase search, got %d", len(resp.Bookings))
}
// Test 2: Lowercase search should also find
w = makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=testbooking", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 1 {
t.Errorf("expected 1 booking for lowercase search, got %d", len(resp.Bookings))
}
}
// TestAdminBookings_Search_NoResults verifies that searching with a query that matches no bookings
// returns an empty list with total count of 0.
func TestAdminBookings_Search_NoResults(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
_, err = fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler)
// Search with non-matching query
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=NONEXISTENT_QUERY_XYZ123", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200 for no results, got %d", w.Code)
}
var resp bookings.BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 0 {
t.Errorf("expected 0 bookings for non-matching search, got %d", len(resp.Bookings))
}
if resp.Total != 0 {
t.Errorf("expected total 0 for non-matching search, got %d", resp.Total)
}
}
// TestAdminBookings_Search_MultipleResults verifies that search returns all bookings whose notes
// contain the search query, with correct total count.
func TestAdminBookings_Search_MultipleResults(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Create multiple bookings with similar notes
bookingID1, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking 1: %v", err)
}
bookingID2, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking 2: %v", err)
}
// Update both with searchable notes
_, err = tx.Exec(ctx,
"UPDATE bookings SET notes = 'Search Query Pattern' WHERE id = $1", bookingID1)
if err != nil {
t.Fatalf("failed to update booking 1: %v", err)
}
_, err = tx.Exec(ctx,
"UPDATE bookings SET notes = 'Another Search Query' WHERE id = $1", bookingID2)
if err != nil {
t.Fatalf("failed to update booking 2: %v", err)
}
handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler)
// Search for "Query" - should match both
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=Query", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp bookings.BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 2 {
t.Errorf("expected 2 bookings matching 'Query', got %d", len(resp.Bookings))
}
if resp.Total != 2 {
t.Errorf("expected total 2, got %d", resp.Total)
}
}
// =============================================================================
// SearchAdminBookingsHandler — out_of_hours field
// =============================================================================
func TestAdminBookings_Search_OutOfHoursField(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
futureTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, out_of_hours, notes)
VALUES ($1, $2, 'confirmed', true, 'OutOfHoursSearchTest')
RETURNING id
`, userID, futureTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create out-of-hours booking: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to link service: %v", err)
}
handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=OutOfHoursSearchTest", nil, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) == 0 {
t.Fatal("expected at least 1 booking in search results")
}
var found bool
for _, b := range resp.Bookings {
if b.ID == bookingID {
if !b.OutOfHours {
t.Error("expected out_of_hours=true for the out-of-hours booking in search results")
}
found = true
break
}
}
if !found {
t.Error("expected out-of-hours booking to appear in search results")
}
}
func TestAdminBookings_Search_OutOfHoursFalseByDefault(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, notes)
VALUES ($1, $2, 'confirmed', 'NormalSearchBooking')
RETURNING id
`, userID, clock.Now().Add(72*time.Hour).Truncate(time.Second)).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to link service: %v", err)
}
handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=NormalSearchBooking", nil, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) == 0 {
t.Fatal("expected at least 1 booking in search results")
}
var found bool
for _, b := range resp.Bookings {
if b.ID == bookingID {
if b.OutOfHours {
t.Error("expected out_of_hours=false (default) for normal booking in search results")
}
found = true
break
}
}
if !found {
t.Error("expected normal booking to appear in search results")
}
}
// =============================================================================
// Admin List Edit Requests Tests
// =============================================================================
// TestAdminBookings_ListEditRequests verifies that an admin can list all pending edit requests
// for a specific booking, and that the response includes the correct count and request details.
func TestAdminBookings_ListEditRequests(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
// Update booking status to confirmed (required for edit requests)
_, err = tx.Exec(ctx,
"UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to update booking status: %v", err)
}
// Clean up ALL existing edit requests in DB to ensure clean state
_, err = tx.Exec(ctx, "DELETE FROM booking_edit_requests")
if err != nil {
t.Fatalf("failed to clean up edit requests: %v", err)
}
// Create 3 edit requests via direct SQL insert
var emptyServices []string
for i := 1; i <= 3; i++ {
_, err = tx.Exec(ctx, `
INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes, has_overrides)
VALUES ($1, $2, $3, $4, $5, $6)`,
bookingID, userID, clock.Now().Add(time.Duration(i)*24*time.Hour),
emptyServices, fmt.Sprintf("Edit request %d", i), false)
if err != nil {
t.Fatalf("failed to create edit request %d: %v", i, err)
}
}
handler := http.HandlerFunc(bookings.AdminListEditRequestsHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID+"/edit-requests", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp struct {
Requests []bookings.BookingEditRequest `json:"requests"`
Total int `json:"total"`
}
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Requests) != 3 {
t.Errorf("expected 3 edit requests, got %d", len(resp.Requests))
}
if resp.Total != 3 {
t.Errorf("expected total 3, got %d", resp.Total)
}
// Verify the requests are for the correct booking
for _, req := range resp.Requests {
if req.BookingID != bookingID {
t.Errorf("expected booking ID %s, got %s", bookingID, req.BookingID)
}
}
}
// =============================================================================
// Admin Deny Edit Request Tests
// =============================================================================
// TestAdminBookings_DenyEditRequest verifies that denying an edit request deletes the request
// while keeping the original booking time unchanged, and returns success.
func TestAdminBookings_DenyEditRequest(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Create confirmed booking
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
// Confirm the booking
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Get original start_time
var originalStartTime time.Time
err = tx.QueryRow(ctx,
"SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&originalStartTime)
if err != nil {
t.Fatalf("failed to get original start_time: %v", err)
}
// Create edit request with new_start_time via direct SQL
newStartTime := originalStartTime.Add(24 * time.Hour).Truncate(time.Minute)
var editRequestID string
err = tx.QueryRow(ctx,
`INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, notes)
VALUES ($1, $2, $3, 'Please change time')
RETURNING id`,
bookingID, userID, newStartTime).Scan(&editRequestID)
if err != nil {
t.Fatalf("failed to create edit request: %v", err)
}
// Call admin deny endpoint
handler := http.HandlerFunc(bookings.AdminRejectEditRequestHandler)
path := fmt.Sprintf("/api/admin/bookings/%s/edit-requests/%s/deny", bookingID, editRequestID)
req := httptest.NewRequest("POST", path, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
rctx.URLParams.Add("request_id", editRequestID)
reqCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx)
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, adminID)
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK && w.Code != http.StatusNoContent {
t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String())
}
// Verify edit request is deleted
var erCount int
err = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&erCount)
if err != nil {
t.Fatalf("failed to query edit requests: %v", err)
}
if erCount != 0 {
t.Errorf("expected edit request to be deleted after deny, got %d", erCount)
}
// Verify booking start_time unchanged
var finalStartTime time.Time
err = tx.QueryRow(ctx,
"SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&finalStartTime)
if err != nil {
t.Fatalf("failed to get final start_time: %v", err)
}
if !originalStartTime.Equal(finalStartTime) {
t.Errorf("expected booking start_time to remain %v, got %v", originalStartTime, finalStartTime)
}
}
// TestAdminBookings_ApproveEditRequest verifies that approving an edit request updates the booking's
// start_time to the requested time, deletes the edit request, and acknowledges the admin notification.
func TestAdminBookings_ApproveEditRequest(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
// Create admin user
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
// Create regular user
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
// Set deposits_required=0
_, 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)
}
// Create service
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Create confirmed booking
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
// Confirm the booking
_, err = tx.Exec(ctx,
"UPDATE bookings SET status = $1 WHERE id = $2", "confirmed", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Get original booking start_time
var originalStartTime time.Time
err = tx.QueryRow(ctx,
"SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&originalStartTime)
if err != nil {
t.Fatalf("failed to get original start_time: %v", err)
}
// Create edit request with new_start_time
newStartTime := clock.Now().Add(48 * time.Hour).Truncate(time.Minute)
var editRequestID string
var emptyServices []string
err = tx.QueryRow(ctx,
`INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes)
VALUES ($1, $2, $3, $4, 'Please change time')
RETURNING id`,
bookingID, userID, newStartTime, emptyServices).Scan(&editRequestID)
if err != nil {
t.Fatalf("failed to create edit request: %v", err)
}
// Create admin notification
_, err = tx.Exec(ctx,
`INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_requested', $1, $2)`,
bookingID, userID)
if err != nil {
t.Fatalf("failed to create admin notification: %v", err)
}
// Call admin approve endpoint
handler := http.HandlerFunc(bookings.AdminApproveEditRequestHandler)
path := fmt.Sprintf("/api/admin/bookings/%s/edit-requests/%s/approve", bookingID, editRequestID)
w := makeAdminRequest(handler, "POST", path, nil, ctx)
if w.Code != http.StatusNoContent {
t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String())
}
// Verify edit request was deleted (approved)
var erCount int
err = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM booking_edit_requests WHERE id = $1", editRequestID).Scan(&erCount)
if err != nil {
t.Fatalf("failed to query edit requests: %v", err)
}
if erCount != 0 {
t.Errorf("expected 0 edit requests after approve, got %d", erCount)
}
// Verify booking start_time was updated to new_start_time
var updatedStartTime time.Time
err = tx.QueryRow(ctx,
"SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&updatedStartTime)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if !updatedStartTime.Equal(newStartTime) {
t.Errorf("expected booking start_time %v, got %v", newStartTime, updatedStartTime)
}
// Verify admin notification was acknowledged
var ackTime *time.Time
err = tx.QueryRow(ctx,
`SELECT acknowledged_at FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_requested'`,
bookingID).Scan(&ackTime)
if err != nil {
t.Fatalf("failed to query notification: %v", err)
}
if ackTime == nil {
t.Errorf("expected notification to be acknowledged after approve, but acknowledged_at is still NULL")
}
}
// =============================================================================
// Deposit System Tests
// =============================================================================
// TestAdminBookings_Get_DepositFields verifies that admin booking endpoints return
// deposit-related fields (deposit_required, deposit_amount, deposit_paid, deposit_deadline)
// for bookings that have deposit_required=true.
func TestAdminBookings_Get_DepositFields(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Create booking via SQL with deposit_required=true (simulating user-created booking)
futureTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, deposit_required)
VALUES ($1, $2, 'confirmed', true)
RETURNING id
`, userID, futureTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Link service to booking
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to link service: %v", err)
}
// GET single booking via admin endpoint
w := makeAdminRequest(http.HandlerFunc(bookings.GetAdminBookingHandler), "GET", "/api/admin/bookings/"+bookingID, nil, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var fetchedBooking bookings.Booking
if err := parseResponseBody(w, &fetchedBooking); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
// Verify deposit fields are populated
if !fetchedBooking.DepositRequired {
t.Error("expected DepositRequired to be true")
}
if fetchedBooking.DepositAmount <= 0 {
t.Error("expected DepositAmount to be positive")
}
// DepositPaid is a bool, just verify it exists
_ = fetchedBooking.DepositPaid
if fetchedBooking.DepositDeadline == nil {
t.Error("expected DepositDeadline to be set")
}
}
// TestAdminBookings_List_DepositFields verifies that admin booking list returns
// deposit-related fields for each booking.
func TestAdminBookings_List_DepositFields(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Create booking via SQL with deposit_required=true
futureTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, deposit_required)
VALUES ($1, $2, 'pending', true)
RETURNING id
`, userID, futureTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Link service to booking
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to link service: %v", err)
}
// GET all bookings via admin endpoint
w := makeAdminRequest(http.HandlerFunc(bookings.GetAllAdminBookingsHandler), "GET", "/api/admin/bookings", nil, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 1 {
t.Fatalf("expected 1 booking, got %d", len(resp.Bookings))
}
booking := resp.Bookings[0]
// Verify deposit fields are populated in list
if !booking.DepositRequired {
t.Error("expected DepositRequired to be true in list")
}
if booking.DepositAmount <= 0 {
t.Error("expected DepositAmount to be positive in list")
}
}
// =============================================================================
// Time Blocker Tests for Admin Bookings
// =============================================================================
// TestAdminBookings_Create_OverlappingBlocker_WithWarning verifies that admins can
// create bookings that overlap with time blockers, but receive a warning.
// The booking is still created (201 Created), unlike regular users who get 409.
func TestAdminBookings_Create_OverlappingBlocker_WithWarning(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Create a time blocker for a specific time
blockerTime := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'Staff meeting', $2)
`, blockerTime, adminID)
if err != nil {
t.Fatalf("failed to create time blocker: %v", err)
}
// Admin creates booking overlapping the blocker
req := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: blockerTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx)
// Admin should get 201 Created (not 409 Conflict)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
// Parse response to check for warnings
var response map[string]interface{}
if err := parseResponseBody(w, &response); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
// Check for warnings array
warnings, ok := response["warnings"].([]interface{})
if !ok || len(warnings) == 0 {
t.Error("expected warnings array with at least one warning")
} else {
// Verify warning mentions the blocker
warningStr, ok := warnings[0].(string)
if !ok {
t.Errorf("expected warning to be a string, got: %v", warnings[0])
} else if !bytes.Contains([]byte(warningStr), []byte("blocker")) {
t.Errorf("expected warning to mention 'blocker', got: %s", warningStr)
}
}
// Verify booking was created
bookingData, ok := response["booking"].(map[string]interface{})
if !ok {
t.Fatal("expected booking in response")
}
if bookingData["id"] == nil {
t.Error("expected booking ID to be set")
}
}
// TestAdminBookings_Edit_OverlappingBlocker_WithWarning verifies that admins can
// edit bookings to overlap with time blockers, but receive a warning.
// The booking is still updated (200 OK with warnings), unlike regular users who get 409.
// TestAdminBookings_Create_EnforceDeposits_Bypass tests that admin can create bookings
// for users with outstanding deposits by setting enforce_deposits=false.
func TestAdminBookings_Create_EnforceDeposits_Bypass(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Set user to have outstanding deposits (deposits_required = 3)
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
futureTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
falseVal := false
req := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: futureTime,
ServiceIDs: []string{serviceID},
EnforceDeposits: &falseVal, // Bypass deposit check
}
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx)
// Should succeed (not 409 Conflict) because deposits check was bypassed
if w.Code != http.StatusCreated {
t.Errorf("expected status 201 when enforce_deposits=false, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminBookings_Create_EnforceDeposits_Enforced tests that by default (or when enforce_deposits=true),
// admin bookings respect the deposit requirement rules.
func TestAdminBookings_Create_EnforceDeposits_Enforced(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Set user to have outstanding deposits
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
// Create first booking for user (will have it active)
firstTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
firstReq := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: firstTime,
ServiceIDs: []string{serviceID},
EnforceDeposits: nil, // Default: enforce deposits
}
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", firstReq, ctx)
if w.Code != http.StatusCreated {
t.Fatalf("failed to create first booking: %d. body: %s", w.Code, w.Body.String())
}
// Try to create second booking (should fail due to one-active-booking limit)
secondTime := clock.Now().Add(96 * time.Hour).Truncate(time.Second)
secondReq := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: secondTime,
ServiceIDs: []string{serviceID},
EnforceDeposits: nil, // Default: enforce (deposits_required > 0 still active)
}
w = makeAdminRequest(handler, "POST", "/api/admin/bookings", secondReq, ctx)
// Should get 409 Conflict because user has active booking and deposits outstanding
if w.Code != http.StatusConflict {
t.Errorf("expected status 409 when enforce_deposits is enforced, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminBookings_Create_WalkIn tests that admins can create walk-in bookings
// (no advance time requirement), including immediate/past times if needed.
func TestAdminBookings_Create_WalkIn(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Seed working hours so we have a valid booking window
// Try to create booking with walk-in time (30 minutes from now - less than 1h requirement)
// Regular users would be rejected, but admin should succeed
walkInTime := clock.Now().Add(30 * time.Minute).Truncate(time.Second)
walkInTime = time.Date(walkInTime.Year(), walkInTime.Month(), walkInTime.Day(), 10, 0, 0, 0, walkInTime.Location())
req := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: walkInTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx)
// Should succeed - admins can bypass the 1-hour minimum requirement
if w.Code != http.StatusCreated {
t.Errorf("expected status 201 for admin walk-in, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminBookings_Create_WalkInWithDeposits tests that admins can create walk-ins
// even when user has outstanding deposits and enforce_deposits=false.
func TestAdminBookings_Create_WalkInWithDeposits(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Seed working hours
// Set user to have outstanding deposits
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits: %v", err)
}
// Create walk-in booking with enforce_deposits=false
walkInTime := clock.Now().Add(15 * time.Minute).Truncate(time.Second)
walkInTime = time.Date(walkInTime.Year(), walkInTime.Month(), walkInTime.Day(), 10, 0, 0, 0, walkInTime.Location())
falseVal := false
req := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: walkInTime,
ServiceIDs: []string{serviceID},
EnforceDeposits: &falseVal, // Bypass deposit checks
}
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx)
// Should succeed - admin walk-in with deposit bypass
if w.Code != http.StatusCreated {
t.Errorf("expected status 201 for admin walk-in with deposits, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// Deposit Reduction Tests (Tasks 4, 6, 10)
// =============================================================================
// TestAdminBookings_Confirm_CompletesWithPayment_ReducesDeposits verifies that when a
// confirmed booking is progressed to completed with at least one payment, the user's
// deposits_required is reduced by 1.
func TestAdminBookings_Confirm_CompletesWithPayment_ReducesDeposits(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Set user to have deposits_required = 2
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 2 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
// Create confirmed booking
futureTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'confirmed')
RETURNING id
`, userID, futureTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Link service to booking
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to link service: %v", err)
}
// Add a payment for the booking
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, amount, payment_type, payment_method, status)
VALUES ($1, 50.00, 'deposit', 'online_square', 'completed')
`, bookingID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Progress booking to completed via admin progress handler
progressReq := bookings.ProgressBookingRequest{
Status: "completed",
}
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Verify deposits_required was reduced from 2 to 1
var depositsRequired int
err = tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired)
if err != nil {
t.Fatalf("failed to query deposits_required: %v", err)
}
if depositsRequired != 1 {
t.Errorf("expected deposits_required = 1 after completing booking with payment, got %d", depositsRequired)
}
}
// TestAdminBookings_Confirm_CompletesWithoutPayment_NoReduction verifies that when a
// booking is completed without any payments, the user's deposits_required is NOT reduced.
func TestAdminBookings_Confirm_CompletesWithoutPayment_NoReduction(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Set user to have deposits_required = 2
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 2 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
// Create confirmed booking
futureTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'confirmed')
RETURNING id
`, userID, futureTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Link service to booking
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to link service: %v", err)
}
// Note: NO payment added - this is the key difference
// Progress booking to completed via admin progress handler
progressReq := bookings.ProgressBookingRequest{
Status: "completed",
}
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Verify deposits_required is still 2 (no reduction because no payment)
var depositsRequired int
err = tx.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired)
if err != nil {
t.Fatalf("failed to query deposits_required: %v", err)
}
if depositsRequired != 2 {
t.Errorf("expected deposits_required = 2 (unchanged) after completing booking without payment, got %d", depositsRequired)
}
}
// TestAdminBookings_Create_EnforceDepositsFalse_BypassesLimit verifies that when
// enforce_deposits is set to false, the admin can create a second booking for a user
// who already has an active booking, bypassing the one-active-booking limit.
func TestAdminBookings_Create_EnforceDepositsFalse_BypassesLimit(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Set user to have deposits_required = 3
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
// Create first booking (will be active)
firstTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
firstReq := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: firstTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", firstReq, ctx)
if w.Code != http.StatusCreated {
t.Fatalf("failed to create first booking: %d. body: %s", w.Code, w.Body.String())
}
// Try to create second booking with enforce_deposits=false
secondTime := clock.Now().Add(96 * time.Hour).Truncate(time.Second)
falseVal := false
secondReq := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: secondTime,
ServiceIDs: []string{serviceID},
EnforceDeposits: &falseVal, // Bypass deposit checks
}
w = makeAdminRequest(handler, "POST", "/api/admin/bookings", secondReq, ctx)
// Should succeed (201 Created) because enforce_deposits=false bypasses the limit
if w.Code != http.StatusCreated {
t.Errorf("expected status 201 when enforce_deposits=false, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminBookings_Create_EnforceDepositsFalse_Within24h verifies that when
// enforce_deposits is set to false, the admin can create a booking within 24 hours
// for a user with outstanding deposits.
func TestAdminBookings_Create_EnforceDepositsFalse_Within24h(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Seed working hours
// Set user to have deposits_required = 3
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
// Try to create booking within 24 hours with enforce_deposits=false
// Use a time 12 hours from now (within 24h)
within24h := clock.Now().Add(12 * time.Hour).Truncate(time.Second)
// Adjust to a valid slot within working hours
within24h = time.Date(within24h.Year(), within24h.Month(), within24h.Day(), 14, 0, 0, 0, within24h.Location())
falseVal := false
req := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: within24h,
ServiceIDs: []string{serviceID},
EnforceDeposits: &falseVal, // Bypass deposit checks
}
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx)
// Should succeed (201 Created) because enforce_deposits=false bypasses the 24h check
if w.Code != http.StatusCreated {
t.Errorf("expected status 201 when enforce_deposits=false for within-24h booking, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAdminBookings_Create_WalkInGuestUser verifies that an admin can create a booking
// for a guest user (created via fixtures.CreateTestGuestUser).
func TestAdminBookings_Create_WalkInGuestUser(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
guestID, err := fixtures.CreateTestGuestUser(tx)
if err != nil {
t.Fatalf("failed to create guest user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Seed working hours
// Create booking for tomorrow
tomorrow := clock.Now().Add(24 * time.Hour).Truncate(time.Second)
tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location())
req := bookings.AdminCreateBookingForUserRequest{
UserID: guestID,
StartTime: tomorrow,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
// Verify booking exists with correct user_id
var foundUserID string
err = tx.QueryRow(ctx, `
SELECT user_id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1
`, guestID).Scan(&foundUserID)
if err != nil {
t.Fatalf("failed to verify booking exists: %v", err)
}
if foundUserID != guestID {
t.Errorf("expected booking user_id = %s, got %s", guestID, foundUserID)
}
}
// =============================================================================
// Exceptional Hours Tests
// =============================================================================
// TestAdminUpdateBooking_ClosedExceptionalHours_WarningOnly verifies that an admin can
// update a booking's time to fall within a closed exceptional hours period, receiving
// a warning but proceeding with the update.
func TestGetBookingsByCreatedRange(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Create bookings with specific created_at timestamps
_, err = tx.Exec(ctx, `
INSERT INTO bookings (id, user_id, start_time, status, created_at)
VALUES ('book00000001', $1, '2099-12-31 10:00:00+00', 'confirmed', '2025-01-15 09:00:00+00')
`, userID)
if err != nil {
t.Fatalf("failed to create booking 1: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000001', $1)
`, serviceID)
if err != nil {
t.Fatalf("failed to link service to booking 1: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO bookings (id, user_id, start_time, status, created_at)
VALUES ('book00000002', $1, '2099-12-31 11:00:00+00', 'pending', '2025-01-15 14:00:00+00')
`, userID)
if err != nil {
t.Fatalf("failed to create booking 2: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000002', $1)
`, serviceID)
if err != nil {
t.Fatalf("failed to link service to booking 2: %v", err)
}
// Booking outside the range (created before)
_, err = tx.Exec(ctx, `
INSERT INTO bookings (id, user_id, start_time, status, created_at)
VALUES ('book00000003', $1, '2099-12-31 12:00:00+00', 'confirmed', '2025-01-10 09:00:00+00')
`, userID)
if err != nil {
t.Fatalf("failed to create booking 3: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000003', $1)
`, serviceID)
if err != nil {
t.Fatalf("failed to link service to booking 3: %v", err)
}
handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-01-15T00:00:00Z&end=2025-01-16T00:00:00Z", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.OverlappingBookingsResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 2 {
t.Errorf("expected 2 bookings in range, got %d", len(resp.Bookings))
}
}
// TestGetBookingsByCreatedRange_Empty verifies that the endpoint returns an
// empty array when no bookings fall within the created_at range.
func TestGetBookingsByCreatedRange_Empty(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-01-01T00:00:00Z&end=2025-01-02T00:00:00Z", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.OverlappingBookingsResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 0 {
t.Errorf("expected 0 bookings, got %d", len(resp.Bookings))
}
}
// TestGetBookingsByCreatedRange_MissingParams verifies that the endpoint
// returns 400 when start or end query parameters are missing.
func TestGetBookingsByCreatedRange_MissingParams(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler)
// Missing both params
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range", nil, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 (missing both), got %d", w.Code)
}
// Missing end param
w = makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-01-01T00:00:00Z", nil, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 (missing end), got %d", w.Code)
}
// Missing start param
w = makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?end=2025-01-02T00:00:00Z", nil, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 (missing start), got %d", w.Code)
}
}
// TestGetBookingsByCreatedRange_InvalidFormat verifies that the endpoint
// returns 400 when the date format is invalid.
func TestGetBookingsByCreatedRange_InvalidFormat(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=not-a-date&end=2025-01-02T00:00:00Z", nil, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestGetBookingsByCreatedRange_OrderedByCreatedAt verifies that results
// are returned in ascending order by created_at.
func TestGetBookingsByCreatedRange_OrderedByCreatedAt(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Create bookings with created_at in reverse order
_, err = tx.Exec(ctx, `
INSERT INTO bookings (id, user_id, start_time, status, created_at)
VALUES ('book00000010', $1, '2099-12-31 10:00:00+00', 'confirmed', '2025-03-01 15:00:00+00')
`, userID)
if err != nil {
t.Fatalf("failed to create booking 10: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000010', $1)
`, serviceID)
if err != nil {
t.Fatalf("failed to link service to booking 10: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO bookings (id, user_id, start_time, status, created_at)
VALUES ('book00000011', $1, '2099-12-31 11:00:00+00', 'pending', '2025-03-01 10:00:00+00')
`, userID)
if err != nil {
t.Fatalf("failed to create booking 11: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000011', $1)
`, serviceID)
if err != nil {
t.Fatalf("failed to link service to booking 11: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO bookings (id, user_id, start_time, status, created_at)
VALUES ('book00000012', $1, '2099-12-31 12:00:00+00', 'confirmed', '2025-03-01 12:00:00+00')
`, userID)
if err != nil {
t.Fatalf("failed to create booking 12: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000012', $1)
`, serviceID)
if err != nil {
t.Fatalf("failed to link service to booking 12: %v", err)
}
handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-03-01T00:00:00Z&end=2025-03-02T00:00:00Z", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.OverlappingBookingsResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) != 3 {
t.Fatalf("expected 3 bookings, got %d", len(resp.Bookings))
}
// Verify order: book00000011 (10:00) < book00000012 (12:00) < book00000010 (15:00)
expectedOrder := []string{"book00000011", "book00000012", "book00000010"}
for i, expected := range expectedOrder {
if resp.Bookings[i].ID != expected {
t.Errorf("booking[%d] expected %s, got %s", i, expected, resp.Bookings[i].ID)
}
}
}
func TestGetAdminBooking_WithDiscounts(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Create completed booking
bookingTime := clock.Now().Add(-24 * time.Hour)
bookingID := createCompletedBookingWithTimeForAdmin(t, ctx, tx, userID, serviceID, bookingTime, 50.00)
// Create a discount campaign and apply it
var campaignID string
err = tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '2 days', NOW() + INTERVAL '2 days')
RETURNING id
`, "Admin Test Campaign").Scan(&campaignID)
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'time_based', 10.0, 50.00, 5.00)
`, bookingID, userID, campaignID)
if err != nil {
t.Fatalf("failed to create booking discount: %v", err)
}
// Call GetAdminBookingHandler
handler := http.HandlerFunc(bookings.GetAdminBookingHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var booking bookings.Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Fatalf("failed to parse booking: %v", err)
}
if len(booking.Discounts) != 1 {
t.Fatalf("expected 1 discount, got %d", len(booking.Discounts))
}
d := booking.Discounts[0]
if d.CampaignName == nil || *d.CampaignName != "Admin Test Campaign" {
t.Errorf("expected campaign name 'Admin Test Campaign', got %v", d.CampaignName)
}
if d.DiscountAmount != 5.00 {
t.Errorf("expected discount amount 5.00, got %.2f", d.DiscountAmount)
}
}
func createCompletedBookingWithTimeForAdmin(t *testing.T, ctx context.Context, tx db.Querier, userID, serviceID string, startTime time.Time, price float64) string {
t.Helper()
var bookingID string
err := tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'completed')
RETURNING id
`, userID, startTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create completed booking: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id, override_price)
VALUES ($1, $2, $3)
`, bookingID, serviceID, price)
if err != nil {
t.Fatalf("failed to link service: %v", err)
}
return bookingID
}
// =============================================================================
// Custom Service Tests
// =============================================================================
// TestAdminBookings_CreateWithCustomServices verifies that an admin can create
// a booking with only custom services via AdminCreateBookingForUserHandler.
// It checks that the booking_custom_services entry is created and the custom
// service usage_count is incremented.
func TestAdminBookings_CreateWithCustomServices(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
customServiceID, err := fixtures.CreateTestCustomService(tx)
if err != nil {
t.Fatalf("failed to create custom service: %v", err)
}
futureTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
req := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: futureTime,
CustomServiceIDs: []string{customServiceID},
}
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var response map[string]interface{}
if err := parseResponseBody(w, &response); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
bookingData, ok := response["booking"].(map[string]interface{})
if !ok {
t.Fatal("expected booking in response")
}
bookingID, ok := bookingData["id"].(string)
if !ok || bookingID == "" {
t.Fatal("expected booking id in response")
}
// Verify booking_custom_services has the custom service linked
var bcsCount int
err = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM booking_custom_services WHERE booking_id = $1 AND custom_service_id = $2",
bookingID, customServiceID).Scan(&bcsCount)
if err != nil {
t.Fatalf("failed to query booking_custom_services: %v", err)
}
if bcsCount != 1 {
t.Errorf("expected 1 booking_custom_services entry, got %d", bcsCount)
}
// Verify custom_services usage_count was incremented
var usageCount int
err = tx.QueryRow(ctx,
"SELECT usage_count FROM custom_services WHERE id = $1", customServiceID).Scan(&usageCount)
if err != nil {
t.Fatalf("failed to query custom_service usage_count: %v", err)
}
if usageCount < 1 {
t.Errorf("expected usage_count >= 1, got %d", usageCount)
}
}
// TestAdminBookings_CreateWithCustomAndRegularServices verifies that an admin
// can create a booking with both regular and custom services simultaneously.
// It checks that entries are created in both booking_services and
// booking_custom_services.
func TestAdminBookings_CreateWithCustomAndRegularServices(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
customServiceID, err := fixtures.CreateTestCustomService(tx)
if err != nil {
t.Fatalf("failed to create custom service: %v", err)
}
futureTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
req := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: futureTime,
ServiceIDs: []string{serviceID},
CustomServiceIDs: []string{customServiceID},
}
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var response map[string]interface{}
if err := parseResponseBody(w, &response); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
bookingData, ok := response["booking"].(map[string]interface{})
if !ok {
t.Fatal("expected booking in response")
}
bookingID, ok := bookingData["id"].(string)
if !ok || bookingID == "" {
t.Fatal("expected booking id in response")
}
// Verify booking_services has the regular service
var svcCount int
err = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM booking_services WHERE booking_id = $1 AND service_id = $2",
bookingID, serviceID).Scan(&svcCount)
if err != nil {
t.Fatalf("failed to query booking_services: %v", err)
}
if svcCount != 1 {
t.Errorf("expected 1 booking_services entry, got %d", svcCount)
}
// Verify booking_custom_services has the custom service
var csCount int
err = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM booking_custom_services WHERE booking_id = $1 AND custom_service_id = $2",
bookingID, customServiceID).Scan(&csCount)
if err != nil {
t.Fatalf("failed to query booking_custom_services: %v", err)
}
if csCount != 1 {
t.Errorf("expected 1 booking_custom_services entry, got %d", csCount)
}
}
// TestAdminBookings_Create_CustomServiceValidation verifies that the handler
// rejects requests with neither service_ids nor custom_service_ids, testing
// both nil and empty arrays.
func TestAdminBookings_Create_CustomServiceValidation(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
// Test 1: both service_ids and custom_service_ids are nil
t.Run("missing both service and custom service IDs", func(t *testing.T) {
req := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: clock.Now().Add(72 * time.Hour),
}
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
})
// Test 2: both are empty arrays
t.Run("empty service_ids and custom_service_ids arrays", func(t *testing.T) {
req := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: clock.Now().Add(72 * time.Hour),
ServiceIDs: []string{},
CustomServiceIDs: []string{},
}
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
})
// Test 3: custom_service_ids is provided (should succeed — need working hours)
t.Run("provides custom_service_ids only", func(t *testing.T) {
customServiceID, err := fixtures.CreateTestCustomService(tx)
if err != nil {
t.Fatalf("failed to create custom service: %v", err)
}
futureTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
req := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: futureTime,
CustomServiceIDs: []string{customServiceID},
}
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
})
}
// TestAdminBookings_Confirm_WithCustomOverrides verifies that an admin can
// confirm a booking with custom service overrides via ConfirmBookingHandler.
// It checks that the override_price and override_duration_minutes are stored
// in booking_custom_services.
func TestAdminBookings_Confirm_WithCustomOverrides(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
customServiceID, err := fixtures.CreateTestCustomService(tx)
if err != nil {
t.Fatalf("failed to create custom service: %v", err)
}
// Create a pending booking with a regular service
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
// Link a custom service to the booking
_, err = tx.Exec(ctx,
"INSERT INTO booking_custom_services (booking_id, custom_service_id) VALUES ($1, $2)",
bookingID, customServiceID)
if err != nil {
t.Fatalf("failed to link custom service to booking: %v", err)
}
// Confirm with custom service overrides
overridePrice := 65.00
overrideDuration := 30
confirmReq := bookings.ConfirmBookingRequest{
CustomServiceOverrides: []bookings.ServiceOverride{
{
ServiceID: customServiceID,
OverridePrice: &overridePrice,
OverrideDurationMinutes: &overrideDuration,
},
},
}
handler := http.HandlerFunc(bookings.ConfirmBookingHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/confirm", confirmReq, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var booking bookings.Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if booking.Status != "confirmed" {
t.Errorf("expected status 'confirmed', got %s", booking.Status)
}
// Verify override was applied to booking_custom_services
var actualPrice *float64
var actualDuration *int
err = tx.QueryRow(ctx,
"SELECT override_price, override_duration_minutes FROM booking_custom_services WHERE booking_id = $1 AND custom_service_id = $2",
bookingID, customServiceID).Scan(&actualPrice, &actualDuration)
if err != nil {
t.Fatalf("failed to query booking_custom_services override: %v", err)
}
if actualPrice == nil || *actualPrice != overridePrice {
t.Errorf("expected override_price %.2f, got %v", overridePrice, actualPrice)
}
if actualDuration == nil || *actualDuration != overrideDuration {
t.Errorf("expected override_duration_minutes %d, got %v", overrideDuration, actualDuration)
}
}
// TestAdminBookings_CreateWithCustomServicesAndOverrides verifies that an admin
// can create a booking with custom services and apply price/duration overrides
// at creation time via AdminCreateBookingForUserHandler.
func TestAdminBookings_CreateWithCustomServicesAndOverrides(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
customServiceID, err := fixtures.CreateTestCustomService(tx)
if err != nil {
t.Fatalf("failed to create custom service: %v", err)
}
overridePrice := 60.00
overrideDuration := 30
futureTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
req := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: futureTime,
CustomServiceIDs: []string{customServiceID},
CustomOverrides: []bookings.ServiceOverride{
{
ServiceID: customServiceID,
OverridePrice: &overridePrice,
OverrideDurationMinutes: &overrideDuration,
},
},
}
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var response map[string]interface{}
if err := parseResponseBody(w, &response); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
bookingData, ok := response["booking"].(map[string]interface{})
if !ok {
t.Fatal("expected booking in response")
}
bookingID, ok := bookingData["id"].(string)
if !ok || bookingID == "" {
t.Fatal("expected booking id in response")
}
// Verify override was applied to booking_custom_services
var actualPrice *float64
var actualDuration *int
err = tx.QueryRow(ctx,
"SELECT override_price, override_duration_minutes FROM booking_custom_services WHERE booking_id = $1 AND custom_service_id = $2",
bookingID, customServiceID).Scan(&actualPrice, &actualDuration)
if err != nil {
t.Fatalf("failed to query booking_custom_services override: %v", err)
}
if actualPrice == nil || *actualPrice != overridePrice {
t.Errorf("expected override_price %.2f, got %v", overridePrice, actualPrice)
}
if actualDuration == nil || *actualDuration != overrideDuration {
t.Errorf("expected override_duration_minutes %d, got %v", overrideDuration, actualDuration)
}
}
// TestAdminBookings_AdminReserve_WithCustomServices verifies that an admin can
// create a call-in reservation with custom services via AdminReserveSlotHandler.
// It verifies the response duration matches the custom service duration and
// that the time_blocker is created reflecting the custom service duration.
func TestAdminBookings_AdminReserve_WithCustomServices(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)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
customServiceID, err := fixtures.CreateTestCustomService(tx)
if err != nil {
t.Fatalf("failed to create custom service: %v", err)
}
// Set custom service duration to a known value for assertion
_, err = tx.Exec(ctx,
"UPDATE custom_services SET duration_minutes = 45 WHERE id = $1", customServiceID)
if err != nil {
t.Fatalf("failed to update custom service duration: %v", err)
}
tomorrow := clock.Now().Add(24 * time.Hour).Truncate(time.Second)
tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location())
req := bookings.AdminReserveSlotRequest{
UserID: &userID,
ReservationType: "callin",
StartTime: tomorrow,
CustomServiceIDs: []string{customServiceID},
TTLMinutes: 15,
}
handler := http.HandlerFunc(bookings.AdminReserveSlotHandler)
w := makeRequestWithContext(handler, "POST", "/api/admin/bookings/reserve", req, adminID, "admin", ctx)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.AdminReserveSlotResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
// Custom service duration is 45 minutes
if resp.DurationMinutes != 45 {
t.Errorf("expected duration 45, 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)
}
}
// =============================================================================
// GetAdminBookingHandler — out_of_hours field
// =============================================================================
func TestGetAdminBooking_OutOfHoursField(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
futureTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, out_of_hours)
VALUES ($1, $2, 'confirmed', true)
RETURNING id
`, userID, futureTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create out-of-hours booking: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to link service: %v", err)
}
handler := http.HandlerFunc(bookings.GetAdminBookingHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var booking bookings.Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Fatalf("failed to parse booking: %v", err)
}
if !booking.OutOfHours {
t.Error("expected out_of_hours=true in booking response")
}
}
func TestGetAdminBooking_OutOfHoursFalseByDefault(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'confirmed')
RETURNING id
`, userID, clock.Now().Add(72*time.Hour).Truncate(time.Second)).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to link service: %v", err)
}
handler := http.HandlerFunc(bookings.GetAdminBookingHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var booking bookings.Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Fatalf("failed to parse booking: %v", err)
}
if booking.OutOfHours {
t.Error("expected out_of_hours=false (default) for normal booking")
}
}
// =============================================================================
// GetAllAdminBookingsHandler — out_of_hours field
// =============================================================================
func TestAdminBookings_List_OutOfHoursField(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
futureTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, out_of_hours)
VALUES ($1, $2, 'confirmed', true)
RETURNING id
`, userID, futureTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create out-of-hours booking: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to link service: %v", err)
}
handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings", nil, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) == 0 {
t.Fatal("expected at least 1 booking in list response")
}
var found bool
for _, b := range resp.Bookings {
if b.ID == bookingID {
if !b.OutOfHours {
t.Error("expected out_of_hours=true for the out-of-hours booking in list response")
}
found = true
break
}
}
if !found {
t.Error("expected out-of-hours booking to appear in list response")
}
}
func TestAdminBookings_List_OutOfHoursFalseByDefault(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'confirmed')
RETURNING id
`, userID, clock.Now().Add(72*time.Hour).Truncate(time.Second)).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to link service: %v", err)
}
handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/bookings", nil, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp.Bookings) == 0 {
t.Fatal("expected at least 1 booking in list response")
}
var found bool
for _, b := range resp.Bookings {
if b.ID == bookingID {
if b.OutOfHours {
t.Error("expected out_of_hours=false for normal booking in list response")
}
found = true
break
}
}
if !found {
t.Error("expected normal booking to appear in list response")
}
}
// =============================================================================
// AdminCreateBookingForUserHandler — out_of_hours field
// =============================================================================
func TestAdminBookings_Create_OutOfHours(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
_, 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)
}
futureTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
req := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: futureTime,
ServiceIDs: []string{serviceID},
OutOfHours: true,
}
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var response map[string]interface{}
if err := parseResponseBody(w, &response); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
bookingData, ok := response["booking"].(map[string]interface{})
if !ok {
t.Fatal("expected booking in response")
}
outOfHours, ok := bookingData["out_of_hours"].(bool)
if !ok {
t.Fatal("expected out_of_hours field in booking response")
}
if !outOfHours {
t.Error("expected out_of_hours=true in create booking response")
}
bookingID, ok := bookingData["id"].(string)
if !ok || bookingID == "" {
t.Fatal("expected booking id in response")
}
var dbOutOfHours bool
err = tx.QueryRow(ctx, "SELECT out_of_hours FROM bookings WHERE id = $1", bookingID).Scan(&dbOutOfHours)
if err != nil {
t.Fatalf("failed to query booking out_of_hours: %v", err)
}
if !dbOutOfHours {
t.Error("expected out_of_hours=true in database")
}
}
func TestAdminBookings_Create_OutOfHoursFalseByDefault(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
_, 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)
}
futureTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
req := bookings.AdminCreateBookingForUserRequest{
UserID: userID,
StartTime: futureTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var response map[string]interface{}
if err := parseResponseBody(w, &response); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
bookingData, ok := response["booking"].(map[string]interface{})
if !ok {
t.Fatal("expected booking in response")
}
outOfHours, ok := bookingData["out_of_hours"].(bool)
if !ok {
t.Fatal("expected out_of_hours field in booking response")
}
if outOfHours {
t.Error("expected out_of_hours=false (default) in create booking response")
}
}
// TestAdminBookings_CountMatchesData verifies that the COUNT query returns the
// same total as the data query when filtering by date, even at BST boundary
// (23:xx UTC = 00:xx BST next day). Fix #1 — count query must use same London
// boundaries as the data query.
func TestAdminBookings_CountMatchesData(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Create a booking during BST period at 00:30 BST (= 23:30 UTC previous day)
londonLoc, _ := time.LoadLocation("Europe/London")
bkTime := time.Date(2099, 6, 15, 0, 30, 0, 0, londonLoc) // 00:30 BST = June 14 23:30 UTC
_, err = fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bkTime)
if err != nil {
t.Fatalf("failed to create booking at BST midnight: %v", err)
}
// Query with start_date = June 15 (the London date).
// The COUNT query should return 1, matching the data query.
handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler)
endDate := time.Date(2099, 6, 15, 23, 59, 0, 0, time.UTC).Format("2006-01-02")
startDate := time.Date(2099, 6, 15, 0, 0, 0, 0, time.UTC).Format("2006-01-02")
w := makeAdminRequest(handler, "GET",
"/api/admin/bookings?start_date="+startDate+"&end_date="+endDate, nil, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Total != len(resp.Bookings) {
t.Errorf("COUNT returned %d but data query returned %d bookings — COUNT must use same London boundaries as data query", resp.Total, len(resp.Bookings))
}
if resp.Total != 1 {
t.Errorf("expected 1 booking (at BST boundary, London date June 15), got total=%d bookings=%d", resp.Total, len(resp.Bookings))
}
}
// TestAdminBookings_CountMatchesData_AutumnDST verifies that the COUNT query
// returns the same total as the data query at the autumn DST boundary (BST→GMT
// transition on Oct 25, 2026). A booking at 00:30 BST on Oct 25 = 23:30 UTC
// Oct 24 — any date-boundary logic that uses UTC instead of London time would
// miss this booking or produce a COUNT/data mismatch.
func TestAdminBookings_CountMatchesData_AutumnDST(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Create a booking just after midnight BST on Oct 25, 2026 (autumn DST
// transition day — BST ends at 02:00 BST → 01:00 GMT).
// 00:30 BST on Oct 25 = 23:30 UTC on Oct 24.
// This booking has a London date of Oct 25 but a UTC date of Oct 24,
// so it would be missed by any query that uses UTC boundaries instead
// of London timezone boundaries.
londonLoc, _ := time.LoadLocation("Europe/London")
bkTime := time.Date(2026, 10, 25, 0, 30, 0, 0, londonLoc) // 00:30 BST
_, err = fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bkTime)
if err != nil {
t.Fatalf("failed to create booking at autumn DST midnight: %v", err)
}
// Query with date range Oct 25 (the London date).
// The booking should be found because its London date is Oct 25.
handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler)
startDate := "2026-10-25"
endDate := "2026-10-25"
w := makeAdminRequest(handler, "GET",
"/api/admin/bookings?start_date="+startDate+"&end_date="+endDate, nil, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp bookings.BookingListResponse
if err := parseResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Total != len(resp.Bookings) {
t.Errorf("COUNT returned %d but data query returned %d bookings — COUNT must use same London boundaries as data query", resp.Total, len(resp.Bookings))
}
if resp.Total != 1 {
t.Errorf("expected 1 booking (at autumn DST boundary, London date Oct 25), got total=%d bookings=%d", resp.Total, len(resp.Bookings))
}
}