- Add discount campaign management and validation logic - Update booking handlers with discount application flow - Add customer relationship endpoints for loyalty tracking - Update frontend modals (booking, approval, payment, reschedule) - Add DiscountsManagement and loyalty reference documentation - Update dev scripts and database init for discount tables - Clean up completed plan files
3210 lines
105 KiB
Go
3210 lines
105 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
|
|
// - 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"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"crussell/handlers/bookings"
|
|
"crussell/mw"
|
|
"crussell/testutils/fixtures"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// =============================================================================
|
|
func seedDefaultWorkingHours(t *testing.T) {
|
|
t.Helper()
|
|
|
|
// Seed 7 days of working hours (Monday=0 to Sunday=6)
|
|
// Use wide hours to avoid test failures due to business logic time checks
|
|
hours := []struct {
|
|
weekday int
|
|
startTime string
|
|
endTime string
|
|
isOpen bool
|
|
}{
|
|
{0, "08:00", "20:00", true}, // Monday
|
|
{1, "08:00", "20:00", true}, // Tuesday
|
|
{2, "08:00", "20:00", true}, // Wednesday
|
|
{3, "08:00", "20:00", true}, // Thursday
|
|
{4, "08:00", "20:00", true}, // Friday
|
|
{5, "08:00", "20:00", true}, // Saturday
|
|
{6, "08:00", "20:00", true}, // Sunday
|
|
}
|
|
|
|
for _, h := range hours {
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (weekday) DO UPDATE SET start_time = $2, end_time = $3, is_open = $4
|
|
`, h.weekday, h.startTime, h.endTime, h.isOpen)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed working hours: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
bookingID1, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking 1: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID1)
|
|
|
|
bookingID2, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking 2: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID2)
|
|
|
|
handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler)
|
|
w := makeAdminRequest(handler, "GET", "/api/admin/bookings", nil)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestAdminBookings_List_FilterByStatus tests that an admin can filter
|
|
// bookings by status (e.g., pending, confirmed, completed).
|
|
func TestAdminBookings_List_FilterByStatus(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler)
|
|
w := makeAdminRequest(handler, "GET", "/api/admin/bookings?status=pending", nil)
|
|
|
|
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)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
futureTime := time.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)
|
|
|
|
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 = db.DB.QueryRow(context.Background(),
|
|
"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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
tests := []struct {
|
|
name string
|
|
req bookings.AdminCreateBookingForUserRequest
|
|
}{
|
|
{
|
|
name: "missing user ID",
|
|
req: bookings.AdminCreateBookingForUserRequest{
|
|
StartTime: time.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: time.Now().Add(72 * time.Hour),
|
|
},
|
|
},
|
|
{
|
|
name: "empty service IDs",
|
|
req: bookings.AdminCreateBookingForUserRequest{
|
|
UserID: userID,
|
|
StartTime: time.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)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
_, err = db.DB.Exec(context.Background(),
|
|
"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)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler)
|
|
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search", nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
handler := http.HandlerFunc(bookings.GetAdminBookingHandler)
|
|
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
handler := http.HandlerFunc(bookings.GetAdminBookingHandler)
|
|
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/nonexistent-id", nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
bookingID1, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking 1: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID1)
|
|
|
|
bookingID2, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking 2: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID2)
|
|
|
|
handler := http.HandlerFunc(bookings.GetAllBookingsByUserHandler)
|
|
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/user/"+userID, nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
req := bookings.ProgressBookingRequest{
|
|
Status: "confirmed",
|
|
}
|
|
|
|
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
|
|
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", req)
|
|
|
|
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 = db.DB.QueryRow(context.Background(),
|
|
"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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
req := bookings.ProgressBookingRequest{
|
|
Status: "invalid_status",
|
|
}
|
|
|
|
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
|
|
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", req)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
req := bookings.ProgressBookingRequest{
|
|
Status: "confirmed",
|
|
}
|
|
|
|
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
|
|
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/nonexistent-id/progress", req)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
req := bookings.ConfirmBookingRequest{}
|
|
|
|
handler := http.HandlerFunc(bookings.ConfirmBookingHandler)
|
|
w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/confirm", req)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
ctx := context.Background()
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
_, err = db.DB.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)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
ctx := context.Background()
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
_, err = db.DB.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)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var dbStatus string
|
|
err = db.DB.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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
handler := http.HandlerFunc(bookings.AdminCancelBookingHandler)
|
|
w := makeAdminRequest(handler, "POST", "/api/admin/bookings/nonexistent-id/cancel", nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
ctx := context.Background()
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
// Booking stays in 'pending' status (no confirmation)
|
|
|
|
handler := http.HandlerFunc(bookings.AdminCancelBookingHandler)
|
|
w := makeAdminRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", nil)
|
|
|
|
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 = db.DB.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 = db.DB.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1`,
|
|
bookingID).Scan(¬ifCount)
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
ctx := context.Background()
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
// Confirm the booking
|
|
_, err = db.DB.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)
|
|
|
|
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 = db.DB.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 = db.DB.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'cancelled_booking'`,
|
|
bookingID).Scan(¬ifCount)
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
ctx := context.Background()
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
// Set booking to in-progress status
|
|
_, err = db.DB.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)
|
|
|
|
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 = db.DB.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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
ctx := context.Background()
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
// Set to already cancelled
|
|
_, err = db.DB.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)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
ctx := context.Background()
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
// Set to completed
|
|
_, err = db.DB.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)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
_, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
w := makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.GetAllAdminBookingsHandler)), "GET", "/api/admin/bookings", nil)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("LIST: expected status 403, got %d", w.Code)
|
|
}
|
|
|
|
req := bookings.AdminCreateBookingForUserRequest{
|
|
UserID: userID,
|
|
StartTime: time.Now().Add(72 * time.Hour),
|
|
ServiceIDs: []string{serviceID},
|
|
}
|
|
w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)), "POST", "/api/admin/bookings", req)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
// 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 = db.DB.QueryRow(context.Background(), `
|
|
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)
|
|
}
|
|
defer db.DB.Exec(context.Background(), "DELETE FROM exceptional_working_hours_groups WHERE id = $1", groupID)
|
|
|
|
// 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 = db.DB.Exec(context.Background(), `
|
|
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 = db.DB.Exec(context.Background(), `
|
|
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)
|
|
|
|
// 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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
// Update notes with mixed case
|
|
_, err = db.DB.Exec(context.Background(),
|
|
"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)
|
|
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)
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
handler := http.HandlerFunc(bookings.SearchAdminBookingsHandler)
|
|
|
|
// Search with non-matching query
|
|
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/search?q=NONEXISTENT_QUERY_XYZ123", nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
// Create multiple bookings with similar notes
|
|
bookingID1, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking 1: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID1)
|
|
|
|
bookingID2, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking 2: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID2)
|
|
|
|
// Update both with searchable notes
|
|
_, err = db.DB.Exec(context.Background(),
|
|
"UPDATE bookings SET notes = 'Search Query Pattern' WHERE id = $1", bookingID1)
|
|
if err != nil {
|
|
t.Fatalf("failed to update booking 1: %v", err)
|
|
}
|
|
|
|
_, err = db.DB.Exec(context.Background(),
|
|
"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)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
// Update booking status to confirmed (required for edit requests)
|
|
_, err = db.DB.Exec(context.Background(),
|
|
"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 = db.DB.Exec(context.Background(), "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 = db.DB.Exec(context.Background(), `
|
|
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, time.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)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
// Create confirmed booking
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
// Confirm the booking
|
|
_, err = db.DB.Exec(context.Background(), "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 = db.DB.QueryRow(context.Background(),
|
|
"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 = db.DB.QueryRow(context.Background(),
|
|
`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)
|
|
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
ctx = context.WithValue(ctx, mw.UserIDKey, adminID)
|
|
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
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 = db.DB.QueryRow(context.Background(),
|
|
"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 = db.DB.QueryRow(context.Background(),
|
|
"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) {
|
|
resetTestData(t)
|
|
|
|
// Create admin user
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
// Create regular user
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
// Set deposits_required=0
|
|
_, err = db.DB.Exec(context.Background(), "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(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
// Create confirmed booking
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
// Confirm the booking
|
|
_, err = db.DB.Exec(context.Background(),
|
|
"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 = db.DB.QueryRow(context.Background(),
|
|
"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 := time.Now().Add(48 * time.Hour).Truncate(time.Minute)
|
|
var editRequestID string
|
|
var emptyServices []string
|
|
err = db.DB.QueryRow(context.Background(),
|
|
`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 = db.DB.Exec(context.Background(),
|
|
`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)
|
|
req := httptest.NewRequest("POST", path, nil)
|
|
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", bookingID)
|
|
rctx.URLParams.Add("request_id", editRequestID)
|
|
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
ctx = context.WithValue(ctx, mw.UserIDKey, adminID)
|
|
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
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 = db.DB.QueryRow(context.Background(),
|
|
"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 = db.DB.QueryRow(context.Background(),
|
|
"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 = db.DB.QueryRow(context.Background(),
|
|
`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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
// Create booking via SQL with deposit_required=true (simulating user-created booking)
|
|
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
|
var bookingID string
|
|
err = db.DB.QueryRow(context.Background(), `
|
|
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)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
// Link service to booking
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
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)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
// Create booking via SQL with deposit_required=true
|
|
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
|
var bookingID string
|
|
err = db.DB.QueryRow(context.Background(), `
|
|
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)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
// Link service to booking
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
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)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
// Create a time blocker for a specific time
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
blockerTime := time.Date(2099, 12, 31, 10, 0, 0, 0, ukLocation)
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
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)
|
|
|
|
// 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.
|
|
func TestAdminBookings_Edit_OverlappingBlocker_WithWarning(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
// Create a booking first
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
// Create a time blocker for a specific time
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
blockerTime := time.Date(2099, 12, 31, 14, 0, 0, 0, ukLocation)
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, 'Staff meeting', NULL)
|
|
`, blockerTime)
|
|
if err != nil {
|
|
t.Fatalf("failed to create time blocker: %v", err)
|
|
}
|
|
|
|
// Admin edits booking to overlap the blocker
|
|
req := bookings.EditBookingRequest{
|
|
StartTime: blockerTime,
|
|
}
|
|
|
|
// Set up chi router for URL param
|
|
r := chi.NewRouter()
|
|
r.Put("/api/admin/bookings/{id}", bookings.AdminEditBookingHandler)
|
|
|
|
// Create request with chi context and admin auth
|
|
bodyBytes, _ := json.Marshal(req)
|
|
reqHTTP := httptest.NewRequest("PUT", "/api/admin/bookings/"+bookingID, bytes.NewReader(bodyBytes))
|
|
reqHTTP.Header.Set("Content-Type", "application/json")
|
|
|
|
// Add admin context
|
|
ctx := context.WithValue(reqHTTP.Context(), mw.UserIDKey, adminID)
|
|
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
|
|
reqHTTP = reqHTTP.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, reqHTTP)
|
|
|
|
// Admin should get 200 OK (not 409 Conflict)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Parse response
|
|
var response map[string]interface{}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
|
|
// Check for warnings
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
// Set user to have outstanding deposits (deposits_required = 3)
|
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set deposits_required: %v", err)
|
|
}
|
|
|
|
futureTime := time.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)
|
|
|
|
// 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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
// Set user to have outstanding deposits
|
|
_, err = db.DB.Exec(context.Background(), "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 := time.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)
|
|
|
|
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 := time.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)
|
|
|
|
// 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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
// Seed working hours so we have a valid booking window
|
|
seedDefaultWorkingHours(t)
|
|
|
|
// 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 := time.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)
|
|
|
|
// 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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
// Seed working hours
|
|
seedDefaultWorkingHours(t)
|
|
|
|
// Set user to have outstanding deposits
|
|
_, err = db.DB.Exec(context.Background(), "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 := time.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)
|
|
|
|
// 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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Set user to have deposits_required = 2
|
|
_, err = db.DB.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 := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
|
var bookingID string
|
|
err = db.DB.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)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
// Link service to booking
|
|
_, err = db.DB.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 = db.DB.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)
|
|
|
|
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 = db.DB.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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Set user to have deposits_required = 2
|
|
_, err = db.DB.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 := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
|
var bookingID string
|
|
err = db.DB.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)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
// Link service to booking
|
|
_, err = db.DB.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)
|
|
|
|
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 = db.DB.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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Set user to have deposits_required = 3
|
|
_, err = db.DB.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 := time.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)
|
|
|
|
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 := time.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)
|
|
|
|
// 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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Seed working hours
|
|
seedDefaultWorkingHours(t)
|
|
|
|
// Set user to have deposits_required = 3
|
|
_, err = db.DB.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 := time.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)
|
|
|
|
// 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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
guestID, err := fixtures.CreateTestGuestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create guest user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, guestID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
// Seed working hours
|
|
seedDefaultWorkingHours(t)
|
|
|
|
// Create booking for tomorrow
|
|
tomorrow := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
|
tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location())
|
|
|
|
req := bookings.AdminCreateBookingForUserRequest{
|
|
UserID: guestID,
|
|
StartTime: tomorrow,
|
|
ServiceIDs: []string{serviceID},
|
|
}
|
|
|
|
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
|
|
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req)
|
|
|
|
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
|
|
ctx := context.Background()
|
|
var foundUserID string
|
|
err = db.DB.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 TestAdminUpdateBooking_ClosedExceptionalHours_WarningOnly(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
originalTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
|
|
|
_, err = db.DB.Exec(context.Background(),
|
|
"UPDATE bookings SET start_time = $1 WHERE id = $2", originalTime, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to update booking time: %v", err)
|
|
}
|
|
|
|
targetDate := time.Now().Add(5 * 24 * time.Hour).Truncate(24 * time.Hour)
|
|
|
|
var groupID int
|
|
err = db.DB.QueryRow(context.Background(), `
|
|
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)
|
|
}
|
|
|
|
// DB convention: 0=Monday..6=Sunday; Go: 0=Sunday..6=Saturday. Convert.
|
|
dbWeekday := (int(targetDate.Weekday()) + 6) % 7
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
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)
|
|
}
|
|
|
|
daysToMonday := int(targetDate.Weekday())
|
|
if daysToMonday == 0 {
|
|
daysToMonday = 7
|
|
}
|
|
mondayOfWeek := targetDate.AddDate(0, 0, -daysToMonday+1)
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
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)
|
|
}
|
|
|
|
targetTime := targetDate.Add(14 * time.Hour).Truncate(time.Second)
|
|
req := bookings.EditBookingRequest{
|
|
StartTime: targetTime,
|
|
}
|
|
|
|
handler := http.HandlerFunc(bookings.AdminEditBookingHandler)
|
|
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, 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)
|
|
}
|
|
|
|
warnings, ok := response["warnings"].([]interface{})
|
|
if !ok || len(warnings) == 0 {
|
|
t.Error("expected warnings array with at least one warning about working hours")
|
|
} else {
|
|
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("working hours")) {
|
|
t.Errorf("expected warning to mention 'working hours', got: %s", warningStr)
|
|
}
|
|
}
|
|
|
|
var updatedStartTime time.Time
|
|
err = db.DB.QueryRow(context.Background(),
|
|
"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(targetTime) {
|
|
t.Errorf("expected booking start_time %v, got %v", targetTime, updatedStartTime)
|
|
}
|
|
}
|
|
|
|
// TestAdminCreateBookingForUser_ClosedExceptionalHours_Rejected verifies that an admin
|
|
// cannot create a booking for a user during hours marked as closed in the exceptional
|
|
// working hours (holiday) system.
|
|
func TestAdminCreateBookingForUser_ClosedExceptionalHours_Rejected(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
targetDate := time.Now().Add(5 * 24 * time.Hour).Truncate(24 * time.Hour)
|
|
|
|
var groupID int
|
|
err = db.DB.QueryRow(context.Background(), `
|
|
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)
|
|
}
|
|
|
|
// DB convention: 0=Monday..6=Sunday; Go: 0=Sunday..6=Saturday. Convert.
|
|
dbWeekday := (int(targetDate.Weekday()) + 6) % 7
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
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)
|
|
}
|
|
|
|
daysToMonday := int(targetDate.Weekday())
|
|
if daysToMonday == 0 {
|
|
daysToMonday = 7
|
|
}
|
|
mondayOfWeek := targetDate.AddDate(0, 0, -daysToMonday+1)
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
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)
|
|
}
|
|
|
|
targetTime := targetDate.Add(14 * time.Hour).Truncate(time.Second)
|
|
req := bookings.AdminCreateBookingForUserRequest{
|
|
UserID: userID,
|
|
StartTime: targetTime,
|
|
ServiceIDs: []string{serviceID},
|
|
}
|
|
|
|
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
|
|
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req)
|
|
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
if !bytes.Contains(w.Body.Bytes(), []byte("holiday hours")) {
|
|
t.Errorf("expected error message to mention 'holiday hours', got: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// GetBookingsByCreatedRange Tests
|
|
// =============================================================================
|
|
|
|
// TestGetBookingsByCreatedRange verifies that the endpoint returns bookings
|
|
// created within the specified created_at range.
|
|
func TestGetBookingsByCreatedRange(t *testing.T) {
|
|
resetTestData(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
// Create bookings with specific created_at timestamps
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
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)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, "book00000001")
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
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 = db.DB.Exec(context.Background(), `
|
|
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)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, "book00000002")
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
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 = db.DB.Exec(context.Background(), `
|
|
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)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, "book00000003")
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
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)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
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)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler)
|
|
|
|
// Missing both params
|
|
w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range", nil)
|
|
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)
|
|
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)
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
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)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, adminID)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(db.DB, userID)
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
defer fixtures.DeleteService(db.DB, serviceID)
|
|
|
|
// Create bookings with created_at in reverse order
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
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)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, "book00000010")
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
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 = db.DB.Exec(context.Background(), `
|
|
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)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, "book00000011")
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
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 = db.DB.Exec(context.Background(), `
|
|
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)
|
|
}
|
|
defer fixtures.DeleteBooking(db.DB, "book00000012")
|
|
_, err = db.DB.Exec(context.Background(), `
|
|
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)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
seedDefaultWorkingHours(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
|
|
// Create completed booking
|
|
bookingTime := time.Now().Add(-24 * time.Hour)
|
|
bookingID := createCompletedBookingWithTimeForAdmin(t, userID, serviceID, bookingTime, 50.00)
|
|
|
|
// Create a discount campaign and apply it
|
|
var campaignID string
|
|
err = db.DB.QueryRow(context.Background(), `
|
|
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 = db.DB.Exec(context.Background(), `
|
|
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)
|
|
|
|
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, userID, serviceID string, startTime time.Time, price float64) string {
|
|
t.Helper()
|
|
var bookingID string
|
|
err := db.DB.QueryRow(context.Background(), `
|
|
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 = db.DB.Exec(context.Background(), `
|
|
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
|
|
}
|