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:
2026-04-30 11:45:08 +01:00
co-authored by Sisyphus
parent 4f173b04dc
commit 52ca9b425b
3 changed files with 386 additions and 58 deletions
+286 -33
View File
@@ -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())
}
}