Two-tier notification system: new_booking (all public bookings) + pending_booking (notes/today). Priority-sorted queue, unread count polling, enriched responses with user_name/booking_start_time. Fix critical bug: edit_requested cleanup was broken (wrong reason string in 3 handlers). Add 15 new tests covering priority ordering, enrichment, and notification creation flows. Update Admin Manual, Technical Manual, and gap backlog docs.
2663 lines
86 KiB
Go
2663 lines
86 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"
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
// =============================================================================
|
|
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)
|
|
_, 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, int(targetDate.Weekday()), "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)
|
|
targetWeekday := int(targetDate.Weekday())
|
|
if targetWeekday == 0 {
|
|
targetWeekday = 7 // Sunday -> 7
|
|
}
|
|
mondayOfWeek := targetDate.AddDate(0, 0, -targetWeekday+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),
|
|
pq.Array(&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, pq.Array(&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)
|
|
}
|
|
}
|