test: add guest booking, reservation, and anonymization tests
New tests: guest user creation (success, duplicate email, registered collision), guest booking flow (success, missing user_id, non-guest user_id, deposit bypass), reservation lifecycle (logged-in, anonymous, replace, validation, conflict detection, dual cleanup), anonymization (6mo threshold, pending exclusion). Fixes: deposit advance rule 48h→24h (stale test), time-based test flakiness (2h→72h offsets), handler confusion in no-show tests, enum type casting for booking status. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -27,6 +27,7 @@ import (
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/handlers/user"
|
||||
"crussell/mw"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
@@ -1092,8 +1093,9 @@ func TestBookings_Delete_NoShow24hThreshold(t *testing.T) {
|
||||
booking.ID)
|
||||
|
||||
// Delete within 24 hours (no forgiveness) - should result in no-show + deposits penalty
|
||||
delHandler := http.HandlerFunc(DeleteBookingHandler)
|
||||
delReq := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": false}
|
||||
w = makeRequest(handler, "DELETE", "/api/bookings/"+booking.ID, delReq, token)
|
||||
w = makeRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token)
|
||||
|
||||
if w.Code != http.StatusOK && w.Code != http.StatusNoContent {
|
||||
t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String())
|
||||
@@ -1156,8 +1158,9 @@ func TestBookings_Delete_NoShow_WithForgiveness(t *testing.T) {
|
||||
|
||||
// Delete within 24 hours WITH forgiveness
|
||||
trueVal := true
|
||||
delHandler := http.HandlerFunc(DeleteBookingHandler)
|
||||
delReq := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": trueVal}
|
||||
w = makeRequest(handler, "DELETE", "/api/bookings/"+booking.ID, delReq, token)
|
||||
w = makeRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token)
|
||||
|
||||
if w.Code != http.StatusOK && w.Code != http.StatusNoContent {
|
||||
t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String())
|
||||
@@ -1279,14 +1282,8 @@ func TestBookings_Unauthorized(t *testing.T) {
|
||||
|
||||
w := makeRequest(http.HandlerFunc(handler), tt.method, tt.path, tt.body, "")
|
||||
|
||||
// GetCalendar returns 404 when no auth because handler checks booking first
|
||||
// TestBookings_Create_MinimumAdvance tests that user bookings must be made at least
|
||||
// 1 hour in advance (USER requirement only - admins via AdminCreateBookingForUserHandler can accept walk-ins).
|
||||
expectedStatus = http.StatusNotFound
|
||||
}
|
||||
|
||||
if w.Code != expectedStatus {
|
||||
t.Errorf("expected status %d, got %d", expectedStatus, w.Code)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1438,12 +1435,12 @@ func TestBookings_Create_MinimumAdvance(t *testing.T) {
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Test booking 1+ hour in advance - should succeed
|
||||
aheadTime := time.Now().Add(2 * time.Hour).Truncate(time.Second)
|
||||
aheadTime = time.Date(aheadTime.Year(), aheadTime.Month(), aheadTime.Day(), 10, 0, 0, 0, aheadTime.Location())
|
||||
req := CreateBookingRequest{
|
||||
StartTime: aheadTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
aheadTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
aheadTime = time.Date(aheadTime.Year(), aheadTime.Month(), aheadTime.Day(), 10, 0, 0, 0, aheadTime.Location())
|
||||
req := CreateBookingRequest{
|
||||
StartTime: aheadTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(CreateBookingHandler)
|
||||
w := makeRequest(handler, "POST", "/api/bookings", req, token)
|
||||
@@ -1486,7 +1483,7 @@ func TestBookings_Create_WithNotes_StatusPending(t *testing.T) {
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Booking 2+ hours ahead with notes
|
||||
futureTime := time.Now().Add(2 * time.Hour).Truncate(time.Second)
|
||||
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
|
||||
notes := "Special treatment needed"
|
||||
req := CreateBookingRequest{
|
||||
@@ -1540,7 +1537,7 @@ func TestBookings_Create_WithoutNotes_StatusConfirmed(t *testing.T) {
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Booking 2+ hours ahead without notes
|
||||
futureTime := time.Now().Add(2 * time.Hour).Truncate(time.Second)
|
||||
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
|
||||
req := CreateBookingRequest{
|
||||
StartTime: futureTime,
|
||||
@@ -2522,7 +2519,6 @@ func TestBookings_RequestEdit_AlreadyHasPending(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Patch Test Validation Tests
|
||||
// =============================================================================
|
||||
@@ -2612,7 +2608,7 @@ func TestBookings_Create_PatchTestRequired_WithinNoticePeriod(t *testing.T) {
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Try to book within notice period (24h required, but only 1h passed)
|
||||
futureTime := time.Now().Add(12 * time.Hour).Truncate(time.Second)
|
||||
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
|
||||
req := CreateBookingRequest{
|
||||
StartTime: futureTime,
|
||||
@@ -2750,9 +2746,10 @@ func TestBookings_Create_PatchTestRequired_ValidRecord(t *testing.T) {
|
||||
// Deposit Requirement Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestBookings_Create_DepositRequired_Within48Hours verifies that a user with deposits_required > 0
|
||||
// cannot book within 48 hours notice. They must complete more appointments to remove this restriction.
|
||||
func TestBookings_Create_DepositRequired_Within48Hours(t *testing.T) {
|
||||
// TestBookings_Create_DepositRequired_Within24Hours verifies that a user with deposits_required > 0
|
||||
// cannot book within 24 hours notice (deposit payment window). They must complete more appointments
|
||||
// to remove this restriction.
|
||||
func TestBookings_Create_DepositRequired_Within24Hours(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -2764,7 +2761,7 @@ func TestBookings_Create_DepositRequired_Within48Hours(t *testing.T) {
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
// Set deposits_required=3 to trigger 48h advance booking requirement
|
||||
// Set deposits_required=3 to trigger 24h advance booking requirement
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
@@ -2778,11 +2775,11 @@ func TestBookings_Create_DepositRequired_Within48Hours(t *testing.T) {
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Try to book within 48 hours (should be blocked)
|
||||
within48h := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
||||
within48h = time.Date(within48h.Year(), within48h.Month(), within48h.Day(), 10, 0, 0, 0, within48h.Location())
|
||||
// Try to book within 24 hours (should be blocked by deposit advance rule)
|
||||
// 23h advance: passes 1h minimum, fails 24h deposit rule
|
||||
within24h := time.Now().Add(23 * time.Hour)
|
||||
req := CreateBookingRequest{
|
||||
StartTime: within48h,
|
||||
StartTime: within24h,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
@@ -2790,11 +2787,11 @@ func TestBookings_Create_DepositRequired_Within48Hours(t *testing.T) {
|
||||
w := makeRequest(handler, "POST", "/api/bookings", req, token)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400 for within 48h booking with deposit requirement, got %d. body: %s", w.Code, w.Body.String())
|
||||
t.Errorf("expected status 400 for within 24h booking with deposit requirement, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
if !bytes.Contains(w.Body.Bytes(), []byte("48 hours")) {
|
||||
t.Errorf("expected error message about 48 hours, got: %s", w.Body.String())
|
||||
if !bytes.Contains(w.Body.Bytes(), []byte("24 hours")) {
|
||||
t.Errorf("expected error message about 24 hours, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2896,6 +2893,7 @@ func TestBookings_Create_NoDepositRequired_Within48Hours(t *testing.T) {
|
||||
t.Errorf("expected status 201 for booking within 48h with no deposit required, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Deposit Snapshot and Field Tests
|
||||
// =============================================================================
|
||||
@@ -3110,7 +3108,6 @@ func TestBookings_Get_DepositFieldsReturned(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TestBookings_Edit_ClosedDay_UserBlocked verifies that a regular user cannot edit a booking
|
||||
// to fall on a closed day (exceptional hours marked as is_open=false).
|
||||
func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) {
|
||||
@@ -3418,4 +3415,260 @@ func TestBookings_Edit_OverlappingBlocker_UserBlocked(t *testing.T) {
|
||||
if !bytes.Contains(w.Body.Bytes(), []byte("blocked")) {
|
||||
t.Errorf("expected error message to mention 'blocked', got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Guest Booking Tests ---
|
||||
|
||||
func TestGuestUser_Create_Success(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
req := map[string]string{
|
||||
"firstName": "Jane",
|
||||
"lastName": "Doe",
|
||||
"email": "jane.doe@test.com",
|
||||
"phone": "07123456789",
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(user.CreateGuestUserHandler)
|
||||
w := makeRequest(handler, "POST", "/api/users/guest", req, "")
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["role"] != "guest" {
|
||||
t.Errorf("expected role 'guest', got '%s'", resp["role"])
|
||||
}
|
||||
if resp["id"] == "" {
|
||||
t.Error("expected non-empty user ID")
|
||||
}
|
||||
|
||||
// Verify user exists in DB
|
||||
var role string
|
||||
err := db.DB.QueryRow(context.Background(), `SELECT account_role FROM users WHERE id = $1`, resp["id"]).Scan(&role)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query user: %v", err)
|
||||
}
|
||||
if role != "guest" {
|
||||
t.Errorf("expected role 'guest' in DB, got '%s'", role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuestUser_Create_DuplicateEmail(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// First guest creation
|
||||
req := map[string]string{
|
||||
"firstName": "John",
|
||||
"lastName": "Smith",
|
||||
"email": "john@test.com",
|
||||
"phone": "07123456789",
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(user.CreateGuestUserHandler)
|
||||
w1 := makeRequest(handler, "POST", "/api/users/guest", req, "")
|
||||
if w1.Code != http.StatusCreated {
|
||||
t.Fatalf("first guest creation failed: %d", w1.Code)
|
||||
}
|
||||
var resp1 map[string]string
|
||||
json.Unmarshal(w1.Body.Bytes(), &resp1)
|
||||
|
||||
// Second guest with same email — should create a NEW account
|
||||
req2 := map[string]string{
|
||||
"firstName": "Jane",
|
||||
"lastName": "Smith",
|
||||
"email": "john@test.com", // same email
|
||||
"phone": "07123456780",
|
||||
}
|
||||
w2 := makeRequest(handler, "POST", "/api/users/guest", req2, "")
|
||||
if w2.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201 for second guest, got %d. body: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
var resp2 map[string]string
|
||||
json.Unmarshal(w2.Body.Bytes(), &resp2)
|
||||
|
||||
if resp1["id"] == resp2["id"] {
|
||||
t.Error("expected different user IDs for duplicate email, got same ID")
|
||||
}
|
||||
|
||||
// Verify two separate guest accounts exist
|
||||
var count int
|
||||
db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM users WHERE email = $1 AND account_role = 'guest'`, "john@test.com").Scan(&count)
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 guest accounts with same email, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuestUser_Create_RegisteredEmailCollision(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create a registered user with a known email
|
||||
registeredEmail := "registered@example.com"
|
||||
db.DB.Exec(context.Background(), `
|
||||
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, account_role)
|
||||
VALUES ('Registered', 'User', $1, '07123456700', '1990-01-01', 'verified_email')
|
||||
`, registeredEmail)
|
||||
|
||||
// Try to create a guest with same email as the registered user
|
||||
req := map[string]string{
|
||||
"firstName": "Evil",
|
||||
"lastName": "Guest",
|
||||
"email": registeredEmail,
|
||||
"phone": "07123456799",
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(user.CreateGuestUserHandler)
|
||||
w := makeRequest(handler, "POST", "/api/users/guest", req, "")
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("expected status 409 for registered email collision, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "log in") {
|
||||
t.Errorf("expected error to mention 'log in', got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuestBooking_Create_Success(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
// Create guest user
|
||||
guestReq := map[string]string{
|
||||
"firstName": "Guest",
|
||||
"lastName": "User",
|
||||
"email": "guest@test.com",
|
||||
"phone": "07123456789",
|
||||
}
|
||||
handler := http.HandlerFunc(user.CreateGuestUserHandler)
|
||||
w := makeRequest(handler, "POST", "/api/users/guest", guestReq, "")
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("failed to create guest user: %d", w.Code)
|
||||
}
|
||||
var guestResp map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &guestResp)
|
||||
guestID := guestResp["id"]
|
||||
|
||||
// Create booking as guest
|
||||
serviceID, _ := fixtures.CreateTestService(db.DB)
|
||||
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
|
||||
|
||||
req := CreateBookingRequest{
|
||||
StartTime: futureTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
UserID: &guestID,
|
||||
}
|
||||
|
||||
bookingHandler := http.HandlerFunc(CreateBookingHandler)
|
||||
w2 := makeRequest(bookingHandler, "POST", "/api/bookings", req, "")
|
||||
|
||||
if w2.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201 for guest booking, got %d. body: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
// Verify booking in DB
|
||||
var count int
|
||||
db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1`, guestID).Scan(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 booking for guest, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuestBooking_Create_WithoutUserID(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Attempt booking without auth AND without user_id
|
||||
serviceID, _ := fixtures.CreateTestService(db.DB)
|
||||
futureTime := time.Now().Add(72 * time.Hour)
|
||||
|
||||
req := CreateBookingRequest{
|
||||
StartTime: futureTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(CreateBookingHandler)
|
||||
w := makeRequest(handler, "POST", "/api/bookings", req, "")
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401 for missing user_id, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuestBooking_Create_NonGuestUserID(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create a registered (non-guest) user
|
||||
userID, _ := fixtures.CreateTestUser(db.DB)
|
||||
|
||||
// Try to book using their user_id but without auth token
|
||||
serviceID, _ := fixtures.CreateTestService(db.DB)
|
||||
futureTime := time.Now().Add(72 * time.Hour)
|
||||
|
||||
req := CreateBookingRequest{
|
||||
StartTime: futureTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
UserID: &userID,
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(CreateBookingHandler)
|
||||
w := makeRequest(handler, "POST", "/api/bookings", req, "")
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400 for non-guest user_id, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "guest") {
|
||||
t.Errorf("expected error to mention 'guest', got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuestBooking_SkipsDepositCheck(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
// Create guest user
|
||||
guestReq := map[string]string{
|
||||
"firstName": "Guest",
|
||||
"lastName": "Skipper",
|
||||
"email": "skip@test.com",
|
||||
"phone": "07123456788",
|
||||
}
|
||||
handler := http.HandlerFunc(user.CreateGuestUserHandler)
|
||||
w := makeRequest(handler, "POST", "/api/users/guest", guestReq, "")
|
||||
var guestResp map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &guestResp)
|
||||
guestID := guestResp["id"]
|
||||
|
||||
// Give them an active booking with deposit required
|
||||
serviceID, _ := fixtures.CreateTestService(db.DB)
|
||||
pastTime := time.Now().Add(72 * time.Hour)
|
||||
db.DB.Exec(context.Background(), `
|
||||
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||
VALUES ($1, $2, 'confirmed'::booking_status, false)
|
||||
`, guestID, pastTime)
|
||||
|
||||
// Guest should still be able to create a second booking (deposit check skipped)
|
||||
futureTime := time.Now().Add(96 * time.Hour).Truncate(time.Second)
|
||||
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 14, 0, 0, 0, futureTime.Location())
|
||||
|
||||
req := CreateBookingRequest{
|
||||
StartTime: futureTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
UserID: &guestID,
|
||||
}
|
||||
|
||||
bookingHandler := http.HandlerFunc(CreateBookingHandler)
|
||||
w2 := makeRequest(bookingHandler, "POST", "/api/bookings", req, "")
|
||||
|
||||
if w2.Code != http.StatusCreated {
|
||||
t.Errorf("expected guest to bypass deposit check, got %d. body: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user