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())
}
}
+34 -25
View File
@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -83,11 +84,7 @@ func makeReserveRequest(method, path string, body interface{}, token string) *ht
func reserveTestToken(t *testing.T, userID, role string) string {
t.Helper()
token, err := jwt.GenerateTestJWT(userID, role, time.Hour)
if err != nil {
t.Fatalf("failed to generate test JWT: %v", err)
}
return token
return jwt.GenerateTestToken(userID, role)
}
// TestReserveSlot_LoggedIn verifies logged-in users can reserve a slot.
@@ -95,7 +92,8 @@ func TestReserveSlot_LoggedIn(t *testing.T) {
cleanup := setupReserveTestDB(t)
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)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
@@ -104,7 +102,7 @@ func TestReserveSlot_LoggedIn(t *testing.T) {
reqBody := ReserveSlotRequest{
StartTime: startTime,
ServiceIDs: serviceIDs,
ServiceIDs: []string{serviceID},
}
w := makeReserveRequest("POST", "/api/bookings/reserve", reqBody, token)
@@ -132,7 +130,8 @@ func TestReserveSlot_LoggedIn_ReplacesExisting(t *testing.T) {
cleanup := setupReserveTestDB(t)
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)
if err != nil {
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
var count int
err := db.DB.QueryRow(context.Background(), `
err = db.DB.QueryRow(context.Background(), `
SELECT COUNT(*) FROM time_blockers
WHERE description LIKE 'RESERVATION:user:%' AND created_by = 'user-002'
`).Scan(&count)
WHERE description LIKE 'RESERVATION:user:%' AND created_by = $1
`, userID).Scan(&count)
if err != nil {
t.Fatalf("failed to count reservations: %v", err)
}
@@ -251,19 +250,25 @@ func TestReserveSlot_BlockedByExistingBooking(t *testing.T) {
cleanup := setupReserveTestDB(t)
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)
_, err := db.DB.Exec(context.Background(), `
_, err = db.DB.Exec(context.Background(), `
INSERT INTO bookings (user_id, start_time, status, deposit_required)
VALUES ($1, $2, 'confirmed', false)
`, "fixture-user", bookingStart)
`, userID, bookingStart)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
StartTime: bookingStart,
ServiceIDs: serviceIDs,
ServiceIDs: []string{serviceID},
}, "")
if w.Code != http.StatusConflict {
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)
_, err := db.DB.Exec(context.Background(), `
_, err = db.DB.Exec(context.Background(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'Admin Blocked', NULL)
`, blockerStart)
@@ -292,7 +297,7 @@ func TestReserveSlot_BlockedByTimeBlocker(t *testing.T) {
w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
StartTime: blockerStart,
ServiceIDs: serviceIDs,
ServiceIDs: []string{serviceID},
}, "")
if w.Code != http.StatusConflict {
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()
// Create fixture users for user reservations
user1ID, _ := fixtures.CreateTestUser(db.DB)
user2ID, _ := fixtures.CreateTestUser(db.DB)
// Create old anon reservation (15 min ago)
_, err := db.DB.Exec(ctx, `
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)
_, 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)
VALUES ($1, 60, 'RESERVATION:user:user-001:1234', $2, 'user-001')
`, time.Now().Add(72*time.Hour), time.Now().Add(-45*time.Minute))
VALUES ($1, 60, 'RESERVATION:user:%s:1234', $2, $3)
`, user1ID), time.Now().Add(72*time.Hour), time.Now().Add(-45*time.Minute), user1ID)
if err != nil {
t.Fatalf("failed to create old user reservation: %v", err)
}
// 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)
VALUES ($1, 60, 'RESERVATION:user:user-002:1234', $2, 'user-002')
`, time.Now().Add(96*time.Hour), time.Now().Add(-2*time.Hour))
VALUES ($1, 60, 'RESERVATION:user:%s:1234', $2, $3)
`, user2ID), time.Now().Add(96*time.Hour), time.Now().Add(-2*time.Hour), user2ID)
if err != nil {
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
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 {
t.Error("expected 45-min user reservation to survive")
}
// Verify 2hr user reservation was deleted
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 {
t.Error("expected 2-hour user reservation to be deleted")
}
@@ -21,6 +21,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -698,3 +699,68 @@ func TestCleanupOldReservations(t *testing.T) {
// Ensure pool is used to avoid unused import error
var _ = pgxpool.Pool{}
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)
}
}