//go:build test package user import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "crussell/clock" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" ) // ============================================================ // GetGDPRExportHandler Tests // ============================================================ func TestGDPRExport_NoAuth(t *testing.T) { t.Parallel() req := httptest.NewRequest(http.MethodGet, "/api/user/gdpr-export", nil) rr := httptest.NewRecorder() GetGDPRExportHandler(rr, req) if rr.Code != http.StatusUnauthorized { t.Errorf("expected status 401, got %d", rr.Code) } } func TestGDPRExport_CacheMiss(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } token := jwt.GenerateUserToken(userID) req := httptest.NewRequest(http.MethodGet, "/api/user/gdpr-export", nil) req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) rr := httptest.NewRecorder() GetGDPRExportHandler(rr, req) if rr.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rr.Code) t.Logf("response body: %s", rr.Body.String()) return } cacheHeader := rr.Header().Get("X-Cache") if cacheHeader != "MISS" { t.Errorf("expected X-Cache header 'MISS', got %q", cacheHeader) } var resp map[string]string if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if resp["status"] != "generating" { t.Errorf("expected status 'generating', got %q", resp["status"]) } } func TestGDPRExport_CacheHit(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } token := jwt.GenerateUserToken(userID) testData := json.RawMessage(`{"user_profile":{"id":"` + userID + `","first_name":"Test"}}`) gdprExportCacheMu.Lock() gdprExportCache[userID] = &gdprCacheEntry{ data: testData, expiresAt: clock.Now().Add(12 * time.Hour), } gdprExportCacheMu.Unlock() req := httptest.NewRequest(http.MethodGet, "/api/user/gdpr-export", nil) req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) rr := httptest.NewRecorder() GetGDPRExportHandler(rr, req) if rr.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rr.Code) t.Logf("response body: %s", rr.Body.String()) return } cacheHeader := rr.Header().Get("X-Cache") if cacheHeader != "HIT" { t.Errorf("expected X-Cache header 'HIT', got %q", cacheHeader) } if rr.Body.String() != string(testData) { t.Errorf("expected cached data, got %s", rr.Body.String()) } } func TestGDPRExport_CacheGenerating(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } token := jwt.GenerateUserToken(userID) gdprExportCacheMu.Lock() gdprExportCache[userID] = &gdprCacheEntry{ generating: true, expiresAt: clock.Now().Add(12 * time.Hour), } gdprExportCacheMu.Unlock() req := httptest.NewRequest(http.MethodGet, "/api/user/gdpr-export", nil) req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) rr := httptest.NewRecorder() GetGDPRExportHandler(rr, req) if rr.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rr.Code) t.Logf("response body: %s", rr.Body.String()) return } cacheHeader := rr.Header().Get("X-Cache") if cacheHeader != "GENERATING" { t.Errorf("expected X-Cache header 'GENERATING', got %q", cacheHeader) } var resp map[string]string if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if resp["status"] != "generating" { t.Errorf("expected status 'generating', got %q", resp["status"]) } } func TestGDPRExport_ExpiredCacheTriggersRegeneration(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } token := jwt.GenerateUserToken(userID) gdprExportCacheMu.Lock() gdprExportCache[userID] = &gdprCacheEntry{ data: json.RawMessage(`{"old":"data"}`), expiresAt: clock.Now().Add(-1 * time.Hour), } gdprExportCacheMu.Unlock() req := httptest.NewRequest(http.MethodGet, "/api/user/gdpr-export", nil) req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Authorization", "Bearer "+token) rr := httptest.NewRecorder() GetGDPRExportHandler(rr, req) if rr.Code != http.StatusOK { t.Errorf("expected status 200, got %d", rr.Code) return } cacheHeader := rr.Header().Get("X-Cache") if cacheHeader != "MISS" { t.Errorf("expected X-Cache header 'MISS' for expired cache, got %q", cacheHeader) } } // ============================================================ // SQL anonymize_user() Child Table Scrubbing Tests // ============================================================ func TestAnonymizeUser_ScrubsSocialLogins(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO user_social_logins (user_id, provider, immutable_id) VALUES ($1, 'google', 'google-123'), ($1, 'microsoft', 'ms-456') `, userID) if err != nil { t.Fatalf("failed to insert social logins: %v", err) } var count int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_social_logins WHERE user_id = $1`, userID).Scan(&count) if err != nil { t.Fatalf("failed to count social logins: %v", err) } if count != 2 { t.Fatalf("expected 2 social logins before anonymization, got %d", count) } _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) if err != nil { t.Fatalf("anonymize_user failed: %v", err) } err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_social_logins WHERE user_id = $1`, userID).Scan(&count) if err != nil { t.Fatalf("failed to count social logins after anonymization: %v", err) } if count != 0 { t.Errorf("expected 0 social logins after anonymization, got %d", count) } } func TestAnonymizeUser_SoftDeletesSavedCards(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default) VALUES ($1, 'sq_card_123', 'Visa', '4242', 12, 2030, 'fp_abc123', true) `, userID) if err != nil { t.Fatalf("failed to insert saved card: %v", err) } _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) if err != nil { t.Fatalf("anonymize_user failed: %v", err) } var deletedAt, fingerprint, squareCardID interface{} var last4 string var expMonth, expYear int err = tx.QueryRow(ctx, ` SELECT deleted_at, last_4, fingerprint, exp_month, exp_year, square_card_id FROM user_saved_cards WHERE user_id = $1 `, userID).Scan(&deletedAt, &last4, &fingerprint, &expMonth, &expYear, &squareCardID) if err != nil { t.Fatalf("failed to query saved card after anonymization: %v", err) } if deletedAt == nil { t.Error("expected deleted_at to be set after anonymization") } if last4 != "XXXX" { t.Errorf("expected last_4 to be 'XXXX', got %q", last4) } if fingerprint != nil { t.Errorf("expected fingerprint to be NULL, got %v", fingerprint) } if squareCardID != nil { t.Errorf("expected square_card_id to be NULL after anonymization (external-system reference), got %v", squareCardID) } if expMonth != 1 { t.Errorf("expected exp_month to be 1, got %d", expMonth) } if expYear != 2000 { t.Errorf("expected exp_year to be 2000, got %d", expYear) } } func TestDeleteGuestUser_ScrubsSavedCards(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestGuestUser(tx) if err != nil { t.Fatalf("failed to create test guest user: %v", err) } var cardID string err = tx.QueryRow(ctx, ` INSERT INTO user_saved_cards (user_id, square_card_id, square_customer_id, brand, last_4, exp_month, exp_year, fingerprint, is_default) VALUES ($1, 'sq_card_guest', 'sq_customer_guest', 'Visa', '4242', 12, 2030, 'fp_guest123', true) RETURNING id `, userID).Scan(&cardID) if err != nil { t.Fatalf("failed to insert saved card: %v", err) } _, err = tx.Exec(ctx, `SELECT delete_guest_user($1)`, userID) if err != nil { t.Fatalf("delete_guest_user failed: %v", err) } // The guest user row is fully deleted. var userCount int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE id = $1`, userID).Scan(&userCount); err != nil { t.Fatalf("failed to count users: %v", err) } if userCount != 0 { t.Errorf("expected guest user to be deleted, found %d rows", userCount) } // The saved card is soft-deleted and scrubbed. delete_guest_user UNLINKS // the card (user_id = NULL), so query by the card id captured at insert. var deletedAt, fingerprint, squareCardID, squareCustomerID interface{} var last4 string var userIDCol interface{} err = tx.QueryRow(ctx, ` SELECT user_id, deleted_at, last_4, fingerprint, square_card_id, square_customer_id FROM user_saved_cards WHERE id = $1 `, cardID).Scan(&userIDCol, &deletedAt, &last4, &fingerprint, &squareCardID, &squareCustomerID) if err != nil { t.Fatalf("failed to query saved card after deletion: %v", err) } if userIDCol != nil { t.Errorf("expected user_id to be NULL (card unlinked), got %v", userIDCol) } if deletedAt == nil { t.Error("expected deleted_at to be set after delete_guest_user") } if last4 != "XXXX" { t.Errorf("expected last_4 to be 'XXXX', got %q", last4) } if fingerprint != nil { t.Errorf("expected fingerprint to be NULL, got %v", fingerprint) } if squareCardID != nil { t.Errorf("expected square_card_id to be NULL after delete_guest_user (external-system reference), got %v", squareCardID) } if squareCustomerID != nil { t.Errorf("expected square_customer_id to be NULL after delete_guest_user, got %v", squareCustomerID) } } func TestAnonymizeUser_ExpiresVerificationCodes(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO verification_codes (user_id, purpose, code, used_at, expires_at) VALUES ($1, 'email_verify', 'CODE1', NOW(), NOW() + INTERVAL '1 hour'), ($1, 'password_reset', 'CODE2', NULL, NOW() + INTERVAL '1 hour') `, userID) if err != nil { t.Fatalf("failed to insert verification codes: %v", err) } _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) if err != nil { t.Fatalf("anonymize_user failed: %v", err) } var pendingCount int err = tx.QueryRow(ctx, ` SELECT COUNT(*) FROM verification_codes WHERE user_id = $1 AND used_at IS NULL `, userID).Scan(&pendingCount) if err != nil { t.Fatalf("failed to count pending verification codes: %v", err) } if pendingCount != 0 { t.Errorf("expected 0 pending verification codes after anonymization, got %d", pendingCount) } } func TestAnonymizeUser_ScrubsTimeBlockerReservations(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES (NOW(), 60, 'RESERVATION:user:slot123', $1), (NOW() + INTERVAL '1 hour', 30, 'RESERVATION:user:slot456', $1), (NOW() + INTERVAL '2 hours', 45, 'Admin lunch break', $1) `, userID) if err != nil { t.Fatalf("failed to insert time blockers: %v", err) } _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) if err != nil { t.Fatalf("anonymize_user failed: %v", err) } var reservationCount int err = tx.QueryRow(ctx, ` SELECT COUNT(*) FROM time_blockers WHERE created_by = $1 AND description LIKE 'RESERVATION:user:%%' `, userID).Scan(&reservationCount) if err != nil { t.Fatalf("failed to count reservation time blockers: %v", err) } if reservationCount != 0 { t.Errorf("expected 0 RESERVATION time blockers after anonymization, got %d", reservationCount) } var adminBreakDesc string err = tx.QueryRow(ctx, ` SELECT description FROM time_blockers WHERE created_by = $1 AND description = 'Admin lunch break' `, userID).Scan(&adminBreakDesc) if err != nil { t.Errorf("expected non-RESERVATION time blocker to be preserved, got error: %v", err) } if adminBreakDesc != "Admin lunch break" { t.Errorf("expected 'Admin lunch break' to be preserved, got %q", adminBreakDesc) } } // TestAnonymizeUser_RetainsEditRequestNotes verifies that booking edit request // notes survive GDPR erasure: the notes field is a single free-text medical/ // safety record (colour/preference/lateness AND allergy/access/disability // content) retained de-identified for reasonable adjustments (Equality Act // 2010) and legal-claims defence, so the edit request row must survive and its // notes must be kept verbatim. func TestAnonymizeUser_RetainsEditRequestNotes(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, notes) VALUES ($1, $2, NOW() + INTERVAL '1 day', 'Please move my appointment, I have a conflict') `, bookingID, userID) if err != nil { t.Fatalf("failed to insert edit request: %v", err) } _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) if err != nil { t.Fatalf("anonymize_user failed: %v", err) } // The edit request row must survive erasure (the SQL no longer deletes // notes-only edit requests) — it is retained as a de-identified record. var rowCount int err = tx.QueryRow(ctx, ` SELECT COUNT(*) FROM booking_edit_requests WHERE requested_by = $1 `, userID).Scan(&rowCount) if err != nil { t.Fatalf("failed to count edit requests: %v", err) } if rowCount != 1 { t.Errorf("expected edit request row to be retained after anonymization, found %d rows", rowCount) } // The notes are retained verbatim as a de-identified medical/safety record. var notes string err = tx.QueryRow(ctx, ` SELECT notes FROM booking_edit_requests WHERE requested_by = $1 `, userID).Scan(¬es) if err != nil { t.Fatalf("failed to query edit request notes: %v", err) } if notes != "Please move my appointment, I have a conflict" { t.Errorf("expected edit request notes to be retained after anonymization, got %q", notes) } } func TestAnonymizeUser_ClearsNotificationPrefs(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO user_notification_preferences (user_id, email_enabled, sms_enabled, browser_push_enabled) VALUES ($1, true, false, true) `, userID) if err != nil { t.Fatalf("failed to insert notification preferences: %v", err) } _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) if err != nil { t.Fatalf("anonymize_user failed: %v", err) } var count int err = tx.QueryRow(ctx, ` SELECT COUNT(*) FROM user_notification_preferences WHERE user_id = $1 `, userID).Scan(&count) if err != nil { t.Fatalf("failed to count notification preferences: %v", err) } if count != 0 { t.Errorf("expected 0 notification preferences after anonymization, got %d", count) } } // TestAnonymizeUser_Scrubs2FA_RetainsNotes verifies the GDPR erasure gap // closure: anonymize_user() itself clears the 2FA columns on the row, so every // call site (user-initiated delete AND idle-account batch cleanup) is covered // without a separate Go-side scrub. The notes field is a single free-text // medical/safety record (colour/preference/lateness AND allergy/access/ // disability content) and is RETAINED de-identified at erasure for reasonable // adjustments (Equality Act 2010) and legal-claims defence. func TestAnonymizeUser_Scrubs2FA_RetainsNotes(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } _, err = tx.Exec(ctx, ` UPDATE users SET notes = 'Client prefers quiet appointments and has a cat allergy', two_factor_enabled = TRUE, two_factor_method = 'email', two_factor_pending_code_hash = 'abc123', two_factor_pending_code_expires = NOW() + INTERVAL '10 minutes' WHERE id = $1 `, userID) if err != nil { t.Fatalf("failed to set user notes + 2FA: %v", err) } _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) if err != nil { t.Fatalf("anonymize_user failed: %v", err) } var notes string var enabled bool var method, pendingHash, pendingExpires interface{} err = tx.QueryRow(ctx, ` SELECT notes, two_factor_enabled, two_factor_method, two_factor_pending_code_hash, two_factor_pending_code_expires FROM users WHERE id = $1 `, userID).Scan(¬es, &enabled, &method, &pendingHash, &pendingExpires) if err != nil { t.Fatalf("failed to query user after anonymization: %v", err) } if notes != "Client prefers quiet appointments and has a cat allergy" { t.Errorf("expected users.notes to be retained after erasure, got %q", notes) } if enabled { t.Error("expected two_factor_enabled to be FALSE after erasure") } if method != nil { t.Errorf("expected two_factor_method to be NULL after erasure, got %v", method) } if pendingHash != nil { t.Errorf("expected two_factor_pending_code_hash to be NULL after erasure, got %v", pendingHash) } if pendingExpires != nil { t.Errorf("expected two_factor_pending_code_expires to be NULL after erasure, got %v", pendingExpires) } } // TestDeleteAccount_Scrubs2FA_RetainsNotes runs the full DeleteAccountHandler // for a user with 2FA enabled and staff notes, asserting the end-to-end delete // path clears the 2FA columns (via anonymize_user(), which is now the single // source of truth) while RETAINING the notes as a de-identified medical/safety // record for reasonable adjustments (Equality Act 2010) and legal-claims // defence. // Kept sequential (no t.Parallel) because the handler reads the // process-global payments.SquareClient. func TestDeleteAccount_Scrubs2FA_RetainsNotes(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } _, err = tx.Exec(ctx, ` UPDATE users SET notes = 'Staff note with PII', two_factor_enabled = TRUE, two_factor_method = 'sms', two_factor_pending_code_hash = 'deadbeef', two_factor_pending_code_expires = NOW() + INTERVAL '10 minutes' WHERE id = $1 `, userID) if err != nil { t.Fatalf("failed to set user notes + 2FA: %v", err) } req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) rr := httptest.NewRecorder() DeleteAccountHandler(rr, req) if rr.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d. body: %s", rr.Code, rr.Body.String()) } var notes string var enabled bool var method, pendingHash, pendingExpires interface{} err = tx.QueryRow(ctx, ` SELECT notes, two_factor_enabled, two_factor_method, two_factor_pending_code_hash, two_factor_pending_code_expires FROM users WHERE id = $1 `, userID).Scan(¬es, &enabled, &method, &pendingHash, &pendingExpires) if err != nil { t.Fatalf("failed to query user after deletion: %v", err) } if notes != "Staff note with PII" { t.Errorf("expected users.notes to be retained after deletion, got %q", notes) } if enabled { t.Error("expected two_factor_enabled to be FALSE after deletion") } if method != nil { t.Errorf("expected two_factor_method to be NULL after deletion, got %v", method) } if pendingHash != nil { t.Errorf("expected two_factor_pending_code_hash to be NULL after deletion, got %v", pendingHash) } if pendingExpires != nil { t.Errorf("expected two_factor_pending_code_expires to be NULL after deletion, got %v", pendingExpires) } } func TestAnonymizeUser_ScrubsNameHistory(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO name_history (user_id, previous_first_name, previous_last_name) VALUES ($1, 'Old', 'Name') `, userID) if err != nil { t.Fatalf("failed to insert name history: %v", err) } _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) if err != nil { t.Fatalf("anonymize_user failed: %v", err) } var firstName, lastName string err = tx.QueryRow(ctx, ` SELECT previous_first_name, previous_last_name FROM name_history WHERE user_id = $1 `, userID).Scan(&firstName, &lastName) if err != nil { t.Fatalf("failed to query name history: %v", err) } if firstName != "Deleted" { t.Errorf("expected previous_first_name 'Deleted', got %q", firstName) } if lastName != "User" { t.Errorf("expected previous_last_name 'User', got %q", lastName) } } // TestAnonymizeUser_RetainsBookingNotes verifies that booking notes survive // GDPR erasure: the notes field is a single free-text medical/safety record // (colour/preference/lateness AND allergy/access/disability content) retained // de-identified at erasure for reasonable adjustments (Equality Act 2010) and // legal-claims defence (e.g. allergy mistreatment). func TestAnonymizeUser_RetainsBookingNotes(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, ` UPDATE bookings SET notes = 'Please call me on the day, doorbell broken' WHERE id = $1 `, bookingID) if err != nil { t.Fatalf("failed to set booking notes: %v", err) } _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) if err != nil { t.Fatalf("anonymize_user failed: %v", err) } var notes string err = tx.QueryRow(ctx, `SELECT notes FROM bookings WHERE id = $1`, bookingID).Scan(¬es) if err != nil { t.Fatalf("failed to query booking notes: %v", err) } if notes != "Please call me on the day, doorbell broken" { t.Errorf("expected booking notes to be retained after anonymization, got %q", notes) } } func TestAnonymizeUser_DoesNotAffectGuests(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestGuestUser(tx) if err != nil { t.Fatalf("failed to create guest user: %v", err) } var firstName string err = tx.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, userID).Scan(&firstName) if err != nil { t.Fatalf("failed to query guest user before anonymization: %v", err) } _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) if err != nil { t.Fatalf("anonymize_user failed: %v", err) } var firstNameAfter string err = tx.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, userID).Scan(&firstNameAfter) if err != nil { t.Fatalf("failed to query guest user after anonymization: %v", err) } if firstNameAfter != firstName { t.Errorf("expected guest user first name to remain %q, got %q", firstName, firstNameAfter) } } // TestAnonymizeUser_PreservesFinancialRows pins the UK financial 7-year // retention: GDPR erasure must scrub user-linked PII but leave financial rows // untouched. anonymize_user() only NULLs the Square request snapshot (which // embeds BuyerEmail PII); the payments, gift_cards and gift_card_transactions // rows themselves — amount, created_by, square_payment_id, // total_funds_added — must survive erasure byte-for-byte. func TestAnonymizeUser_PreservesFinancialRows(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } // created_by + Square refs: the financial fields the 7-year retention keeps. _, err = tx.Exec(ctx, ` UPDATE payments SET created_by = $1, square_payment_id = 'sq_pay_retention_123', square_request_snapshot = '{"buyer_email":"real@example.com","amount":50.00}' WHERE id = $2 `, userID, paymentID) if err != nil { t.Fatalf("failed to set payment created_by + square_payment_id: %v", err) } // Gift card + its transaction ledger rows, both linked to the user. var giftCardID string err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_by) VALUES (100.00, 40.00, $1, $1) RETURNING id `, userID).Scan(&giftCardID) if err != nil { t.Fatalf("failed to create gift card: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, user_id) VALUES ($1, 'purchase', 100.00, $2) `, giftCardID, userID) if err != nil { t.Fatalf("failed to create gift card transaction: %v", err) } _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) if err != nil { t.Fatalf("anonymize_user failed: %v", err) } // The payment row survives with its financial fields untouched; only the // PII-bearing Square request snapshot is scrubbed. var amount float64 var createdBy, squarePaymentID, snapshot interface{} err = tx.QueryRow(ctx, ` SELECT amount, created_by, square_payment_id, square_request_snapshot FROM payments WHERE id = $1 `, paymentID).Scan(&amount, &createdBy, &squarePaymentID, &snapshot) if err != nil { t.Fatalf("failed to query payment after anonymization: %v", err) } if amount != 50.00 { t.Errorf("expected payment amount 50.00 to survive erasure, got %v", amount) } if createdBy != userID { t.Errorf("expected payment created_by %q to survive erasure, got %v", userID, createdBy) } if squarePaymentID != "sq_pay_retention_123" { t.Errorf("expected payment square_payment_id to survive erasure, got %v", squarePaymentID) } if snapshot != nil { t.Errorf("expected square_request_snapshot to be NULLed (BuyerEmail PII), got %v", snapshot) } // Gift card funds survive erasure. var totalFundsAdded float64 err = tx.QueryRow(ctx, `SELECT total_funds_added FROM gift_cards WHERE id = $1`, giftCardID).Scan(&totalFundsAdded) if err != nil { t.Fatalf("failed to query gift card after anonymization: %v", err) } if totalFundsAdded != 100.00 { t.Errorf("expected gift card total_funds_added 100.00 to survive erasure, got %v", totalFundsAdded) } // Gift card transaction ledger rows survive erasure with the user link intact. var txAmount float64 var txUserID string err = tx.QueryRow(ctx, ` SELECT amount, user_id FROM gift_card_transactions WHERE gift_card_id = $1 `, giftCardID).Scan(&txAmount, &txUserID) if err != nil { t.Fatalf("failed to query gift card transaction after anonymization: %v", err) } if txAmount != 100.00 { t.Errorf("expected gift card transaction amount 100.00 to survive erasure, got %v", txAmount) } if txUserID != userID { t.Errorf("expected gift card transaction user_id %q to survive erasure, got %v", userID, txUserID) } // ... while the user-linked PII is scrubbed. var firstName, email string err = tx.QueryRow(ctx, `SELECT n_first_name, email FROM users WHERE id = $1`, userID).Scan(&firstName, &email) if err != nil { t.Fatalf("failed to query user after anonymization: %v", err) } if firstName != "Deleted" { t.Errorf("expected n_first_name 'Deleted' after erasure, got %q", firstName) } if email != "deleted+"+userID+"@deleted.invalid" { t.Errorf("expected anonymized email, got %q", email) } } // ============================================================ // SQL export_all_user_data() Comprehensive Export Tests // ============================================================ func TestExportAllUserData_BasicProfile(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } var result json.RawMessage err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } var data map[string]interface{} if err := json.Unmarshal(result, &data); err != nil { t.Fatalf("failed to unmarshal export result: %v", err) } profile, ok := data["user_profile"].(map[string]interface{}) if !ok { t.Fatal("expected user_profile section in export") } if profile["id"] != userID { t.Errorf("expected user_profile.id to be %q, got %v", userID, profile["id"]) } if profile["first_name"] != "Test" { t.Errorf("expected user_profile.first_name to be 'Test', got %v", profile["first_name"]) } if profile["last_name"] != "User" { t.Errorf("expected user_profile.last_name to be 'User', got %v", profile["last_name"]) } } func TestExportAllUserData_BookingsWithOverrides(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, ` UPDATE booking_services SET override_price = 75.00 WHERE booking_id = $1 AND service_id = $2 `, bookingID, serviceID) if err != nil { t.Fatalf("failed to set override price: %v", err) } var result json.RawMessage err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } var data map[string]interface{} if err := json.Unmarshal(result, &data); err != nil { t.Fatalf("failed to unmarshal export result: %v", err) } bookings, ok := data["bookings"].([]interface{}) if !ok { t.Fatal("expected bookings section in export") } if len(bookings) == 0 { t.Fatal("expected at least one booking in export") } booking := bookings[0].(map[string]interface{}) if booking["booking_id"] != bookingID { t.Errorf("expected booking_id %q, got %v", bookingID, booking["booking_id"]) } totalPrice := booking["total_price"] switch v := totalPrice.(type) { case string: if v != "75.00" { t.Errorf("expected total_price '75.00' (override), got %q", v) } case float64: if v != 75.00 { t.Errorf("expected total_price 75.00 (override), got %v", v) } default: t.Errorf("unexpected total_price type %T: %v", totalPrice, totalPrice) } services := booking["services"].([]interface{}) if len(services) == 0 { t.Fatal("expected at least one service in booking") } service := services[0].(map[string]interface{}) svcPrice := service["price"] switch v := svcPrice.(type) { case string: if v != "75.00" { t.Errorf("expected service price '75.00' (override), got %v", v) } case float64: if v != 75.00 { t.Errorf("expected service price 75.00 (override), got %v", v) } default: t.Errorf("unexpected service price type %T: %v", svcPrice, svcPrice) } } func TestExportAllUserData_PaymentsAndRefunds(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed") if err != nil { t.Fatalf("failed to create payment: %v", err) } refundID, err := fixtures.CreateTestRefund(tx, paymentID, bookingID, 25.00) if err != nil { t.Fatalf("failed to create refund: %v", err) } var result json.RawMessage err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } var data map[string]interface{} if err := json.Unmarshal(result, &data); err != nil { t.Fatalf("failed to unmarshal export result: %v", err) } payments, ok := data["payments"].([]interface{}) if !ok { t.Fatal("expected payments section in export") } if len(payments) != 1 { t.Fatalf("expected 1 payment, got %d", len(payments)) } payment := payments[0].(map[string]interface{}) if payment["payment_id"] != paymentID { t.Errorf("expected payment_id %q, got %v", paymentID, payment["payment_id"]) } refunds, ok := data["refunds"].([]interface{}) if !ok { t.Fatal("expected refunds section in export") } if len(refunds) != 1 { t.Fatalf("expected 1 refund, got %d", len(refunds)) } refund := refunds[0].(map[string]interface{}) if refund["refund_id"] != refundID { t.Errorf("expected refund_id %q, got %v", refundID, refund["refund_id"]) } } func TestExportAllUserData_SavedCards(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } _, err = fixtures.CreateTestPaymentMethod(tx, userID, "sq_card_test", "Visa", "4242") if err != nil { t.Fatalf("failed to create payment method: %v", err) } var result json.RawMessage err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } var data map[string]interface{} if err := json.Unmarshal(result, &data); err != nil { t.Fatalf("failed to unmarshal export result: %v", err) } cards, ok := data["saved_cards"].([]interface{}) if !ok { t.Fatal("expected saved_cards section in export") } if len(cards) != 1 { t.Fatalf("expected 1 saved card, got %d", len(cards)) } card := cards[0].(map[string]interface{}) if card["brand"] != "Visa" { t.Errorf("expected card brand 'Visa', got %v", card["brand"]) } if card["last_4"] != "4242" { t.Errorf("expected card last_4 '4242', got %v", card["last_4"]) } } func TestExportAllUserData_EmptySectionsReturnEmptyArrays(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } var result json.RawMessage err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } var data map[string]interface{} if err := json.Unmarshal(result, &data); err != nil { t.Fatalf("failed to unmarshal export result: %v", err) } emptySections := []string{ "bookings", "payments", "patch_tests", "saved_cards", "refunds", "social_logins", "loyalty_redemptions", "booking_discounts", "edit_requests", "affiliate_payouts", "forgiven_no_shows", "gift_cards", "admin_audit_log", "gift_card_transactions", "name_history", "referral_discounts", "login_audit", "refresh_tokens", } for _, section := range emptySections { val, ok := data[section] if !ok { t.Errorf("expected %q section in export", section) continue } arr, ok := val.([]interface{}) if !ok { t.Errorf("expected %q to be an array, got %T", section, val) continue } if len(arr) != 0 { t.Errorf("expected %q to be empty array, got %d items", section, len(arr)) } } // gift_card_balance is a JSON object (not array), check separately. if gb, ok := data["gift_card_balance"]; !ok { t.Error("expected 'gift_card_balance' section in export") } else if gbMap, ok := gb.(map[string]interface{}); !ok { t.Errorf("expected 'gift_card_balance' to be an object, got %T", gb) } else if gbMap["balance"] != nil && gbMap["balance"].(float64) != 0 { t.Errorf("expected gift_card_balance to be 0, got %v", gbMap["balance"]) } } func TestExportAllUserData_ExportMetadata(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } var result json.RawMessage err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } var data map[string]interface{} if err := json.Unmarshal(result, &data); err != nil { t.Fatalf("failed to unmarshal export result: %v", err) } metadata, ok := data["export_metadata"].(map[string]interface{}) if !ok { t.Fatal("expected export_metadata section in export") } if metadata["user_id"] != userID { t.Errorf("expected export_metadata.user_id %q, got %v", userID, metadata["user_id"]) } if metadata["format_version"] != "1.1" { t.Errorf("expected format_version '1.1', got %v", metadata["format_version"]) } if metadata["exported_by"] != "system" { t.Errorf("expected exported_by 'system', got %v", metadata["exported_by"]) } if metadata["exported_at"] == nil { t.Error("expected exported_at to be set") } } func TestExportAllUserData_NotificationPreferences(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO user_notification_preferences (user_id, email_enabled, sms_enabled, browser_push_enabled) VALUES ($1, true, false, true) `, userID) if err != nil { t.Fatalf("failed to insert notification preferences: %v", err) } var result json.RawMessage err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } var data map[string]interface{} if err := json.Unmarshal(result, &data); err != nil { t.Fatalf("failed to unmarshal export result: %v", err) } prefs, ok := data["notification_preferences"].([]interface{}) if !ok { t.Fatal("expected notification_preferences section in export") } if len(prefs) != 1 { t.Fatalf("expected 1 notification preference, got %d", len(prefs)) } pref := prefs[0].(map[string]interface{}) if pref["email_enabled"] != true { t.Errorf("expected email_enabled true, got %v", pref["email_enabled"]) } if pref["sms_enabled"] != false { t.Errorf("expected sms_enabled false, got %v", pref["sms_enabled"]) } if pref["browser_push_enabled"] != true { t.Errorf("expected browser_push_enabled true, got %v", pref["browser_push_enabled"]) } } func TestExportAllUserData_VerificationCodes(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO verification_codes (user_id, purpose, code, used_at, expires_at) VALUES ($1, 'email_verify', 'CODE1', NOW(), NOW() + INTERVAL '1 hour'), ($1, 'password_reset', 'CODE2', NULL, NOW() + INTERVAL '1 hour') `, userID) if err != nil { t.Fatalf("failed to insert verification codes: %v", err) } var result json.RawMessage err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } var data map[string]interface{} if err := json.Unmarshal(result, &data); err != nil { t.Fatalf("failed to unmarshal export result: %v", err) } // Verification codes are authentication tokens, not personal data, // so they SHOULD be excluded from the SAR export (GDPR Art 15). _, exists := data["verification_codes"] if exists { t.Fatal("verification_codes should not be included in GDPR export (authentication tokens)") } } func TestExportAllUserData_EditRequests(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, notes, has_overrides) VALUES ($1, $2, NOW() + INTERVAL '1 day', 'Please reschedule', false) `, bookingID, userID) if err != nil { t.Fatalf("failed to insert edit request: %v", err) } var result json.RawMessage err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } var data map[string]interface{} if err := json.Unmarshal(result, &data); err != nil { t.Fatalf("failed to unmarshal export result: %v", err) } editRequests, ok := data["edit_requests"].([]interface{}) if !ok { t.Fatal("expected edit_requests section in export") } if len(editRequests) != 1 { t.Fatalf("expected 1 edit request, got %d", len(editRequests)) } er := editRequests[0].(map[string]interface{}) if er["notes"] != "Please reschedule" { t.Errorf("expected edit request notes 'Please reschedule', got %v", er["notes"]) } } func TestExportAllUserData_ForgivenNoShows(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO forgiven_no_shows (booking_id, created_at) VALUES ($1, NOW()) `, bookingID) if err != nil { t.Fatalf("failed to insert forgiven no-show: %v", err) } var result json.RawMessage err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result) if err != nil { t.Fatalf("export_all_user_data failed: %v", err) } var data map[string]interface{} if err := json.Unmarshal(result, &data); err != nil { t.Fatalf("failed to unmarshal export result: %v", err) } noShows, ok := data["forgiven_no_shows"].([]interface{}) if !ok { t.Fatal("expected forgiven_no_shows section in export") } if len(noShows) != 1 { t.Fatalf("expected 1 forgiven no-show, got %d", len(noShows)) } ns := noShows[0].(map[string]interface{}) if ns["booking_id"] != bookingID { t.Errorf("expected forgiven_no_show booking_id %q, got %v", bookingID, ns["booking_id"]) } } // ============================================================ // AnonymizeStaleGuestAccounts Tests (SQL behavior) // ============================================================ func TestAnonymizeStaleGuestAccounts_ScrubsAdditionalFields(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) guestID, err := fixtures.CreateTestGuestUser(tx) if err != nil { t.Fatalf("failed to create guest user: %v", err) } _, err = tx.Exec(ctx, ` UPDATE users SET profile_pic_url = 'https://example.com/pic.jpg', referral_code = 'ABCDEF123456', notes = 'Some personal notes about this guest', data_retention_consent = TRUE WHERE id = $1 `, guestID) if err != nil { t.Fatalf("failed to update guest fields: %v", err) } staleTime := clock.Now().Add(-7 * 30 * 24 * time.Hour) _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') `, guestID, staleTime) if err != nil { t.Fatalf("failed to create stale booking: %v", err) } _, err = tx.Exec(ctx, ` UPDATE users SET n_first_name = 'Guest', n_last_name = 'Anonymized', email = 'anon-' || id || '@anon.invalid', phone = '000000000000', date_of_birth = '1900-01-01', profile_pic_url = NULL, referral_code = NULL, notes = NULL, data_retention_consent = FALSE, updated_at = NOW() WHERE account_role = 'guest' AND id NOT IN ( SELECT user_id FROM bookings WHERE status IN ('pending', 'confirmed') ) AND id IN ( SELECT user_id FROM bookings WHERE user_id IS NOT NULL GROUP BY user_id HAVING MAX(start_time) < NOW() - INTERVAL '6 months' ) `) if err != nil { t.Fatalf("stale guest anonymization SQL failed: %v", err) } var profilePicURL, referralCode, notes interface{} var dataRetentionConsent bool err = tx.QueryRow(ctx, ` SELECT profile_pic_url, referral_code, notes, data_retention_consent FROM users WHERE id = $1 `, guestID).Scan(&profilePicURL, &referralCode, ¬es, &dataRetentionConsent) if err != nil { t.Fatalf("failed to query guest user after anonymization: %v", err) } if profilePicURL != nil { t.Errorf("expected profile_pic_url to be NULL, got %v", profilePicURL) } if referralCode != nil { t.Errorf("expected referral_code to be NULL, got %v", referralCode) } if notes != nil { t.Errorf("expected notes to be NULL, got %v", notes) } if dataRetentionConsent != false { t.Errorf("expected data_retention_consent to be FALSE, got %v", dataRetentionConsent) } } func TestAnonymizeStaleGuestAccounts_DoesNotAffectActiveGuests(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) guestID, err := fixtures.CreateTestGuestUser(tx) if err != nil { t.Fatalf("failed to create guest user: %v", err) } _, err = tx.Exec(ctx, ` UPDATE users SET profile_pic_url = 'https://example.com/pic.jpg', referral_code = 'ABCDEF123456', data_retention_consent = TRUE WHERE id = $1 `, guestID) if err != nil { t.Fatalf("failed to update guest fields: %v", err) } recentTime := clock.Now().Add(24 * time.Hour) _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'pending') `, guestID, recentTime) if err != nil { t.Fatalf("failed to create recent booking: %v", err) } _, err = tx.Exec(ctx, ` UPDATE users SET n_first_name = 'Guest', n_last_name = 'Anonymized', email = 'anon-' || id || '@anon.invalid', phone = '000000000000', date_of_birth = '1900-01-01', profile_pic_url = NULL, referral_code = NULL, notes = NULL, data_retention_consent = FALSE, updated_at = NOW() WHERE account_role = 'guest' AND id NOT IN ( SELECT user_id FROM bookings WHERE status IN ('pending', 'confirmed') ) AND id IN ( SELECT user_id FROM bookings WHERE user_id IS NOT NULL GROUP BY user_id HAVING MAX(start_time) < NOW() - INTERVAL '6 months' ) `) if err != nil { t.Fatalf("stale guest anonymization SQL failed: %v", err) } var profilePicURL interface{} var referralCode string var dataRetentionConsent bool err = tx.QueryRow(ctx, ` SELECT profile_pic_url, referral_code, data_retention_consent FROM users WHERE id = $1 `, guestID).Scan(&profilePicURL, &referralCode, &dataRetentionConsent) if err != nil { t.Fatalf("failed to query guest user: %v", err) } if profilePicURL == nil { t.Error("expected profile_pic_url to be preserved for active guest") } if referralCode != "ABCDEF123456" { t.Errorf("expected referral_code to be preserved, got %v", referralCode) } if dataRetentionConsent != true { t.Errorf("expected data_retention_consent to be TRUE, got %v", dataRetentionConsent) } } func TestAnonymizeStaleGuestAccounts_DoesNotAffectRegisteredUsers(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create test user: %v", err) } _, err = tx.Exec(ctx, ` UPDATE users SET profile_pic_url = 'https://example.com/pic.jpg', referral_code = 'ABCDEF123456', data_retention_consent = TRUE WHERE id = $1 `, userID) if err != nil { t.Fatalf("failed to update user fields: %v", err) } _, err = tx.Exec(ctx, ` UPDATE users SET n_first_name = 'Guest', n_last_name = 'Anonymized', email = 'anon-' || id || '@anon.invalid', phone = '000000000000', date_of_birth = '1900-01-01', profile_pic_url = NULL, referral_code = NULL, notes = NULL, data_retention_consent = FALSE, updated_at = NOW() WHERE account_role = 'guest' AND id NOT IN ( SELECT user_id FROM bookings WHERE status IN ('pending', 'confirmed') ) AND id IN ( SELECT user_id FROM bookings WHERE user_id IS NOT NULL GROUP BY user_id HAVING MAX(start_time) < NOW() - INTERVAL '6 months' ) `) if err != nil { t.Fatalf("stale guest anonymization SQL failed: %v", err) } var profilePicURL interface{} var referralCode string err = tx.QueryRow(ctx, ` SELECT profile_pic_url, referral_code FROM users WHERE id = $1 `, userID).Scan(&profilePicURL, &referralCode) if err != nil { t.Fatalf("failed to query user: %v", err) } if profilePicURL == nil { t.Error("expected profile_pic_url to be preserved for registered user") } if referralCode != "ABCDEF123456" { t.Errorf("expected referral_code to be preserved, got %v", referralCode) } } // ============================================================ // CleanupGDPRExportCache Tests // ============================================================ func TestCleanupGDPRExportCache_Empty(t *testing.T) { t.Parallel() gdprExportCacheMu.Lock() gdprExportCache = make(map[string]*gdprCacheEntry) gdprExportCacheMu.Unlock() _, err := CleanupGDPRExportCache(context.Background()) if err != nil { t.Errorf("expected nil error, got %v", err) } } func TestCleanupGDPRExportCache_RemovesExpired(t *testing.T) { t.Parallel() gdprExportCacheMu.Lock() gdprExportCache = map[string]*gdprCacheEntry{ "user-expired": {expiresAt: clock.Now().Add(-1 * time.Hour)}, } gdprExportCacheMu.Unlock() _, err := CleanupGDPRExportCache(context.Background()) if err != nil { t.Errorf("expected nil error, got %v", err) } gdprExportCacheMu.RLock() _, exists := gdprExportCache["user-expired"] gdprExportCacheMu.RUnlock() if exists { t.Error("expected expired entry to be removed") } } func TestCleanupGDPRExportCache_PreservesValid(t *testing.T) { t.Parallel() gdprExportCacheMu.Lock() gdprExportCache = map[string]*gdprCacheEntry{ "user-valid": {expiresAt: clock.Now().Add(1 * time.Hour)}, } gdprExportCacheMu.Unlock() _, err := CleanupGDPRExportCache(context.Background()) if err != nil { t.Errorf("expected nil error, got %v", err) } gdprExportCacheMu.RLock() entry, exists := gdprExportCache["user-valid"] gdprExportCacheMu.RUnlock() if !exists { t.Fatal("expected valid entry to be preserved") } if entry == nil { t.Error("expected non-nil entry") } } func TestCleanupGDPRExportCache_Mixed(t *testing.T) { t.Parallel() gdprExportCacheMu.Lock() gdprExportCache = map[string]*gdprCacheEntry{ "user-expired": {expiresAt: clock.Now().Add(-2 * time.Hour)}, "user-valid": {expiresAt: clock.Now().Add(2 * time.Hour)}, "user-expired2": {expiresAt: clock.Now().Add(-30 * time.Minute)}, } gdprExportCacheMu.Unlock() _, err := CleanupGDPRExportCache(context.Background()) if err != nil { t.Errorf("expected nil error, got %v", err) } gdprExportCacheMu.RLock() _, expiredExists := gdprExportCache["user-expired"] _, validExists := gdprExportCache["user-valid"] _, expired2Exists := gdprExportCache["user-expired2"] gdprExportCacheMu.RUnlock() if expiredExists { t.Error("expected user-expired to be removed") } if expired2Exists { t.Error("expected user-expired2 to be removed") } if !validExists { t.Error("expected user-valid to be preserved") } }