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:
2026-05-03 15:59:40 +01:00
parent 6808752e0d
commit 88ee265603
11 changed files with 2479 additions and 17 deletions
+353 -1
View File
@@ -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)
}
}
+83
View File
@@ -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)
}
}
-5
View File
@@ -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)
} }
} }
+636 -3
View File
@@ -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 }
+91
View File
@@ -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{}
+134
View File
@@ -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())
}
}
+83
View File
@@ -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())
}
}
+111
View File
@@ -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()
}
+13 -8
View File
@@ -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",