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"
|
"time"
|
||||||
|
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
|
"crussell/handlers/user"
|
||||||
"crussell/mw"
|
"crussell/mw"
|
||||||
"crussell/testutils/fixtures"
|
"crussell/testutils/fixtures"
|
||||||
"crussell/testutils/jwt"
|
"crussell/testutils/jwt"
|
||||||
@@ -1092,8 +1093,9 @@ func TestBookings_Delete_NoShow24hThreshold(t *testing.T) {
|
|||||||
booking.ID)
|
booking.ID)
|
||||||
|
|
||||||
// Delete within 24 hours (no forgiveness) - should result in no-show + deposits penalty
|
// 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}
|
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 {
|
if w.Code != http.StatusOK && w.Code != http.StatusNoContent {
|
||||||
t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String())
|
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
|
// Delete within 24 hours WITH forgiveness
|
||||||
trueVal := true
|
trueVal := true
|
||||||
|
delHandler := http.HandlerFunc(DeleteBookingHandler)
|
||||||
delReq := map[string]interface{}{"reason": "client_cancelled", "forgive_no_show": trueVal}
|
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 {
|
if w.Code != http.StatusOK && w.Code != http.StatusNoContent {
|
||||||
t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String())
|
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, "")
|
w := makeRequest(http.HandlerFunc(handler), tt.method, tt.path, tt.body, "")
|
||||||
|
|
||||||
// GetCalendar returns 404 when no auth because handler checks booking first
|
if w.Code != http.StatusUnauthorized {
|
||||||
// TestBookings_Create_MinimumAdvance tests that user bookings must be made at least
|
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1438,12 +1435,12 @@ func TestBookings_Create_MinimumAdvance(t *testing.T) {
|
|||||||
token := jwt.GenerateUserToken(userID)
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
// Test booking 1+ hour in advance - should succeed
|
// Test booking 1+ hour in advance - should succeed
|
||||||
aheadTime := time.Now().Add(2 * time.Hour).Truncate(time.Second)
|
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())
|
aheadTime = time.Date(aheadTime.Year(), aheadTime.Month(), aheadTime.Day(), 10, 0, 0, 0, aheadTime.Location())
|
||||||
req := CreateBookingRequest{
|
req := CreateBookingRequest{
|
||||||
StartTime: aheadTime,
|
StartTime: aheadTime,
|
||||||
ServiceIDs: []string{serviceID},
|
ServiceIDs: []string{serviceID},
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := http.HandlerFunc(CreateBookingHandler)
|
handler := http.HandlerFunc(CreateBookingHandler)
|
||||||
w := makeRequest(handler, "POST", "/api/bookings", req, token)
|
w := makeRequest(handler, "POST", "/api/bookings", req, token)
|
||||||
@@ -1486,7 +1483,7 @@ func TestBookings_Create_WithNotes_StatusPending(t *testing.T) {
|
|||||||
token := jwt.GenerateUserToken(userID)
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
// Booking 2+ hours ahead with notes
|
// 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())
|
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
|
||||||
notes := "Special treatment needed"
|
notes := "Special treatment needed"
|
||||||
req := CreateBookingRequest{
|
req := CreateBookingRequest{
|
||||||
@@ -1540,7 +1537,7 @@ func TestBookings_Create_WithoutNotes_StatusConfirmed(t *testing.T) {
|
|||||||
token := jwt.GenerateUserToken(userID)
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
// Booking 2+ hours ahead without notes
|
// 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())
|
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
|
||||||
req := CreateBookingRequest{
|
req := CreateBookingRequest{
|
||||||
StartTime: futureTime,
|
StartTime: futureTime,
|
||||||
@@ -2522,7 +2519,6 @@ func TestBookings_RequestEdit_AlreadyHasPending(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Patch Test Validation Tests
|
// Patch Test Validation Tests
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -2612,7 +2608,7 @@ func TestBookings_Create_PatchTestRequired_WithinNoticePeriod(t *testing.T) {
|
|||||||
token := jwt.GenerateUserToken(userID)
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
// Try to book within notice period (24h required, but only 1h passed)
|
// 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())
|
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
|
||||||
req := CreateBookingRequest{
|
req := CreateBookingRequest{
|
||||||
StartTime: futureTime,
|
StartTime: futureTime,
|
||||||
@@ -2750,9 +2746,10 @@ func TestBookings_Create_PatchTestRequired_ValidRecord(t *testing.T) {
|
|||||||
// Deposit Requirement Tests
|
// Deposit Requirement Tests
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// TestBookings_Create_DepositRequired_Within48Hours verifies that a user with deposits_required > 0
|
// TestBookings_Create_DepositRequired_Within24Hours verifies that a user with deposits_required > 0
|
||||||
// cannot book within 48 hours notice. They must complete more appointments to remove this restriction.
|
// cannot book within 24 hours notice (deposit payment window). They must complete more appointments
|
||||||
func TestBookings_Create_DepositRequired_Within48Hours(t *testing.T) {
|
// to remove this restriction.
|
||||||
|
func TestBookings_Create_DepositRequired_Within24Hours(t *testing.T) {
|
||||||
cleanup := setupTestDB(t)
|
cleanup := setupTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
@@ -2764,7 +2761,7 @@ func TestBookings_Create_DepositRequired_Within48Hours(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer fixtures.DeleteUser(db.DB, userID)
|
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)
|
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to set deposits_required: %v", err)
|
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)
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
// Try to book within 48 hours (should be blocked)
|
// Try to book within 24 hours (should be blocked by deposit advance rule)
|
||||||
within48h := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
// 23h advance: passes 1h minimum, fails 24h deposit rule
|
||||||
within48h = time.Date(within48h.Year(), within48h.Month(), within48h.Day(), 10, 0, 0, 0, within48h.Location())
|
within24h := time.Now().Add(23 * time.Hour)
|
||||||
req := CreateBookingRequest{
|
req := CreateBookingRequest{
|
||||||
StartTime: within48h,
|
StartTime: within24h,
|
||||||
ServiceIDs: []string{serviceID},
|
ServiceIDs: []string{serviceID},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2790,11 +2787,11 @@ func TestBookings_Create_DepositRequired_Within48Hours(t *testing.T) {
|
|||||||
w := makeRequest(handler, "POST", "/api/bookings", req, token)
|
w := makeRequest(handler, "POST", "/api/bookings", req, token)
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
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")) {
|
if !bytes.Contains(w.Body.Bytes(), []byte("24 hours")) {
|
||||||
t.Errorf("expected error message about 48 hours, got: %s", w.Body.String())
|
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())
|
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
|
// 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
|
// 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).
|
// to fall on a closed day (exceptional hours marked as is_open=false).
|
||||||
func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) {
|
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")) {
|
if !bytes.Contains(w.Body.Bytes(), []byte("blocked")) {
|
||||||
t.Errorf("expected error message to mention 'blocked', got: %s", w.Body.String())
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -83,11 +84,7 @@ func makeReserveRequest(method, path string, body interface{}, token string) *ht
|
|||||||
|
|
||||||
func reserveTestToken(t *testing.T, userID, role string) string {
|
func reserveTestToken(t *testing.T, userID, role string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
token, err := jwt.GenerateTestJWT(userID, role, time.Hour)
|
return jwt.GenerateTestToken(userID, role)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to generate test JWT: %v", err)
|
|
||||||
}
|
|
||||||
return token
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestReserveSlot_LoggedIn verifies logged-in users can reserve a slot.
|
// TestReserveSlot_LoggedIn verifies logged-in users can reserve a slot.
|
||||||
@@ -95,7 +92,8 @@ func TestReserveSlot_LoggedIn(t *testing.T) {
|
|||||||
cleanup := setupReserveTestDB(t)
|
cleanup := setupReserveTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
token := reserveTestToken(t, "user-001", "verified_email")
|
userID, _ := fixtures.CreateTestUser(db.DB)
|
||||||
|
token := reserveTestToken(t, userID, "verified_email")
|
||||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create test service: %v", err)
|
t.Fatalf("failed to create test service: %v", err)
|
||||||
@@ -104,7 +102,7 @@ func TestReserveSlot_LoggedIn(t *testing.T) {
|
|||||||
|
|
||||||
reqBody := ReserveSlotRequest{
|
reqBody := ReserveSlotRequest{
|
||||||
StartTime: startTime,
|
StartTime: startTime,
|
||||||
ServiceIDs: serviceIDs,
|
ServiceIDs: []string{serviceID},
|
||||||
}
|
}
|
||||||
|
|
||||||
w := makeReserveRequest("POST", "/api/bookings/reserve", reqBody, token)
|
w := makeReserveRequest("POST", "/api/bookings/reserve", reqBody, token)
|
||||||
@@ -132,7 +130,8 @@ func TestReserveSlot_LoggedIn_ReplacesExisting(t *testing.T) {
|
|||||||
cleanup := setupReserveTestDB(t)
|
cleanup := setupReserveTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
token := reserveTestToken(t, "user-002", "verified_email")
|
userID, _ := fixtures.CreateTestUser(db.DB)
|
||||||
|
token := reserveTestToken(t, userID, "verified_email")
|
||||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create test service: %v", err)
|
t.Fatalf("failed to create test service: %v", err)
|
||||||
@@ -161,10 +160,10 @@ func TestReserveSlot_LoggedIn_ReplacesExisting(t *testing.T) {
|
|||||||
|
|
||||||
// Verify only one user reservation exists
|
// Verify only one user reservation exists
|
||||||
var count int
|
var count int
|
||||||
err := db.DB.QueryRow(context.Background(), `
|
err = db.DB.QueryRow(context.Background(), `
|
||||||
SELECT COUNT(*) FROM time_blockers
|
SELECT COUNT(*) FROM time_blockers
|
||||||
WHERE description LIKE 'RESERVATION:user:%' AND created_by = 'user-002'
|
WHERE description LIKE 'RESERVATION:user:%' AND created_by = $1
|
||||||
`).Scan(&count)
|
`, userID).Scan(&count)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to count reservations: %v", err)
|
t.Fatalf("failed to count reservations: %v", err)
|
||||||
}
|
}
|
||||||
@@ -251,19 +250,25 @@ func TestReserveSlot_BlockedByExistingBooking(t *testing.T) {
|
|||||||
cleanup := setupReserveTestDB(t)
|
cleanup := setupReserveTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
// Create a booking at the same time
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create test service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a fixture user and booking at the same time
|
||||||
|
userID, _ := fixtures.CreateTestUser(db.DB)
|
||||||
bookingStart := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
bookingStart := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
||||||
_, err := db.DB.Exec(context.Background(), `
|
_, err = db.DB.Exec(context.Background(), `
|
||||||
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||||
VALUES ($1, $2, 'confirmed', false)
|
VALUES ($1, $2, 'confirmed', false)
|
||||||
`, "fixture-user", bookingStart)
|
`, userID, bookingStart)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create booking: %v", err)
|
t.Fatalf("failed to create booking: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
|
w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
|
||||||
StartTime: bookingStart,
|
StartTime: bookingStart,
|
||||||
ServiceIDs: serviceIDs,
|
ServiceIDs: []string{serviceID},
|
||||||
}, "")
|
}, "")
|
||||||
if w.Code != http.StatusConflict {
|
if w.Code != http.StatusConflict {
|
||||||
t.Errorf("expected 409 for overlapping booking, got %d. body: %s", w.Code, w.Body.String())
|
t.Errorf("expected 409 for overlapping booking, got %d. body: %s", w.Code, w.Body.String())
|
||||||
@@ -282,7 +287,7 @@ func TestReserveSlot_BlockedByTimeBlocker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
blockerStart := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
blockerStart := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
||||||
_, err := db.DB.Exec(context.Background(), `
|
_, err = db.DB.Exec(context.Background(), `
|
||||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
||||||
VALUES ($1, 60, 'Admin Blocked', NULL)
|
VALUES ($1, 60, 'Admin Blocked', NULL)
|
||||||
`, blockerStart)
|
`, blockerStart)
|
||||||
@@ -292,7 +297,7 @@ func TestReserveSlot_BlockedByTimeBlocker(t *testing.T) {
|
|||||||
|
|
||||||
w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
|
w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
|
||||||
StartTime: blockerStart,
|
StartTime: blockerStart,
|
||||||
ServiceIDs: serviceIDs,
|
ServiceIDs: []string{serviceID},
|
||||||
}, "")
|
}, "")
|
||||||
if w.Code != http.StatusConflict {
|
if w.Code != http.StatusConflict {
|
||||||
t.Errorf("expected 409 for blocked slot, got %d. body: %s", w.Code, w.Body.String())
|
t.Errorf("expected 409 for blocked slot, got %d. body: %s", w.Code, w.Body.String())
|
||||||
@@ -307,6 +312,10 @@ func TestReserveSlot_DualCleanup(t *testing.T) {
|
|||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create fixture users for user reservations
|
||||||
|
user1ID, _ := fixtures.CreateTestUser(db.DB)
|
||||||
|
user2ID, _ := fixtures.CreateTestUser(db.DB)
|
||||||
|
|
||||||
// Create old anon reservation (15 min ago)
|
// Create old anon reservation (15 min ago)
|
||||||
_, err := db.DB.Exec(ctx, `
|
_, err := db.DB.Exec(ctx, `
|
||||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
|
||||||
@@ -326,19 +335,19 @@ func TestReserveSlot_DualCleanup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create old user reservation (45 min ago) - should survive (> 10min, < 1hr)
|
// Create old user reservation (45 min ago) - should survive (> 10min, < 1hr)
|
||||||
_, err = db.DB.Exec(ctx, `
|
_, err = db.DB.Exec(ctx, fmt.Sprintf(`
|
||||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
|
||||||
VALUES ($1, 60, 'RESERVATION:user:user-001:1234', $2, 'user-001')
|
VALUES ($1, 60, 'RESERVATION:user:%s:1234', $2, $3)
|
||||||
`, time.Now().Add(72*time.Hour), time.Now().Add(-45*time.Minute))
|
`, user1ID), time.Now().Add(72*time.Hour), time.Now().Add(-45*time.Minute), user1ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create old user reservation: %v", err)
|
t.Fatalf("failed to create old user reservation: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create very old user reservation (2 hours ago) - should be deleted
|
// Create very old user reservation (2 hours ago) - should be deleted
|
||||||
_, err = db.DB.Exec(ctx, `
|
_, err = db.DB.Exec(ctx, fmt.Sprintf(`
|
||||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
|
||||||
VALUES ($1, 60, 'RESERVATION:user:user-002:1234', $2, 'user-002')
|
VALUES ($1, 60, 'RESERVATION:user:%s:1234', $2, $3)
|
||||||
`, time.Now().Add(96*time.Hour), time.Now().Add(-2*time.Hour))
|
`, user2ID), time.Now().Add(96*time.Hour), time.Now().Add(-2*time.Hour), user2ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create very old user reservation: %v", err)
|
t.Fatalf("failed to create very old user reservation: %v", err)
|
||||||
}
|
}
|
||||||
@@ -365,14 +374,14 @@ func TestReserveSlot_DualCleanup(t *testing.T) {
|
|||||||
|
|
||||||
// Verify 45-min user reservation survived
|
// Verify 45-min user reservation survived
|
||||||
var user45Count int
|
var user45Count int
|
||||||
db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:user:user-001:%'`).Scan(&user45Count)
|
db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE $1`, fmt.Sprintf("RESERVATION:user:%s:%%", user1ID)).Scan(&user45Count)
|
||||||
if user45Count != 1 {
|
if user45Count != 1 {
|
||||||
t.Error("expected 45-min user reservation to survive")
|
t.Error("expected 45-min user reservation to survive")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify 2hr user reservation was deleted
|
// Verify 2hr user reservation was deleted
|
||||||
var user2hrCount int
|
var user2hrCount int
|
||||||
db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:user:user-002:%'`).Scan(&user2hrCount)
|
db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE $1`, fmt.Sprintf("RESERVATION:user:%s:%%", user2ID)).Scan(&user2hrCount)
|
||||||
if user2hrCount > 0 {
|
if user2hrCount > 0 {
|
||||||
t.Error("expected 2-hour user reservation to be deleted")
|
t.Error("expected 2-hour user reservation to be deleted")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -698,3 +699,68 @@ func TestCleanupOldReservations(t *testing.T) {
|
|||||||
// 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{}
|
||||||
|
|
||||||
|
func TestAnonymizeStaleGuestAccounts(t *testing.T) {
|
||||||
|
cleanup := setupTimeBlockersTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Guest 1: last booking 7 months ago — should be anonymized
|
||||||
|
guest1ID, _ := fixtures.CreateTestUser(db.DB)
|
||||||
|
db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest1ID)
|
||||||
|
db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||||
|
VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false)
|
||||||
|
`, guest1ID)
|
||||||
|
|
||||||
|
// Guest 2: last booking 3 months ago — should NOT be anonymized
|
||||||
|
guest2ID, _ := fixtures.CreateTestUser(db.DB)
|
||||||
|
db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest2ID)
|
||||||
|
db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||||
|
VALUES ($1, NOW() - INTERVAL '3 months', 'completed', false)
|
||||||
|
`, guest2ID)
|
||||||
|
|
||||||
|
// Guest 3: has a pending booking — should NOT be anonymized (regardless of booking age)
|
||||||
|
guest3ID, _ := fixtures.CreateTestUser(db.DB)
|
||||||
|
db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest3ID)
|
||||||
|
db.DB.Exec(ctx, `
|
||||||
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||||
|
VALUES ($1, NOW() + INTERVAL '2 days', 'pending', false)
|
||||||
|
`, guest3ID)
|
||||||
|
|
||||||
|
// Run anonymization
|
||||||
|
err := AnonymizeStaleGuestAccounts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guest 1 should be anonymized
|
||||||
|
var g1Name string
|
||||||
|
db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guest1ID).Scan(&g1Name)
|
||||||
|
if g1Name != "Guest" {
|
||||||
|
t.Errorf("expected guest 1 to be anonymized, got first_name='%s'", g1Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guest 2 should NOT be anonymized
|
||||||
|
var g2Name string
|
||||||
|
db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guest2ID).Scan(&g2Name)
|
||||||
|
if g2Name == "Guest" {
|
||||||
|
t.Error("expected guest 2 to NOT be anonymized (booking too recent)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guest 3 should NOT be anonymized (has pending booking)
|
||||||
|
var g3Name string
|
||||||
|
db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guest3ID).Scan(&g3Name)
|
||||||
|
if g3Name == "Guest" {
|
||||||
|
t.Error("expected guest 3 to NOT be anonymized (has pending booking)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify guest 1's email was anonymized
|
||||||
|
var g1Email string
|
||||||
|
db.DB.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, guest1ID).Scan(&g1Email)
|
||||||
|
if !strings.HasPrefix(g1Email, "anon-") {
|
||||||
|
t.Errorf("expected guest 1 email to start with 'anon-', got '%s'", g1Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user