refactor: remove auto deposit penalty on no-shows, add comprehensive tests
- Remove automatic deposits_required=3 on no-shows, give admin flexibility - Add tests for no-show deposit logic (forgiven, over 24h, under 24h) - Add tests for reservation cleanup TTL (admin walk-in/call-in 15min) - Add tests for EXIF GPS data stripping in portfolio images - Add tests for contact info endpoint - Add tests for guest account anonymization
This commit is contained in:
@@ -71,7 +71,6 @@ func seedDefaultWorkingHours(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// List Admin Bookings Tests
|
// List Admin Bookings Tests
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
@@ -2350,3 +2349,356 @@ func TestAdminBookings_Create_WalkInWithDeposits(t *testing.T) {
|
|||||||
t.Errorf("expected status 201 for admin walk-in with deposits, got %d. body: %s", w.Code, w.Body.String())
|
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) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
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) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
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) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
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) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
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) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
"crussell/handlers/user"
|
"crussell/handlers/user"
|
||||||
@@ -473,3 +474,85 @@ func TestAdminUsers_Get_Success(t *testing.T) {
|
|||||||
t.Errorf("expected account_role 'verified_email', got '%s'", resp.AccountRole)
|
t.Errorf("expected account_role 'verified_email', got '%s'", resp.AccountRole)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAdminUsers_AddPatchTest_Duplicate verifies that recording the same patch test
|
||||||
|
// twice updates the tested_at timestamp (upsert behavior).
|
||||||
|
func TestAdminUsers_AddPatchTest_Duplicate(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
var userID string
|
||||||
|
err := db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
|
||||||
|
VALUES ('Test', 'User', 'testuser@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email')
|
||||||
|
RETURNING id
|
||||||
|
`).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a service
|
||||||
|
var serviceID string
|
||||||
|
err = db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
|
||||||
|
VALUES ('Gel Polish Full Set', 'Gel polish service', 45.00, 60, true, 16)
|
||||||
|
RETURNING id
|
||||||
|
`).Scan(&serviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a patch test that links to this service
|
||||||
|
var patchTestID string
|
||||||
|
err = db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids)
|
||||||
|
VALUES ('Gel Allergy Test', 'Patch test for gel products', 24, 6, $1)
|
||||||
|
RETURNING id
|
||||||
|
`, []string{serviceID}).Scan(&patchTestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create patch test: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(user.AddPatchTestHandler)
|
||||||
|
|
||||||
|
// Record patch test first time - should return 201 Created
|
||||||
|
reqBody := user.AddPatchTestRequest{PatchTestID: patchTestID}
|
||||||
|
w := makeAdminRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Errorf("first record: expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query tested_at time T1
|
||||||
|
var t1 time.Time
|
||||||
|
err = db.DB.QueryRow(context.Background(), `
|
||||||
|
SELECT tested_at FROM user_patch_tests WHERE user_id = $1 AND patch_test_id = $2
|
||||||
|
`, userID, patchTestID).Scan(&t1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get tested_at: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait 100ms to ensure timestamp will change
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Record same patch test again - should return 201 or 200 (upsert updates)
|
||||||
|
reqBody = user.AddPatchTestRequest{PatchTestID: patchTestID}
|
||||||
|
w = makeAdminRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody)
|
||||||
|
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
|
||||||
|
t.Errorf("second record: expected status 200 or 201, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query tested_at time T2
|
||||||
|
var t2 time.Time
|
||||||
|
err = db.DB.QueryRow(context.Background(), `
|
||||||
|
SELECT tested_at FROM user_patch_tests WHERE user_id = $1 AND patch_test_id = $2
|
||||||
|
`, userID, patchTestID).Scan(&t2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get tested_at: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert T2 > T1 (upsert updated the timestamp)
|
||||||
|
if !t2.After(t1) {
|
||||||
|
t.Errorf("expected t2 %v after t1 %v, but it's not", t2, t1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,507 @@
|
|||||||
|
//go:build test
|
||||||
|
// +build test
|
||||||
|
|
||||||
|
package bookings
|
||||||
|
|
||||||
|
// Package bookings contains tests for admin booking reservation endpoints.
|
||||||
|
//
|
||||||
|
// Test Coverage:
|
||||||
|
// - AdminReserveSlotHandler: POST /api/admin/bookings/reserve - Admin slot reservation (walk-in or call-in)
|
||||||
|
//
|
||||||
|
// Tests cover walk-in and call-in reservation types, validation, slot overlaps, and replacement behavior.
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"crussell/db"
|
||||||
|
"crussell/mw"
|
||||||
|
"crussell/testutils/fixtures"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// makeAdminReserveRequest creates a request with admin context for admin reserve slot handler
|
||||||
|
// It sets mw.UserIDKey and mw.UserRoleKey to "admin" in the context
|
||||||
|
func makeAdminReserveRequest(handler http.Handler, body interface{}, adminID string) *httptest.ResponseRecorder {
|
||||||
|
var req *http.Request
|
||||||
|
if body != nil {
|
||||||
|
bodyBytes, _ := json.Marshal(body)
|
||||||
|
req = httptest.NewRequest("POST", "/api/admin/bookings/reserve", bytes.NewReader(bodyBytes))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
} else {
|
||||||
|
req = httptest.NewRequest("POST", "/api/admin/bookings/reserve", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
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)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Walk-in Reservation Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestAdminReserveSlot_WalkIn_Success tests that an admin can successfully
|
||||||
|
// create a walk-in reservation with a valid duration. The test verifies
|
||||||
|
// the reservation is created in the database with the correct duration.
|
||||||
|
func TestAdminReserveSlot_WalkIn_Success(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
seedDefaultWorkingHours(t)
|
||||||
|
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, adminID)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
req := AdminReserveSlotRequest{
|
||||||
|
ReservationType: "walkin",
|
||||||
|
StartTime: now,
|
||||||
|
DurationMinutes: 30,
|
||||||
|
TTLMinutes: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
||||||
|
w := makeAdminReserveRequest(handler, req, adminID)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response and verify duration
|
||||||
|
var resp AdminReserveSlotResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.DurationMinutes != 30 {
|
||||||
|
t.Errorf("expected duration 30, got %d", resp.DurationMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify time_blocker was created with correct description pattern
|
||||||
|
var desc string
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
"SELECT description FROM time_blockers WHERE description LIKE 'RESERVATION:admin:walkin:%'",
|
||||||
|
).Scan(&desc)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to query time_blocker: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(desc, "RESERVATION:admin:walkin:") {
|
||||||
|
t.Errorf("expected description to start with 'RESERVATION:admin:walkin:', got %s", desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Call-in Reservation Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestAdminReserveSlot_CallIn_Success tests that an admin can successfully
|
||||||
|
// create a call-in reservation with valid service IDs. The test verifies
|
||||||
|
// the reservation duration matches the service duration.
|
||||||
|
func TestAdminReserveSlot_CallIn_Success(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
seedDefaultWorkingHours(t)
|
||||||
|
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %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)
|
||||||
|
|
||||||
|
_, err = db.DB.Exec(context.Background(), "UPDATE services SET duration_minutes = 30 WHERE id = $1", serviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to update service duration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tomorrow := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
||||||
|
tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location())
|
||||||
|
|
||||||
|
req := AdminReserveSlotRequest{
|
||||||
|
UserID: &userID,
|
||||||
|
ReservationType: "callin",
|
||||||
|
StartTime: tomorrow,
|
||||||
|
ServiceIDs: []string{serviceID},
|
||||||
|
TTLMinutes: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
||||||
|
w := makeAdminReserveRequest(handler, req, adminID)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response and verify duration matches service (30 min)
|
||||||
|
var resp AdminReserveSlotResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.DurationMinutes != 30 {
|
||||||
|
t.Errorf("expected duration 30, got %d", resp.DurationMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify time_blocker was created with correct description pattern
|
||||||
|
var desc string
|
||||||
|
err = db.DB.QueryRow(context.Background(),
|
||||||
|
"SELECT description FROM time_blockers WHERE description LIKE 'RESERVATION:admin:callin:%'",
|
||||||
|
).Scan(&desc)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to query time_blocker: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(desc, "RESERVATION:admin:callin:") {
|
||||||
|
t.Errorf("expected description to start with 'RESERVATION:admin:callin:', got %s", desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Validation Tests - Missing Duration
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestAdminReserveSlot_WalkIn_MissingDuration tests that walk-in reservations
|
||||||
|
// fail with HTTP 400 when duration_minutes is missing or zero.
|
||||||
|
func TestAdminReserveSlot_WalkIn_MissingDuration(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
seedDefaultWorkingHours(t)
|
||||||
|
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, adminID)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
req := AdminReserveSlotRequest{
|
||||||
|
ReservationType: "walkin",
|
||||||
|
StartTime: now,
|
||||||
|
TTLMinutes: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
||||||
|
w := makeAdminReserveRequest(handler, req, adminID)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify error message mentions duration_minutes
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "duration_minutes") {
|
||||||
|
t.Errorf("expected body to contain 'duration_minutes', got %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Validation Tests - Missing Services
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestAdminReserveSlot_CallIn_MissingServices tests that call-in reservations
|
||||||
|
// fail with HTTP 400 when service_ids is empty.
|
||||||
|
func TestAdminReserveSlot_CallIn_MissingServices(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
seedDefaultWorkingHours(t)
|
||||||
|
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, adminID)
|
||||||
|
|
||||||
|
tomorrow := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
||||||
|
tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location())
|
||||||
|
|
||||||
|
req := AdminReserveSlotRequest{
|
||||||
|
ReservationType: "callin",
|
||||||
|
StartTime: tomorrow,
|
||||||
|
ServiceIDs: []string{},
|
||||||
|
TTLMinutes: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
||||||
|
w := makeAdminReserveRequest(handler, req, adminID)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify error message mentions service
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "service") {
|
||||||
|
t.Errorf("expected body to contain 'service', got %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Validation Tests - Invalid Reservation Type
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestAdminReserveSlot_InvalidReservationType tests that reservations
|
||||||
|
// fail with HTTP 400 when reservation_type is invalid.
|
||||||
|
func TestAdminReserveSlot_InvalidReservationType(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
seedDefaultWorkingHours(t)
|
||||||
|
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, adminID)
|
||||||
|
|
||||||
|
req := AdminReserveSlotRequest{
|
||||||
|
ReservationType: "invalid",
|
||||||
|
StartTime: time.Now(),
|
||||||
|
DurationMinutes: 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
||||||
|
w := makeAdminReserveRequest(handler, req, adminID)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify error message mentions valid types
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "walkin") && !strings.Contains(body, "callin") {
|
||||||
|
t.Errorf("expected body to contain 'walkin' or 'callin', got %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Slot Overlap Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestAdminReserveSlot_SlotOverlap tests that a reservation fails
|
||||||
|
// with HTTP 409 when the slot overlaps with an existing booking.
|
||||||
|
func TestAdminReserveSlot_SlotOverlap(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
seedDefaultWorkingHours(t)
|
||||||
|
|
||||||
|
// Create test admin user (for the booking)
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, adminID)
|
||||||
|
|
||||||
|
// Create test 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)
|
||||||
|
|
||||||
|
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 deposits_required=0 for test user
|
||||||
|
_, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
tomorrow := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
||||||
|
tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location())
|
||||||
|
|
||||||
|
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test booking: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||||
|
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
"UPDATE bookings SET start_time = $1, status = 'confirmed' WHERE id = $2",
|
||||||
|
tomorrow, bookingID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to update booking time: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now try to reserve a call-in that overlaps (tomorrow 10:15 - 15 min after start)
|
||||||
|
overlapTime := tomorrow.Add(15 * time.Minute)
|
||||||
|
|
||||||
|
req := AdminReserveSlotRequest{
|
||||||
|
ReservationType: "callin",
|
||||||
|
StartTime: overlapTime,
|
||||||
|
ServiceIDs: []string{serviceID},
|
||||||
|
TTLMinutes: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
||||||
|
w := makeAdminReserveRequest(handler, req, adminID)
|
||||||
|
|
||||||
|
// Should return 409 Conflict due to overlap
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Reservation Replacement Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestAdminReserveSlot_ReplacesExisting tests that reserving twice
|
||||||
|
// on the same admin replaces the previous reservation.
|
||||||
|
func TestAdminReserveSlot_ReplacesExisting(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
seedDefaultWorkingHours(t)
|
||||||
|
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, adminID)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
req := AdminReserveSlotRequest{
|
||||||
|
ReservationType: "walkin",
|
||||||
|
StartTime: now,
|
||||||
|
DurationMinutes: 30,
|
||||||
|
TTLMinutes: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
||||||
|
w := makeAdminReserveRequest(handler, req, adminID)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstResp AdminReserveSlotResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &firstResp); err != nil {
|
||||||
|
t.Fatalf("failed to parse first response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count reservations before second request
|
||||||
|
var countBefore int
|
||||||
|
var qerr error
|
||||||
|
qerr = db.DB.QueryRow(context.Background(),
|
||||||
|
"SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%'",
|
||||||
|
).Scan(&countBefore)
|
||||||
|
if qerr != nil {
|
||||||
|
t.Fatalf("failed to count reservations: %v", qerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second reservation (same admin, new time)
|
||||||
|
laterTime := now.Add(1 * time.Hour)
|
||||||
|
req2 := AdminReserveSlotRequest{
|
||||||
|
ReservationType: "walkin",
|
||||||
|
StartTime: laterTime,
|
||||||
|
DurationMinutes: 45,
|
||||||
|
TTLMinutes: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
w = makeAdminReserveRequest(handler, req2, adminID)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var secondResp AdminReserveSlotResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &secondResp); err != nil {
|
||||||
|
t.Fatalf("failed to parse second response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count reservations after second request - should still be 1
|
||||||
|
var countAfter int
|
||||||
|
qerr = db.DB.QueryRow(context.Background(),
|
||||||
|
"SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%'",
|
||||||
|
).Scan(&countAfter)
|
||||||
|
if qerr != nil {
|
||||||
|
t.Fatalf("failed to count reservations: %v", qerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old one should be deleted, new one exists (id should be different)
|
||||||
|
if countAfter != 1 {
|
||||||
|
t.Errorf("expected 1 reservation after replacement, got %d", countAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
if firstResp.ID == secondResp.ID {
|
||||||
|
t.Errorf("expected new reservation ID to be different from old one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Past Start Time Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestAdminReserveSlot_WalkIn_PastStart tests that walk-in reservations
|
||||||
|
func TestAdminReserveSlot_WalkIn_PastStart(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
seedDefaultWorkingHours(t)
|
||||||
|
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, adminID)
|
||||||
|
|
||||||
|
pastTime := time.Now().Add(-5 * time.Minute)
|
||||||
|
req := AdminReserveSlotRequest{
|
||||||
|
ReservationType: "walkin",
|
||||||
|
StartTime: pastTime,
|
||||||
|
DurationMinutes: 30,
|
||||||
|
TTLMinutes: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
||||||
|
w := makeAdminReserveRequest(handler, req, adminID)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify error message mentions the past time limit
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "past") {
|
||||||
|
t.Errorf("expected body to contain 'past', got %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1869,17 +1869,12 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
tx.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&startTime)
|
tx.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&startTime)
|
||||||
noticeHours := startTime.Sub(time.Now()).Hours()
|
noticeHours := startTime.Sub(time.Now()).Hours()
|
||||||
|
|
||||||
// < 24 hours notice: treat as no-show
|
|
||||||
if noticeHours < 24 {
|
if noticeHours < 24 {
|
||||||
// Check if admin is forgiving this no-show
|
|
||||||
isForgiving := req.ForgiveNoShow != nil && *req.ForgiveNoShow
|
isForgiving := req.ForgiveNoShow != nil && *req.ForgiveNoShow
|
||||||
|
|
||||||
if !isForgiving {
|
if !isForgiving {
|
||||||
// No forgiveness: apply penalty
|
|
||||||
tx.Exec(r.Context(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
|
|
||||||
tx.Exec(r.Context(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bookingID)
|
tx.Exec(r.Context(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bookingID)
|
||||||
} else {
|
} else {
|
||||||
// Forgiveness granted: treat as client_cancelled, no penalty
|
|
||||||
tx.Exec(r.Context(), "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID)
|
tx.Exec(r.Context(), "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1107,13 +1107,12 @@ func TestBookings_Delete_NoShow24hThreshold(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("failed to query deposits: %v", err)
|
t.Errorf("failed to query deposits: %v", err)
|
||||||
}
|
}
|
||||||
if deposits != 3 {
|
if deposits != 0 {
|
||||||
t.Errorf("expected deposits=3 after no-show penalty, got %d", deposits)
|
t.Errorf("expected deposits=0 (admin flexibility, not auto-applied), got %d", deposits)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBookings_Delete_NoShow_WithForgiveness tests that admin can forgive a no-show
|
// TestBookings_Delete_NoShow_WithForgiveness tests that admin can forgive a no-show
|
||||||
// by passing forgive_no_show=true, which prevents the deposit penalty.
|
|
||||||
func TestBookings_Delete_NoShow_WithForgiveness(t *testing.T) {
|
func TestBookings_Delete_NoShow_WithForgiveness(t *testing.T) {
|
||||||
cleanup := setupTestDB(t)
|
cleanup := setupTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -1660,6 +1659,640 @@ func TestBookings_Create_MultipleServices(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// No-Show Deposit Logic Tests (Task 5)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3 tests that when a booking is
|
||||||
|
// deleted as no-show with less than 24 hours notice (and no forgiveness), the
|
||||||
|
// user's deposits_required is set to 3 and the booking status becomes "no_show".
|
||||||
|
func TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
seedDefaultWorkingHours(t)
|
||||||
|
|
||||||
|
// Create test user with deposits_required = 0
|
||||||
|
userID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, userID)
|
||||||
|
|
||||||
|
// Ensure 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test service: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteService(db.DB, serviceID)
|
||||||
|
|
||||||
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
|
// Create booking with start_time = now + 12 hours (< 24h notice)
|
||||||
|
soonTime := time.Now().Add(12 * time.Hour).Truncate(time.Second)
|
||||||
|
soonTime = time.Date(soonTime.Year(), soonTime.Month(), soonTime.Day(), 10, 0, 0, 0, soonTime.Location())
|
||||||
|
|
||||||
|
bookingReq := CreateBookingRequest{
|
||||||
|
StartTime: soonTime,
|
||||||
|
ServiceIDs: []string{serviceID},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(CreateBookingHandler)
|
||||||
|
w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token)
|
||||||
|
|
||||||
|
var booking Booking
|
||||||
|
if err := parseResponseBody(w, &booking); err != nil {
|
||||||
|
t.Fatalf("failed to parse booking response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a payment to the booking
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
"INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)",
|
||||||
|
booking.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create payment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete booking with reason "no_show" and no forgiveness
|
||||||
|
delHandler := http.HandlerFunc(DeleteBookingHandler)
|
||||||
|
delReq := map[string]interface{}{"reason": "no_show", "forgive_no_show": false}
|
||||||
|
w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "")
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var deposits int
|
||||||
|
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to query deposits: %v", err)
|
||||||
|
}
|
||||||
|
if deposits != 0 {
|
||||||
|
t.Errorf("expected deposits_required=0, got %d", deposits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify booking status = "no_show"
|
||||||
|
var status string
|
||||||
|
err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to query booking status: %v", err)
|
||||||
|
}
|
||||||
|
if status != "no_show" {
|
||||||
|
t.Errorf("expected status 'no_show', got %s", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteBooking_CancelOver24h_NoDepositPenalty tests that when a booking is
|
||||||
|
// cancelled with more than 24 hours notice (client_cancelled), no deposit penalty
|
||||||
|
// is applied and deposits_required remains 0.
|
||||||
|
func TestDeleteBooking_CancelOver24h_NoDepositPenalty(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
seedDefaultWorkingHours(t)
|
||||||
|
|
||||||
|
// Create test user with deposits_required = 0
|
||||||
|
userID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, userID)
|
||||||
|
|
||||||
|
// Ensure 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test service: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteService(db.DB, serviceID)
|
||||||
|
|
||||||
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
|
// Create booking with start_time = now + 48 hours (> 24h notice)
|
||||||
|
laterTime := time.Now().Add(48 * time.Hour).Truncate(time.Second)
|
||||||
|
laterTime = time.Date(laterTime.Year(), laterTime.Month(), laterTime.Day(), 10, 0, 0, 0, laterTime.Location())
|
||||||
|
|
||||||
|
bookingReq := CreateBookingRequest{
|
||||||
|
StartTime: laterTime,
|
||||||
|
ServiceIDs: []string{serviceID},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(CreateBookingHandler)
|
||||||
|
w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token)
|
||||||
|
|
||||||
|
var booking Booking
|
||||||
|
if err := parseResponseBody(w, &booking); err != nil {
|
||||||
|
t.Fatalf("failed to parse booking response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a payment to the booking
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
"INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)",
|
||||||
|
booking.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create payment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete booking with reason "client_cancelled" (>= 24h notice)
|
||||||
|
delHandler := http.HandlerFunc(DeleteBookingHandler)
|
||||||
|
delReq := map[string]interface{}{"reason": "client_cancelled"}
|
||||||
|
w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "")
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify deposits_required = 0 (no penalty)
|
||||||
|
var deposits int
|
||||||
|
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to query deposits: %v", err)
|
||||||
|
}
|
||||||
|
if deposits != 0 {
|
||||||
|
t.Errorf("expected deposits_required=0, got %d", deposits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify booking status = "client_cancelled"
|
||||||
|
var status string
|
||||||
|
err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to query booking status: %v", err)
|
||||||
|
}
|
||||||
|
if status != "client_cancelled" {
|
||||||
|
t.Errorf("expected status 'client_cancelled', got %s", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteBooking_NoShowWithForgiveness_NoPenalty tests that when a booking is
|
||||||
|
// deleted as no-show with forgiveness (forgive_no_show: true), no deposit penalty
|
||||||
|
// is applied and the booking status becomes "client_cancelled".
|
||||||
|
func TestDeleteBooking_NoShowWithForgiveness_NoPenalty(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
seedDefaultWorkingHours(t)
|
||||||
|
|
||||||
|
// Create test user with deposits_required = 0
|
||||||
|
userID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, userID)
|
||||||
|
|
||||||
|
// Ensure 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test service: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteService(db.DB, serviceID)
|
||||||
|
|
||||||
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
|
// Create booking with start_time = now + 12 hours (< 24h notice)
|
||||||
|
soonTime := time.Now().Add(12 * time.Hour).Truncate(time.Second)
|
||||||
|
soonTime = time.Date(soonTime.Year(), soonTime.Month(), soonTime.Day(), 10, 0, 0, 0, soonTime.Location())
|
||||||
|
|
||||||
|
bookingReq := CreateBookingRequest{
|
||||||
|
StartTime: soonTime,
|
||||||
|
ServiceIDs: []string{serviceID},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(CreateBookingHandler)
|
||||||
|
w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token)
|
||||||
|
|
||||||
|
var booking Booking
|
||||||
|
if err := parseResponseBody(w, &booking); err != nil {
|
||||||
|
t.Fatalf("failed to parse booking response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a payment to the booking
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
"INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)",
|
||||||
|
booking.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create payment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete booking with reason "no_show" AND forgiveness
|
||||||
|
delHandler := http.HandlerFunc(DeleteBookingHandler)
|
||||||
|
delReq := map[string]interface{}{"reason": "no_show", "forgive_no_show": true}
|
||||||
|
w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "")
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify deposits_required = 0 (no penalty due to forgiveness)
|
||||||
|
var deposits int
|
||||||
|
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to query deposits: %v", err)
|
||||||
|
}
|
||||||
|
if deposits != 0 {
|
||||||
|
t.Errorf("expected deposits_required=0 after forgiveness, got %d", deposits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify booking status = "client_cancelled" (not "no_show")
|
||||||
|
var status string
|
||||||
|
err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to query booking status: %v", err)
|
||||||
|
}
|
||||||
|
if status != "client_cancelled" {
|
||||||
|
t.Errorf("expected status 'client_cancelled' with forgiveness, got %s", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteBooking_SecondNoShow_StaysAt3 tests that when a user has their second
|
||||||
|
// no-show, the deposits_required stays at 3 (not 6). The handler sets deposits to 3
|
||||||
|
// on the first no-show and doesn't increment on subsequent no-shows.
|
||||||
|
func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
seedDefaultWorkingHours(t)
|
||||||
|
|
||||||
|
// Create test user with deposits_required = 0
|
||||||
|
userID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, userID)
|
||||||
|
|
||||||
|
// Ensure 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test service: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteService(db.DB, serviceID)
|
||||||
|
|
||||||
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
|
// === First booking: no-show ===
|
||||||
|
soonTime1 := time.Now().Add(12 * time.Hour).Truncate(time.Second)
|
||||||
|
soonTime1 = time.Date(soonTime1.Year(), soonTime1.Month(), soonTime1.Day(), 10, 0, 0, 0, soonTime1.Location())
|
||||||
|
|
||||||
|
bookingReq1 := CreateBookingRequest{
|
||||||
|
StartTime: soonTime1,
|
||||||
|
ServiceIDs: []string{serviceID},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(CreateBookingHandler)
|
||||||
|
w := makeRequest(handler, "POST", "/api/bookings", bookingReq1, token)
|
||||||
|
|
||||||
|
var booking1 Booking
|
||||||
|
if err := parseResponseBody(w, &booking1); err != nil {
|
||||||
|
t.Fatalf("failed to parse booking response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add payment
|
||||||
|
_, _ = db.DB.Exec(context.Background(),
|
||||||
|
"INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)",
|
||||||
|
booking1.ID)
|
||||||
|
|
||||||
|
// Delete as no-show
|
||||||
|
delHandler := http.HandlerFunc(DeleteBookingHandler)
|
||||||
|
delReq1 := map[string]interface{}{"reason": "no_show", "forgive_no_show": false}
|
||||||
|
w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking1.ID, delReq1, token, "")
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first delete failed: %d body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify deposits = 3 after first no-show
|
||||||
|
var deposits1 int
|
||||||
|
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits1)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to query deposits: %v", err)
|
||||||
|
}
|
||||||
|
if deposits1 != 0 {
|
||||||
|
t.Errorf("expected deposits=0 after first no-show, got %d", deposits1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Second booking: no-show ===
|
||||||
|
// Need to wait a bit or create with different time to avoid conflict
|
||||||
|
// Use tomorrow + 12 hours
|
||||||
|
soonTime2 := time.Now().Add(36 * time.Hour).Truncate(time.Second)
|
||||||
|
soonTime2 = time.Date(soonTime2.Year(), soonTime2.Month(), soonTime2.Day(), 10, 0, 0, 0, soonTime2.Location())
|
||||||
|
|
||||||
|
bookingReq2 := CreateBookingRequest{
|
||||||
|
StartTime: soonTime2,
|
||||||
|
ServiceIDs: []string{serviceID},
|
||||||
|
}
|
||||||
|
|
||||||
|
w = makeRequest(handler, "POST", "/api/bookings", bookingReq2, token)
|
||||||
|
|
||||||
|
var booking2 Booking
|
||||||
|
if err := parseResponseBody(w, &booking2); err != nil {
|
||||||
|
t.Fatalf("failed to parse second booking response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add payment
|
||||||
|
_, _ = db.DB.Exec(context.Background(),
|
||||||
|
"INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)",
|
||||||
|
booking2.ID)
|
||||||
|
|
||||||
|
// Delete as no-show
|
||||||
|
delReq2 := map[string]interface{}{"reason": "no_show", "forgive_no_show": false}
|
||||||
|
w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking2.ID, delReq2, token, "")
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second delete failed: %d body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var deposits2 int
|
||||||
|
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits2)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to query deposits: %v", err)
|
||||||
|
}
|
||||||
|
if deposits2 != 0 {
|
||||||
|
t.Errorf("expected deposits=0, got %d", deposits2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCountUnforgivenNoShows_ExcludesForgiven tests that CountUnforgivenNoShows
|
||||||
|
// excludes bookings that have been forgiven (in forgiven_no_shows table).
|
||||||
|
func TestCountUnforgivenNoShows_ExcludesForgiven(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create test 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)
|
||||||
|
|
||||||
|
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 3 bookings with status "no_show" within last 6 months
|
||||||
|
now := time.Now()
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
startTime := now.Add(time.Duration(i*30) * 24 * time.Hour) // 0, 30, 60 days ago
|
||||||
|
var bookingID string
|
||||||
|
err := db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO bookings (user_id, start_time, status, notes)
|
||||||
|
VALUES ($1, $2, 'no_show', 'test no-show')
|
||||||
|
RETURNING id
|
||||||
|
`, userID, startTime).Scan(&bookingID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create booking %d: %v", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link service
|
||||||
|
_, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forgive the first one (i == 0)
|
||||||
|
if i == 0 {
|
||||||
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
"INSERT INTO forgiven_no_shows (booking_id) VALUES ($1)",
|
||||||
|
bookingID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to forgive no-show: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call CountUnforgivenNoShows directly (unexported but in same package)
|
||||||
|
count, err := CountUnforgivenNoShows(context.Background(), userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountUnforgivenNoShows failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be 2 (3 total - 1 forgiven = 2)
|
||||||
|
if count != 2 {
|
||||||
|
t.Errorf("expected count=2 (3 total - 1 forgiven), got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCountUnforgivenNoShows_ExcludesOld tests that CountUnforgivenNoShows
|
||||||
|
// excludes no-shows older than 6 months.
|
||||||
|
func TestCountUnforgivenNoShows_ExcludesOld(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create test 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)
|
||||||
|
|
||||||
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test service: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteService(db.DB, serviceID)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Create 1 booking with status "no_show" from 7 months ago (should be excluded)
|
||||||
|
oldStartTime := now.Add(-7 * 30 * 24 * time.Hour)
|
||||||
|
var oldBookingID string
|
||||||
|
err = db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO bookings (user_id, start_time, status, notes)
|
||||||
|
VALUES ($1, $2, 'no_show', 'old no-show')
|
||||||
|
RETURNING id
|
||||||
|
`, userID, oldStartTime).Scan(&oldBookingID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create old booking: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = db.DB.Exec(context.Background(),
|
||||||
|
"INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)",
|
||||||
|
oldBookingID, serviceID)
|
||||||
|
|
||||||
|
// Create 1 booking with status "no_show" from 1 month ago (should be included)
|
||||||
|
recentStartTime := now.Add(-30 * 24 * time.Hour)
|
||||||
|
var recentBookingID string
|
||||||
|
err = db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO bookings (user_id, start_time, status, notes)
|
||||||
|
VALUES ($1, $2, 'no_show', 'recent no-show')
|
||||||
|
RETURNING id
|
||||||
|
`, userID, recentStartTime).Scan(&recentBookingID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create recent booking: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = db.DB.Exec(context.Background(),
|
||||||
|
"INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)",
|
||||||
|
recentBookingID, serviceID)
|
||||||
|
|
||||||
|
// Call CountUnforgivenNoShows directly
|
||||||
|
count, err := CountUnforgivenNoShows(context.Background(), userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountUnforgivenNoShows failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be 1 (only the recent one within 6 months)
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("expected count=1 (only recent within 6 months), got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestApplyDepositsIfNeeded_AppliesAt2Plus tests that ApplyDepositsIfNeeded
|
||||||
|
// applies 3 deposits when user has 2 or more unforgiven no-shows in last 6 months.
|
||||||
|
func TestApplyDepositsIfNeeded_AppliesAt2Plus(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create test user with deposits_required = 0
|
||||||
|
userID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, userID)
|
||||||
|
|
||||||
|
// Ensure 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 2 bookings with status "no_show" within last 6 months
|
||||||
|
now := time.Now()
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
startTime := now.Add(time.Duration(i*30) * 24 * time.Hour) // 0, 30 days ago
|
||||||
|
var bookingID string
|
||||||
|
err := db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO bookings (user_id, start_time, status, notes)
|
||||||
|
VALUES ($1, $2, 'no_show', 'test no-show')
|
||||||
|
RETURNING id
|
||||||
|
`, userID, startTime).Scan(&bookingID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create booking %d: %v", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link service
|
||||||
|
_, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call ApplyDepositsIfNeeded directly
|
||||||
|
applied, err := ApplyDepositsIfNeeded(context.Background(), userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyDepositsIfNeeded failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return true (deposits were applied)
|
||||||
|
if !applied {
|
||||||
|
t.Error("expected ApplyDepositsIfNeeded to return true when 2+ no-shows")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify deposits_required = 3
|
||||||
|
var deposits int
|
||||||
|
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to query deposits: %v", err)
|
||||||
|
}
|
||||||
|
if deposits != 3 {
|
||||||
|
t.Errorf("expected deposits_required=3, got %d", deposits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestApplyDepositsIfNeeded_DoesNotApplyAt1 tests that ApplyDepositsIfNeeded
|
||||||
|
// does NOT apply deposits when user has only 1 unforgiven no-show.
|
||||||
|
func TestApplyDepositsIfNeeded_DoesNotApplyAt1(t *testing.T) {
|
||||||
|
cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create test user with deposits_required = 0
|
||||||
|
userID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
defer fixtures.DeleteUser(db.DB, userID)
|
||||||
|
|
||||||
|
// Ensure 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 1 booking with status "no_show" within last 6 months
|
||||||
|
startTime := time.Now().Add(-30 * 24 * time.Hour) // 30 days ago
|
||||||
|
var bookingID string
|
||||||
|
err = db.DB.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO bookings (user_id, start_time, status, notes)
|
||||||
|
VALUES ($1, $2, 'no_show', 'test no-show')
|
||||||
|
RETURNING id
|
||||||
|
`, userID, startTime).Scan(&bookingID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link service
|
||||||
|
_, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call ApplyDepositsIfNeeded directly
|
||||||
|
applied, err := ApplyDepositsIfNeeded(context.Background(), userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyDepositsIfNeeded failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return false (deposits were NOT applied)
|
||||||
|
if applied {
|
||||||
|
t.Error("expected ApplyDepositsIfNeeded to return false when only 1 no-show")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify deposits_required = 0 (unchanged)
|
||||||
|
var deposits int
|
||||||
|
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to query deposits: %v", err)
|
||||||
|
}
|
||||||
|
if deposits != 0 {
|
||||||
|
t.Errorf("expected deposits_required=0 (unchanged), got %d", deposits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure test compilation - import pgxpool to avoid unused import
|
// Ensure test compilation - import pgxpool to avoid unused import
|
||||||
var _ = func() *pgxpool.Pool { return nil }
|
var _ = func() *pgxpool.Pool { return nil }
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -29,6 +31,7 @@ import (
|
|||||||
"crussell/testutils/testdb"
|
"crussell/testutils/testdb"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/kovidgoyal/imaging"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupTestDB(t *testing.T) func() {
|
func setupTestDB(t *testing.T) func() {
|
||||||
@@ -518,3 +521,91 @@ func TestPortfolio_Delete_Unauthenticated(t *testing.T) {
|
|||||||
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
|
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// processImage EXIF Stripping Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestPortfolio_ProcessImage_EXIFStripped verifies that processImage strips EXIF GPS data from images.
|
||||||
|
func TestPortfolio_ProcessImage_EXIFStripped(t *testing.T) {
|
||||||
|
// Create a simple test image using the standard library
|
||||||
|
img := image.NewNRGBA(image.Rect(0, 0, 100, 100))
|
||||||
|
grayColor := color.RGBA{R: 200, G: 200, B: 200, A: 255}
|
||||||
|
for y := 0; y < 100; y++ {
|
||||||
|
for x := 0; x < 100; x++ {
|
||||||
|
img.Set(x, y, grayColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode to JPEG bytes using imaging
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := imaging.Encode(&buf, img, imaging.JPEG, imaging.JPEGQuality(85))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to encode test image: %v", err)
|
||||||
|
}
|
||||||
|
imageBytes := buf.Bytes()
|
||||||
|
|
||||||
|
// Create a JPEG with embedded EXIF GPS data by appending GPS IFD marker after JPEG header
|
||||||
|
// This creates a JPEG that claims to have GPS metadata
|
||||||
|
gpsJpeg := createJpegWithExifMarker(imageBytes)
|
||||||
|
if gpsJpeg == nil {
|
||||||
|
t.Skip("Could not create JPEG with EXIF marker - using basic test")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify EXIF marker is present in the input
|
||||||
|
hasExifBefore := bytes.Contains(gpsJpeg, []byte{0xFF, 0xE1}) // APP1 EXIF marker
|
||||||
|
if !hasExifBefore {
|
||||||
|
t.Skip("Could not inject EXIF marker - skipping GPS stripping test")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the image (this should strip EXIF/GPS data)
|
||||||
|
result, err := processImage(gpsJpeg, 85)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processImage failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the result is a valid JPEG
|
||||||
|
if len(result) == 0 {
|
||||||
|
t.Fatal("processImage returned empty result")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify EXIF marker is NOT present in the output
|
||||||
|
hasExifAfter := bytes.Contains(result, []byte{0xFF, 0xE1})
|
||||||
|
if hasExifAfter {
|
||||||
|
t.Error("EXIF data was NOT stripped by processImage - metadata still present")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createJpegWithExifMarker creates a JPEG with an APP1 EXIF marker inserted after the SOI marker
|
||||||
|
func createJpegWithExifMarker(jpegData []byte) []byte {
|
||||||
|
if len(jpegData) < 2 || jpegData[0] != 0xFF || jpegData[1] != 0xD8 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a minimal APP1 EXIF marker with GPS IFD tag (0x8825)
|
||||||
|
// APP1 marker: FF E1, length, "Exif\0\0", byte order, magic, IFD offset
|
||||||
|
app1 := []byte{
|
||||||
|
0xFF, 0xE1, // APP1 marker
|
||||||
|
0x00, 0x22, // Length: 34 bytes
|
||||||
|
// Exif header
|
||||||
|
0x45, 0x78, 0x69, 0x66, 0x00, 0x00, // "Exif\0\0"
|
||||||
|
0x49, 0x49, // Byte order: little-endian
|
||||||
|
0x2A, 0x00, // Magic number
|
||||||
|
0x08, 0x00, 0x00, 0x00, // Offset to first IFD
|
||||||
|
// Main IFD with GPS IFD pointer
|
||||||
|
0x01, 0x00, // Number of entries: 1
|
||||||
|
0x25, 0x88, // GPS IFD tag (0x8825)
|
||||||
|
0x04, 0x00, // Type: LONG
|
||||||
|
0x01, 0x00, 0x00, 0x00, // Count: 1
|
||||||
|
0x10, 0x00, 0x00, 0x00, // Offset: 16 (to GPS IFD)
|
||||||
|
0x00, 0x00, 0x00, 0x00, // Next IFD: none
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert APP1 after SOI marker (FF D8)
|
||||||
|
result := make([]byte, 0, len(jpegData)+len(app1))
|
||||||
|
result = append(result, jpegData[:2]...)
|
||||||
|
result = append(result, app1...)
|
||||||
|
result = append(result, jpegData[2:]...)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -696,6 +696,474 @@ func TestCleanupOldReservations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Tests for CleanupOldReservations (Admin Walk-In) ---
|
||||||
|
|
||||||
|
// TestCleanupOldReservations_AdminWalkIn verifies that admin walk-in reservations
|
||||||
|
// older than 15 minutes are deleted, while recent ones are preserved.
|
||||||
|
func TestCleanupOldReservations_AdminWalkIn(t *testing.T) {
|
||||||
|
cleanup := setupTimeBlockersTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
||||||
|
|
||||||
|
// Create old walk-in reservation (>15 min old)
|
||||||
|
oldTime := time.Now().Add(-16 * time.Minute).In(ukLocation)
|
||||||
|
_, err := db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||||
|
VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:123', $2)
|
||||||
|
`, oldTime, time.Now().Add(-16*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create old walk-in reservation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create recent walk-in reservation (<15 min old)
|
||||||
|
recentTime := time.Now().Add(-14 * time.Minute).In(ukLocation)
|
||||||
|
_, err = db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||||
|
VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:456', $2)
|
||||||
|
`, recentTime, time.Now().Add(-14*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create recent walk-in reservation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run cleanup
|
||||||
|
err = CleanupOldReservations(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CleanupOldReservations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify old reservation was deleted
|
||||||
|
var oldCount int
|
||||||
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:guest:123'").Scan(&oldCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to check old reservation: %v", err)
|
||||||
|
}
|
||||||
|
if oldCount != 0 {
|
||||||
|
t.Error("expected old walk-in reservation (16 min) to be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify recent reservation still exists
|
||||||
|
var recentCount int
|
||||||
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:guest:456'").Scan(&recentCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to check recent reservation: %v", err)
|
||||||
|
}
|
||||||
|
if recentCount != 1 {
|
||||||
|
t.Error("expected recent walk-in reservation (14 min) to be preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tests for CleanupOldReservations (Admin Call-In) ---
|
||||||
|
|
||||||
|
// TestCleanupOldReservations_AdminCallIn verifies that admin call-in reservations
|
||||||
|
// older than 15 minutes are deleted, while recent ones are preserved.
|
||||||
|
func TestCleanupOldReservations_AdminCallIn(t *testing.T) {
|
||||||
|
cleanup := setupTimeBlockersTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
||||||
|
|
||||||
|
// Create old call-in reservation (>15 min old)
|
||||||
|
oldTime := time.Now().Add(-16 * time.Minute).In(ukLocation)
|
||||||
|
_, err := db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||||
|
VALUES ($1, 60, 'RESERVATION:admin:callin:guest:123', $2)
|
||||||
|
`, oldTime, time.Now().Add(-16*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create old call-in reservation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create recent call-in reservation (<15 min old)
|
||||||
|
recentTime := time.Now().Add(-14 * time.Minute).In(ukLocation)
|
||||||
|
_, err = db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||||
|
VALUES ($1, 60, 'RESERVATION:admin:callin:guest:456', $2)
|
||||||
|
`, recentTime, time.Now().Add(-14*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create recent call-in reservation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run cleanup
|
||||||
|
err = CleanupOldReservations(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CleanupOldReservations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify old reservation was deleted
|
||||||
|
var oldCount int
|
||||||
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:guest:123'").Scan(&oldCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to check old reservation: %v", err)
|
||||||
|
}
|
||||||
|
if oldCount != 0 {
|
||||||
|
t.Error("expected old call-in reservation (16 min) to be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify recent reservation still exists
|
||||||
|
var recentCount int
|
||||||
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:guest:456'").Scan(&recentCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to check recent reservation: %v", err)
|
||||||
|
}
|
||||||
|
if recentCount != 1 {
|
||||||
|
t.Error("expected recent call-in reservation (14 min) to be preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tests for CleanupOldReservations (Mixed Types) ---
|
||||||
|
|
||||||
|
// TestCleanupOldReservations_MixedTypes verifies that cleanup correctly handles
|
||||||
|
// all reservation types with their respective TTLs.
|
||||||
|
func TestCleanupOldReservations_MixedTypes(t *testing.T) {
|
||||||
|
cleanup := setupTimeBlockersTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
||||||
|
|
||||||
|
// Create old user reservation (>1 hour old)
|
||||||
|
oldUserTime := time.Now().Add(-2 * time.Hour).In(ukLocation)
|
||||||
|
_, err := db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||||
|
VALUES ($1, 60, 'RESERVATION:user:old', $2)
|
||||||
|
`, oldUserTime, time.Now().Add(-2*time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create old user reservation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create recent user reservation (<1 hour old)
|
||||||
|
recentUserTime := time.Now().Add(-30 * time.Minute).In(ukLocation)
|
||||||
|
_, err = db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||||
|
VALUES ($1, 60, 'RESERVATION:user:recent', $2)
|
||||||
|
`, recentUserTime, time.Now().Add(-30*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create recent user reservation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create old anon reservation (>10 min old)
|
||||||
|
oldAnonTime := time.Now().Add(-15 * time.Minute).In(ukLocation)
|
||||||
|
_, err = db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||||
|
VALUES ($1, 60, 'RESERVATION:anon:old', $2)
|
||||||
|
`, oldAnonTime, time.Now().Add(-15*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create old anon reservation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create recent anon reservation (<10 min old)
|
||||||
|
recentAnonTime := time.Now().Add(-5 * time.Minute).In(ukLocation)
|
||||||
|
_, err = db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||||
|
VALUES ($1, 60, 'RESERVATION:anon:recent', $2)
|
||||||
|
`, recentAnonTime, time.Now().Add(-5*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create recent anon reservation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create old admin walk-in reservation (>15 min old)
|
||||||
|
oldWalkinTime := time.Now().Add(-20 * time.Minute).In(ukLocation)
|
||||||
|
_, err = db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||||
|
VALUES ($1, 60, 'RESERVATION:admin:walkin:old', $2)
|
||||||
|
`, oldWalkinTime, time.Now().Add(-20*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create old walk-in reservation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create recent admin walk-in reservation (<15 min old)
|
||||||
|
recentWalkinTime := time.Now().Add(-10 * time.Minute).In(ukLocation)
|
||||||
|
_, err = db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||||
|
VALUES ($1, 60, 'RESERVATION:admin:walkin:recent', $2)
|
||||||
|
`, recentWalkinTime, time.Now().Add(-10*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create recent walk-in reservation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create old admin call-in reservation (>15 min old)
|
||||||
|
oldCallinTime := time.Now().Add(-20 * time.Minute).In(ukLocation)
|
||||||
|
_, err = db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||||
|
VALUES ($1, 60, 'RESERVATION:admin:callin:old', $2)
|
||||||
|
`, oldCallinTime, time.Now().Add(-20*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create old call-in reservation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create recent admin call-in reservation (<15 min old)
|
||||||
|
recentCallinTime := time.Now().Add(-10 * time.Minute).In(ukLocation)
|
||||||
|
_, err = db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||||
|
VALUES ($1, 60, 'RESERVATION:admin:callin:recent', $2)
|
||||||
|
`, recentCallinTime, time.Now().Add(-10*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create recent call-in reservation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we have 8 reservations before cleanup
|
||||||
|
var countBefore int
|
||||||
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:%'").Scan(&countBefore)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to count reservations before cleanup: %v", err)
|
||||||
|
}
|
||||||
|
if countBefore != 8 {
|
||||||
|
t.Errorf("expected 8 reservations before cleanup, got %d", countBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run cleanup
|
||||||
|
err = CleanupOldReservations(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CleanupOldReservations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify old reservations were deleted (4 old ones)
|
||||||
|
var oldUserCount, oldAnonCount, oldWalkinCount, oldCallinCount int
|
||||||
|
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:user:old'").Scan(&oldUserCount)
|
||||||
|
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:anon:old'").Scan(&oldAnonCount)
|
||||||
|
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:old'").Scan(&oldWalkinCount)
|
||||||
|
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:old'").Scan(&oldCallinCount)
|
||||||
|
|
||||||
|
if oldUserCount != 0 {
|
||||||
|
t.Error("expected old user reservation to be deleted")
|
||||||
|
}
|
||||||
|
if oldAnonCount != 0 {
|
||||||
|
t.Error("expected old anon reservation to be deleted")
|
||||||
|
}
|
||||||
|
if oldWalkinCount != 0 {
|
||||||
|
t.Error("expected old walk-in reservation to be deleted")
|
||||||
|
}
|
||||||
|
if oldCallinCount != 0 {
|
||||||
|
t.Error("expected old call-in reservation to be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify recent reservations still exist (4 recent ones)
|
||||||
|
var recentUserCount, recentAnonCount, recentWalkinCount, recentCallinCount int
|
||||||
|
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:user:recent'").Scan(&recentUserCount)
|
||||||
|
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:anon:recent'").Scan(&recentAnonCount)
|
||||||
|
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:recent'").Scan(&recentWalkinCount)
|
||||||
|
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:recent'").Scan(&recentCallinCount)
|
||||||
|
|
||||||
|
if recentUserCount != 1 {
|
||||||
|
t.Error("expected recent user reservation to be preserved")
|
||||||
|
}
|
||||||
|
if recentAnonCount != 1 {
|
||||||
|
t.Error("expected recent anon reservation to be preserved")
|
||||||
|
}
|
||||||
|
if recentWalkinCount != 1 {
|
||||||
|
t.Error("expected recent walk-in reservation to be preserved")
|
||||||
|
}
|
||||||
|
if recentCallinCount != 1 {
|
||||||
|
t.Error("expected recent call-in reservation to be preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tests for GetTimeBlockersInRange (Excludes Reservations) ---
|
||||||
|
|
||||||
|
// TestGetTimeBlockersInRange_ExcludesReservations verifies that reservation
|
||||||
|
// blockers are excluded from the results.
|
||||||
|
func TestGetTimeBlockersInRange_ExcludesReservations(t *testing.T) {
|
||||||
|
cleanup := setupTimeBlockersTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
||||||
|
|
||||||
|
// Create a regular blocker for tomorrow at 10:00
|
||||||
|
tomorrow := time.Now().Add(24 * time.Hour).In(ukLocation)
|
||||||
|
blockerTime := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, ukLocation)
|
||||||
|
_, err := db.DB.Exec(ctx, `
|
||||||
|
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 regular blocker: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a reservation for tomorrow at 11:00
|
||||||
|
reservationTime := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 11, 0, 0, 0, ukLocation)
|
||||||
|
_, err = db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
||||||
|
VALUES ($1, 60, 'RESERVATION:user:abc:123', NULL)
|
||||||
|
`, reservationTime)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create reservation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query range covering both times
|
||||||
|
start := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 0, 0, 0, 0, ukLocation)
|
||||||
|
end := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 23, 59, 59, 0, ukLocation)
|
||||||
|
|
||||||
|
blockers, err := GetTimeBlockersInRange(ctx, start, end)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTimeBlockersInRange failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return only 1 blocker (the regular one, not the reservation)
|
||||||
|
if len(blockers) != 1 {
|
||||||
|
t.Errorf("expected 1 blocker, got %d", len(blockers))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the blocker is "Staff meeting"
|
||||||
|
if len(blockers) > 0 && blockers[0].Description != "Staff meeting" {
|
||||||
|
t.Errorf("expected blocker 'Staff meeting', got '%s'", blockers[0].Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tests for AnonymizeStaleGuestAccounts ---
|
||||||
|
|
||||||
|
// TestAnonymizeStaleGuestAccounts_Exactly6Months verifies that a guest with
|
||||||
|
// a booking exactly 6 months ago is anonymized.
|
||||||
|
func TestAnonymizeStaleGuestAccounts_Exactly6Months(t *testing.T) {
|
||||||
|
cleanup := setupTimeBlockersTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create guest user
|
||||||
|
guestID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create guest user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set account_role to guest
|
||||||
|
_, err = db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set guest role: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create booking with start_time exactly 6 months ago
|
||||||
|
_, err = db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||||
|
VALUES ($1, NOW() - INTERVAL '6 months', 'completed', false)
|
||||||
|
`, guestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run anonymization
|
||||||
|
err = AnonymizeStaleGuestAccounts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify guest was anonymized
|
||||||
|
var firstName, lastName, email string
|
||||||
|
err = db.DB.QueryRow(ctx, `SELECT n_first_name, n_last_name, email FROM users WHERE id = $1`, guestID).Scan(&firstName, &lastName, &email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query anonymized user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if firstName != "Guest" {
|
||||||
|
t.Errorf("expected first_name 'Guest', got '%s'", firstName)
|
||||||
|
}
|
||||||
|
if lastName != "Anonymized" {
|
||||||
|
t.Errorf("expected last_name 'Anonymized', got '%s'", lastName)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(email, "anon-") {
|
||||||
|
t.Errorf("expected email to start with 'anon-', got '%s'", email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnonymizeStaleGuestAccounts_ActiveBooking_Skipped verifies that a guest
|
||||||
|
// with an active (future) booking is NOT anonymized even if they have a past booking.
|
||||||
|
func TestAnonymizeStaleGuestAccounts_ActiveBooking_Skipped(t *testing.T) {
|
||||||
|
cleanup := setupTimeBlockersTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create guest user
|
||||||
|
guestID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create guest user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set account_role to guest
|
||||||
|
_, err = db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set guest role: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create past booking (7 months ago)
|
||||||
|
_, err = db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||||
|
VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false)
|
||||||
|
`, guestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create past booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create active booking (tomorrow)
|
||||||
|
tomorrow := time.Now().Add(24 * time.Hour)
|
||||||
|
_, err = db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||||
|
VALUES ($1, $2, 'confirmed', false)
|
||||||
|
`, guestID, tomorrow)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create active booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run anonymization
|
||||||
|
err = AnonymizeStaleGuestAccounts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify guest was NOT anonymized
|
||||||
|
var firstName string
|
||||||
|
err = db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guestID).Scan(&firstName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first name should NOT be "Guest" (it should retain original name)
|
||||||
|
if firstName == "Guest" {
|
||||||
|
t.Error("expected guest with active booking to NOT be anonymized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnonymizeStaleGuestAccounts_NoBookings verifies that a guest with
|
||||||
|
// no bookings is NOT anonymized.
|
||||||
|
func TestAnonymizeStaleGuestAccounts_NoBookings(t *testing.T) {
|
||||||
|
cleanup := setupTimeBlockersTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create guest user with no bookings
|
||||||
|
guestID, err := fixtures.CreateTestUser(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create guest user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set account_role to guest
|
||||||
|
_, err = db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set guest role: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run anonymization
|
||||||
|
err = AnonymizeStaleGuestAccounts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify guest was NOT anonymized
|
||||||
|
var firstName string
|
||||||
|
err = db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guestID).Scan(&firstName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first name should NOT be "Guest" (it should retain original name)
|
||||||
|
if firstName == "Guest" {
|
||||||
|
t.Error("expected guest with no bookings to NOT be anonymized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure pool is used to avoid unused import error
|
// Ensure pool is used to avoid unused import error
|
||||||
var _ = pgxpool.Pool{}
|
var _ = pgxpool.Pool{}
|
||||||
var _ = bytes.Buffer{}
|
var _ = bytes.Buffer{}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//go:build test
|
||||||
|
// +build test
|
||||||
|
|
||||||
|
package user
|
||||||
|
|
||||||
|
// Package user contains tests for guest user creation endpoints.
|
||||||
|
//
|
||||||
|
// Test Coverage:
|
||||||
|
// - CreateGuestUserHandler: POST /api/user/guest - Create a new guest user
|
||||||
|
//
|
||||||
|
// Edge case tests for validation:
|
||||||
|
// - Invalid phone number format
|
||||||
|
// - Empty first name
|
||||||
|
// - Name exceeds 50 character limit
|
||||||
|
// - Invalid email format
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"crussell/testutils/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGuestUser_Create_InvalidPhone verifies that an invalid phone number returns 400 Bad Request.
|
||||||
|
func TestGuestUser_Create_InvalidPhone(t *testing.T) {
|
||||||
|
cleanup, _ := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
jwt.Init()
|
||||||
|
|
||||||
|
reqBody := CreateGuestUserRequest{
|
||||||
|
FirstName: "Test",
|
||||||
|
LastName: "User",
|
||||||
|
Email: "test@test.com",
|
||||||
|
Phone: "not-a-phone",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(reqBody)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/user/guest", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
CreateGuestUserHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d", rr.Code)
|
||||||
|
t.Logf("response body: %s", rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuestUser_Create_EmptyFirstName verifies that an empty first name returns 400 Bad Request.
|
||||||
|
func TestGuestUser_Create_EmptyFirstName(t *testing.T) {
|
||||||
|
cleanup, _ := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
jwt.Init()
|
||||||
|
|
||||||
|
reqBody := CreateGuestUserRequest{
|
||||||
|
FirstName: "",
|
||||||
|
LastName: "User",
|
||||||
|
Email: "test@test.com",
|
||||||
|
Phone: "07123456789",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(reqBody)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/user/guest", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
CreateGuestUserHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d", rr.Code)
|
||||||
|
t.Logf("response body: %s", rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuestUser_Create_NameTooLong verifies that a first name exceeding 50 characters returns 400 Bad Request.
|
||||||
|
func TestGuestUser_Create_NameTooLong(t *testing.T) {
|
||||||
|
cleanup, _ := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
jwt.Init()
|
||||||
|
|
||||||
|
reqBody := CreateGuestUserRequest{
|
||||||
|
FirstName: strings.Repeat("a", 51),
|
||||||
|
LastName: "User",
|
||||||
|
Email: "test@test.com",
|
||||||
|
Phone: "07123456789",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(reqBody)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/user/guest", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
CreateGuestUserHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d", rr.Code)
|
||||||
|
t.Logf("response body: %s", rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuestUser_Create_InvalidEmail verifies that an invalid email format returns 400 Bad Request.
|
||||||
|
func TestGuestUser_Create_InvalidEmail(t *testing.T) {
|
||||||
|
cleanup, _ := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
jwt.Init()
|
||||||
|
|
||||||
|
reqBody := CreateGuestUserRequest{
|
||||||
|
FirstName: "Test",
|
||||||
|
LastName: "User",
|
||||||
|
Email: "not-an-email",
|
||||||
|
Phone: "07123456789",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(reqBody)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/user/guest", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
CreateGuestUserHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d", rr.Code)
|
||||||
|
t.Logf("response body: %s", rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -596,3 +596,86 @@ func TestProfile_UploadPicture(t *testing.T) {
|
|||||||
t.Error("expected url in response")
|
t.Error("expected url in response")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestContactInfo_ReturnsAdmin verifies that GetContactInfoHandler returns contact info for the first admin user.
|
||||||
|
func TestContactInfo_ReturnsAdmin(t *testing.T) {
|
||||||
|
cleanup, pool := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create admin user with profile data
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(pool)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update admin with specific profile data
|
||||||
|
_, err = pool.Exec(context.Background(), `
|
||||||
|
UPDATE users
|
||||||
|
SET n_first_name = 'Jane', n_last_name = 'Smith', phone = '+447700900000', email = 'jane@example.com'
|
||||||
|
WHERE id = $1
|
||||||
|
`, adminID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to update admin profile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call handler directly (no auth needed - public endpoint)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/contact", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
GetContactInfoHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", rr.Code)
|
||||||
|
t.Logf("response body: %s", rr.Body.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var contact ContactInfo
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &contact); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedName := "Jane Smith"
|
||||||
|
if contact.Name != expectedName {
|
||||||
|
t.Errorf("expected name %q, got %q", expectedName, contact.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedEmail := "jane@example.com"
|
||||||
|
if contact.Email != expectedEmail {
|
||||||
|
t.Errorf("expected email %q, got %q", expectedEmail, contact.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedPhone := "+447700900000"
|
||||||
|
if contact.Phone != expectedPhone {
|
||||||
|
t.Errorf("expected phone %q, got %q", expectedPhone, contact.Phone)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedRole := "Owner / Beauty Specialist"
|
||||||
|
if contact.Role != expectedRole {
|
||||||
|
t.Errorf("expected role %q, got %q", expectedRole, contact.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContactInfo_NoAdmin verifies that GetContactInfoHandler returns 404 when no admin exists.
|
||||||
|
func TestContactInfo_NoAdmin(t *testing.T) {
|
||||||
|
cleanup, pool := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Ensure no admin users exist - truncate tables
|
||||||
|
testdb.TruncateTables(t, pool)
|
||||||
|
|
||||||
|
// Create only a regular user (not admin)
|
||||||
|
_, err := fixtures.CreateTestUser(pool)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call handler
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/contact", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
GetContactInfoHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("expected status 404, got %d", rr.Code)
|
||||||
|
t.Logf("response body: %s", rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
//go:build test
|
||||||
|
// +build test
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"crussell/db"
|
||||||
|
"crussell/testutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHealthCheck_OK(t *testing.T) {
|
||||||
|
// Setup test database
|
||||||
|
cleanup := testutils.SetupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create request and recorder
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Call handler directly
|
||||||
|
healthCheckHandler(w, req)
|
||||||
|
|
||||||
|
// Assert 200 OK
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status %d, got %d. body: %s", http.StatusOK, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON response
|
||||||
|
var response map[string]interface{}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("failed to parse JSON response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert status == "ok"
|
||||||
|
status, ok := response["status"].(string)
|
||||||
|
if !ok || status != "ok" {
|
||||||
|
t.Errorf("expected status 'ok', got '%v'", response["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert services
|
||||||
|
services, ok := response["services"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("services not found in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert services.backend == "ok"
|
||||||
|
backend, ok := services["backend"].(string)
|
||||||
|
if !ok || backend != "ok" {
|
||||||
|
t.Errorf("expected services.backend 'ok', got '%v'", services["backend"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert services.database == "ok"
|
||||||
|
database, ok := services["database"].(string)
|
||||||
|
if !ok || database != "ok" {
|
||||||
|
t.Errorf("expected services.database 'ok', got '%v'", services["database"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthCheck_Degraded(t *testing.T) {
|
||||||
|
// Setup test database
|
||||||
|
cleanup := testutils.SetupTestDB(t)
|
||||||
|
|
||||||
|
// Save original db.DB and set to nil to simulate degraded state
|
||||||
|
originalDB := db.DB
|
||||||
|
db.DB = nil
|
||||||
|
|
||||||
|
// Create request and recorder
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Call handler directly
|
||||||
|
healthCheckHandler(w, req)
|
||||||
|
|
||||||
|
// Assert 503 Service Unavailable
|
||||||
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("expected status %d, got %d. body: %s", http.StatusServiceUnavailable, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON response
|
||||||
|
var response map[string]interface{}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("failed to parse JSON response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert status == "degraded"
|
||||||
|
status, ok := response["status"].(string)
|
||||||
|
if !ok || status != "degraded" {
|
||||||
|
t.Errorf("expected status 'degraded', got '%v'", response["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert services
|
||||||
|
services, ok := response["services"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("services not found in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert services.database == "error"
|
||||||
|
database, ok := services["database"].(string)
|
||||||
|
if !ok || database != "error" {
|
||||||
|
t.Errorf("expected services.database 'error', got '%v'", services["database"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore original db.DB and cleanup
|
||||||
|
db.DB = originalDB
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
Vendored
+13
-8
@@ -4,17 +4,21 @@
|
|||||||
"type": "split",
|
"type": "split",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"id": "c3304ad1f15b3261",
|
"id": "88f82a6e53bb7e58",
|
||||||
"type": "tabs",
|
"type": "tabs",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"id": "b72bbfe2e0c01c0c",
|
"id": "0e456d61bc5b6ded",
|
||||||
"type": "leaf",
|
"type": "leaf",
|
||||||
"state": {
|
"state": {
|
||||||
"type": "empty",
|
"type": "markdown",
|
||||||
"state": {},
|
"state": {
|
||||||
|
"file": "Crussell/Test Implementation Plan.md",
|
||||||
|
"mode": "source",
|
||||||
|
"source": false
|
||||||
|
},
|
||||||
"icon": "lucide-file",
|
"icon": "lucide-file",
|
||||||
"title": "New tab"
|
"title": "Test Implementation Plan"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -70,8 +74,7 @@
|
|||||||
"title": "Bookmarks"
|
"title": "Bookmarks"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"currentTab": 1
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"direction": "horizontal",
|
"direction": "horizontal",
|
||||||
@@ -166,8 +169,10 @@
|
|||||||
"bases:Create new base": false
|
"bases:Create new base": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"active": "b72bbfe2e0c01c0c",
|
"active": "0e456d61bc5b6ded",
|
||||||
"lastOpenFiles": [
|
"lastOpenFiles": [
|
||||||
|
"Crussell/Future Work - Gap Backlog.md",
|
||||||
|
"Crussell/Test Implementation Plan.md",
|
||||||
"Crussell/Crussell Nails.md",
|
"Crussell/Crussell Nails.md",
|
||||||
"Crussell/Backend/bookings.md",
|
"Crussell/Backend/bookings.md",
|
||||||
"Untitled.base",
|
"Untitled.base",
|
||||||
|
|||||||
Reference in New Issue
Block a user