//go:build test && dev package user // Package user contains coverage-improving tests for user profile, // account management, guest creation, and admin handlers. // // These tests focus on error paths and edge cases not covered by the // existing test suite, such as unauthorized access, not-found scenarios, // invalid JSON bodies, and duplicate email conflicts. // // NOTE: Do NOT use t.Parallel() in this file. import ( "bytes" "context" "database/sql" "encoding/json" "fmt" "log" "net/http" "net/http/httptest" "os" "strings" "sync" "testing" "time" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "crussell/db" "crussell/handlers/payments" "crussell/internal/square" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" ) // ============================================================================= // DeleteAccountHandler Coverage Tests // ============================================================================= // TestDeleteAccount_Unauthorized verifies that deleting an account without // setting user ID in context returns 401 Unauthorized. func TestDeleteAccount_Unauthorized(t *testing.T) { req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) rr := httptest.NewRecorder() DeleteAccountHandler(rr, req) if rr.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d. body: %s", rr.Code, rr.Body.String()) } } // TestDeleteAccount_NotFound verifies that deleting a non-existent user // (valid userID format in context but no matching DB row) returns 404. func TestDeleteAccount_NotFound(t *testing.T) { ctx, _ := testutils.SetupTestTx(t) req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, "nonexistent123")) rr := httptest.NewRecorder() DeleteAccountHandler(rr, req) if rr.Code != http.StatusNotFound { t.Errorf("expected 404, got %d. body: %s", rr.Code, rr.Body.String()) } } // TestDeleteAccount_WithBooking verifies that deleting a registered user // account with existing bookings succeeds (anonymize_user handles FK). func TestDeleteAccount_WithBooking(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) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } _, err = fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %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.Errorf("expected 204, got %d. body: %s", rr.Code, rr.Body.String()) } // Verify user was anonymized var firstName, accountRole string err = tx.QueryRow(ctx, `SELECT n_first_name, account_role FROM users WHERE id = $1`, userID).Scan(&firstName, &accountRole) if err != nil { t.Fatalf("failed to query anonymized user: %v", err) } if firstName != "Deleted" { t.Errorf("expected first name 'Deleted', got %q", firstName) } if accountRole != "guest" { t.Errorf("expected account_role 'guest', got %q", accountRole) } } // TestDeleteAccount_GuestWithBooking verifies that deleting a guest user // with existing bookings succeeds (delete_guest_user handles FK). func TestDeleteAccount_GuestWithBooking(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestGuestUser(tx) if err != nil { t.Fatalf("failed to create guest user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } _, err = fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { t.Fatalf("failed to create booking: %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.Errorf("expected 204, got %d. body: %s", rr.Code, rr.Body.String()) } // Verify guest user was fully deleted var count int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE id = $1`, userID).Scan(&count) if err != nil { t.Fatalf("failed to query user count: %v", err) } if count != 0 { t.Errorf("expected user to be deleted, found %d rows", count) } } // ============================================================================= // ChangePasswordHandler Coverage Tests // ============================================================================= // TestPasswordChange_Unauthorized verifies that changing password without // user ID in context returns 401. func TestPasswordChange_Unauthorized(t *testing.T) { changeReq := ChangePasswordRequest{ CurrentPassword: "testpassword123", NewPassword: "newpassword456", } body, _ := json.Marshal(changeReq) req := httptest.NewRequest(http.MethodPut, "/api/user/change-password", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() ChangePasswordHandler(rr, req) if rr.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d. body: %s", rr.Code, rr.Body.String()) } } // TestPasswordChange_InvalidJSON verifies that sending an invalid JSON body // returns 400 Bad Request. func TestPasswordChange_InvalidJSON(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) } req := httptest.NewRequest(http.MethodPut, "/api/user/change-password", bytes.NewReader([]byte("not valid json"))) req.Header.Set("Content-Type", "application/json") req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) rr := httptest.NewRecorder() ChangePasswordHandler(rr, req) if rr.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d. body: %s", rr.Code, rr.Body.String()) } } // TestPasswordChange_UserNotFound verifies that changing password for a // non-existent user ID returns 404. func TestPasswordChange_UserNotFound(t *testing.T) { ctx, _ := testutils.SetupTestTx(t) changeReq := ChangePasswordRequest{ CurrentPassword: "testpassword123", NewPassword: "newpassword456", } body, _ := json.Marshal(changeReq) req := httptest.NewRequest(http.MethodPut, "/api/user/change-password", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, "nonexistent123")) rr := httptest.NewRecorder() ChangePasswordHandler(rr, req) if rr.Code != http.StatusNotFound { t.Errorf("expected 404, got %d. body: %s", rr.Code, rr.Body.String()) } } // ============================================================================= // Notification Preferences Coverage Tests // ============================================================================= // TestNotificationPreferences_Get_Unauthorized verifies that getting // notification preferences without auth returns 401. func TestNotificationPreferences_Get_Unauthorized(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/user/notification-preferences", nil) rr := httptest.NewRecorder() GetNotificationPreferencesHandler(rr, req) if rr.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d", rr.Code) } } // TestNotificationPreferences_Update_Unauthorized verifies that updating // notification preferences without auth returns 401. func TestNotificationPreferences_Update_Unauthorized(t *testing.T) { body, _ := json.Marshal(UpdateNotificationPreferencesRequest{}) req := httptest.NewRequest(http.MethodPut, "/api/user/notification-preferences", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() UpdateNotificationPreferencesHandler(rr, req) if rr.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d. body: %s", rr.Code, rr.Body.String()) } } // TestNotificationPreferences_Update_InvalidJSON verifies that sending // invalid JSON to update notification preferences returns 400. func TestNotificationPreferences_Update_InvalidJSON(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) } req := httptest.NewRequest(http.MethodPut, "/api/user/notification-preferences", bytes.NewReader([]byte("{"))) req.Header.Set("Content-Type", "application/json") req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) rr := httptest.NewRecorder() UpdateNotificationPreferencesHandler(rr, req) if rr.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d. body: %s", rr.Code, rr.Body.String()) } } // ============================================================================= // UpdateProfileHandler Coverage Tests // ============================================================================= // TestProfile_Update_InvalidJSON verifies that sending an invalid JSON body // to update profile returns 400. func TestProfile_Update_InvalidJSON(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) } req := httptest.NewRequest(http.MethodPut, "/api/user/profile", bytes.NewReader([]byte("not json"))) req.Header.Set("Content-Type", "application/json") req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) rr := httptest.NewRecorder() UpdateProfileHandler(rr, req) if rr.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d. body: %s", rr.Code, rr.Body.String()) } } // ============================================================================= // Guest User Coverage Tests // ============================================================================= // TestGuestUser_Create_DuplicateEmail verifies that creating a guest user // with an email already registered by a non-guest user returns 409 Conflict. func TestGuestUser_Create_DuplicateEmail(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) // Create a registered (non-guest) user with a known email _, err := fixtures.CreateTestUserWithEmail(tx, "registered.dup@example.com", "verified_email") if err != nil { t.Fatalf("failed to create registered user: %v", err) } // Try to create a guest with the same email reqBody := CreateGuestUserRequest{ FirstName: "Guest", LastName: "User", Email: "registered.dup@example.com", Phone: "07123456789", } body, _ := json.Marshal(reqBody) req := httptest.NewRequest(http.MethodPost, "/api/user/guest", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req = req.WithContext(ctx) rr := httptest.NewRecorder() CreateGuestUserHandler(rr, req) if rr.Code != http.StatusConflict { t.Errorf("expected 409, got %d. body: %s", rr.Code, rr.Body.String()) } } // TestGuestUser_Create_InvalidJSON verifies that sending invalid JSON // returns 400 Bad Request. func TestGuestUser_Create_InvalidJSON(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/user/guest", bytes.NewReader([]byte("{"))) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() CreateGuestUserHandler(rr, req) if rr.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d. body: %s", rr.Code, rr.Body.String()) } } // TestGuestUser_Create_InvalidNameCharacters verifies that invalid characters // in name (e.g., numbers in first name) return 400. func TestGuestUser_Create_InvalidNameCharacters(t *testing.T) { reqBody := CreateGuestUserRequest{ FirstName: "John123", LastName: "Doe", Email: "john.doe@example.com", Phone: "07123456789", } body, _ := json.Marshal(reqBody) req := httptest.NewRequest(http.MethodPost, "/api/user/guest", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() CreateGuestUserHandler(rr, req) if rr.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d. body: %s", rr.Code, rr.Body.String()) } } // ============================================================================= // Admin Handler Coverage Tests // ============================================================================= // TestGetEligiblePatchTestServices_InvalidUserID verifies that an invalid // user ID format returns 404. func TestGetEligiblePatchTestServices_InvalidUserID(t *testing.T) { ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(GetEligiblePatchTestServicesHandler) w := makeAdminHandlerRequest(handler, "GET", "/api/admin/users/invalid/patch-tests/eligible", nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String()) } } // TestGetUserPatchTests_NoMatchUser verifies that requesting patch tests for // a valid-format but non-existent user returns an empty list (200 OK). func TestGetUserPatchTests_NoMatchUser(t *testing.T) { ctx, _ := testutils.SetupTestTx(t) // Use a valid 12-char hex ID that doesn't exist in the DB w := makePatchTestsRequest(GetUserPatchTestsHandler, "GET", "/api/admin/users/aaaa00000000/patch-tests", nil, "admin001", "admin", ctx) if w.Code != http.StatusOK { t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String()) return } var tests []UserPatchTest if err := json.Unmarshal(w.Body.Bytes(), &tests); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } } // TestGetEligiblePatchTestServices_NoMatchUser verifies that eligible patch // tests for a valid-format but non-existent user returns an empty list. func TestGetEligiblePatchTestServices_NoMatchUser(t *testing.T) { ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(GetEligiblePatchTestServicesHandler) w := makeAdminHandlerRequest(handler, "GET", "/api/admin/users/bbbb00000000/patch-tests/eligible", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String()) return } var services []ServiceForPatchTest if err := json.Unmarshal(w.Body.Bytes(), &services); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } } // TestAddPatchTest_InvalidUserID verifies that adding a patch test with an // invalid user ID returns 404. func TestAddPatchTest_InvalidUserID(t *testing.T) { ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(AddPatchTestHandler) reqBody := AddPatchTestRequest{PatchTestID: "testid1234567"} w := makeAdminHandlerRequest(handler, "POST", "/api/admin/users/invalid/patch-tests", reqBody, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String()) } } // TestAddPatchTest_EmptyPatchTestID verifies that adding a patch test with // an empty patch_test_id returns 400. func TestAddPatchTest_EmptyPatchTestID(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } handler := http.HandlerFunc(AddPatchTestHandler) reqBody := AddPatchTestRequest{PatchTestID: ""} w := makeAdminHandlerRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody, ctx) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d. body: %s", w.Code, w.Body.String()) } } func TestAddPatchTest_InvalidJSON(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } handler := http.HandlerFunc(AddPatchTestHandler) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", userID) chiCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx) chiCtx = context.WithValue(chiCtx, mw.UserIDKey, "admin-test-id") chiCtx = context.WithValue(chiCtx, mw.UserRoleKey, "admin") req := httptest.NewRequest(http.MethodPost, "/api/admin/users/"+userID+"/patch-tests", bytes.NewReader([]byte("not json"))) req.Header.Set("Content-Type", "application/json") req = req.WithContext(chiCtx) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d. body: %s", rr.Code, rr.Body.String()) } } // TestListAdminUsers_Search verifies that the list admin users handler // works with a search query parameter. func TestListAdminUsers_Search(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) // Create a user with a distinct name _, err := tx.Exec(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Searchable', 'User', 'searchable@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email') `) if err != nil { t.Fatalf("failed to create searchable user: %v", err) } handler := http.HandlerFunc(ListAdminUsersHandler) w := makeAdminHandlerRequest(handler, "GET", "/api/admin/users?q=Searchable", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } var response UserListResponse if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if response.Total < 1 { t.Errorf("expected at least 1 user in search results, got %d", response.Total) } } // TestListAdminUsers_SearchWithCursor verifies that the list admin users // handler works with search + cursor pagination. func TestListAdminUsers_SearchWithCursor(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) // Create some users so cursor pagination has data for i := 0; i < 3; i++ { _, err := tx.Exec(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Cursor', $1, $2, '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email') `, "TestUser_"+string(rune('A'+i)), "cursor.user."+string(rune('A'+i))+"@test.com") if err != nil { t.Fatalf("failed to create cursor test user: %v", err) } } // First request without cursor handler := http.HandlerFunc(ListAdminUsersHandler) w := makeAdminHandlerRequest(handler, "GET", "/api/admin/users?q=Cursor&per_page=2", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } var firstPage UserListResponse if err := json.Unmarshal(w.Body.Bytes(), &firstPage); err != nil { t.Fatalf("failed to unmarshal first page: %v", err) } // If we have a next cursor, use it if firstPage.NextCursor != nil { w2 := makeAdminHandlerRequest(handler, "GET", "/api/admin/users?q=Cursor&per_page=2&cursor="+*firstPage.NextCursor, nil, ctx) if w2.Code != http.StatusOK { t.Errorf("expected 200 for cursor page, got %d. body: %s", w2.Code, w2.Body.String()) } } } // ============================================================================= // DeleteAccountHandler — S3 goroutine coverage // ============================================================================= // TestDeleteAccount_WithProfilePicture verifies that the S3 profile picture // deletion goroutine is triggered when profile_pic_url is set. func TestDeleteAccount_WithProfilePicture(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) // Set profile_pic_url on the user to trigger S3 deletion goroutine _, err = tx.Exec(ctx, `UPDATE users SET profile_pic_url = 'https://cdn.example.com/pics/old.jpg' WHERE id = $1`, userID) require.NoError(t, err) handler := http.HandlerFunc(DeleteAccountHandler) req := httptest.NewRequest("DELETE", "/api/user/account", nil) req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) w := httptest.NewRecorder() handler.ServeHTTP(w, req) // Handler returns 204 regardless of goroutine result assert.Equal(t, http.StatusNoContent, w.Code) // Best-effort wait for background goroutine to initiate time.Sleep(50 * time.Millisecond) } // ============================================================================= // DeleteAccountHandler — Square goroutine coverage // ============================================================================= // TestDeleteAccount_WithSquareClient verifies that the Square saved card // cleanup goroutine is triggered when SquareClient is set. func TestDeleteAccount_WithSquareClient(t *testing.T) { // Save and restore SquareClient savedSquareClient := payments.SquareClient payments.SquareClient = square.NewDevClient() t.Cleanup(func() { payments.SquareClient = savedSquareClient }) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) handler := http.HandlerFunc(DeleteAccountHandler) req := httptest.NewRequest("DELETE", "/api/user/account", nil) req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.Equal(t, http.StatusNoContent, w.Code) // Best-effort wait for background goroutine to initiate time.Sleep(50 * time.Millisecond) } // ============================================================================= // DeleteAccountHandler — Square ccof redaction + customer deletion // ============================================================================= // syncBuffer is a mutex-guarded log/slog writer so logs written by background // goroutines can be read safely under -race. type syncBuffer struct { mu sync.Mutex buf bytes.Buffer } func (b *syncBuffer) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.buf.Write(p) } func (b *syncBuffer) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buf.String() } // recordingSquareClient records Square erasure calls made by the account // deletion goroutine so tests can assert what was (and wasn't) called. type recordingSquareClient struct { square.SquareClient mu sync.Mutex deletedCards []string deletedCustomers []string } func (c *recordingSquareClient) DeleteCardOnFile(ctx context.Context, cardID string) error { c.mu.Lock() c.deletedCards = append(c.deletedCards, cardID) c.mu.Unlock() // Token-free error so the log-redaction test isolates redaction of the // cardID argument rather than the error string. return fmt.Errorf("square: network error disabling card at Square") } func (c *recordingSquareClient) DeleteCustomer(ctx context.Context, customerID string) error { c.mu.Lock() c.deletedCustomers = append(c.deletedCustomers, customerID) c.mu.Unlock() return nil } func (c *recordingSquareClient) cardDeletes() []string { c.mu.Lock() defer c.mu.Unlock() return append([]string(nil), c.deletedCards...) } func (c *recordingSquareClient) customerDeletes() []string { c.mu.Lock() defer c.mu.Unlock() return append([]string(nil), c.deletedCustomers...) } // TestDeleteAccount_LogsRedactCardTokens verifies the account-deletion // goroutine logs a redacted tokenPrefix form of ccof: card IDs, never the full // token (SECURITY: full ccof tokens must not reach server logs). func TestDeleteAccount_LogsRedactCardTokens(t *testing.T) { savedSquareClient := payments.SquareClient rec := &recordingSquareClient{SquareClient: square.NewDevClient()} payments.SquareClient = rec t.Cleanup(func() { payments.SquareClient = savedSquareClient }) var sb syncBuffer log.SetOutput(&sb) defer log.SetOutput(os.Stderr) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) fullToken := "ccof:secret_token_abc123" _, err = tx.Exec(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, $2, 'cus_secret_abc123', 'Visa', '4242', 12, 2030, 'fp1', true) `, userID, fullToken) require.NoError(t, err) handler := http.HandlerFunc(DeleteAccountHandler) req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) w := httptest.NewRecorder() handler.ServeHTTP(w, req) require.Equal(t, http.StatusNoContent, w.Code) require.Eventually(t, func() bool { return len(rec.cardDeletes()) > 0 }, 5*time.Second, 10*time.Millisecond, "expected the Square cleanup goroutine to attempt card deletion") // The card-deletion log is written by the async goroutine AFTER the call // returns — wait for the redacted prefix to appear instead of racing the // goroutine (the cleanup now dispatches after the local tx commits). require.Eventually(t, func() bool { return strings.Contains(sb.String(), "ccof:sec...") }, 5*time.Second, 10*time.Millisecond, "expected the redacted token prefix to reach the logs") logs := sb.String() if strings.Contains(logs, fullToken) { t.Errorf("full ccof token %q leaked into logs: %q", fullToken, logs) } } // TestDeleteAccount_DeletesSquareCustomerOnce verifies the Square customer // profile (email/name PII) is deleted exactly once even when multiple saved // cards share the same square_customer_id. func TestDeleteAccount_DeletesSquareCustomerOnce(t *testing.T) { savedSquareClient := payments.SquareClient rec := &recordingSquareClient{SquareClient: square.NewDevClient()} payments.SquareClient = rec t.Cleanup(func() { payments.SquareClient = savedSquareClient }) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) for _, cardTok := range []string{"ccof:card_one_123", "ccof:card_two_456"} { _, err := tx.Exec(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, $2, 'cus_shared_123', 'Visa', '4242', 12, 2030, 'fp1', true) `, userID, cardTok) require.NoError(t, err) } handler := http.HandlerFunc(DeleteAccountHandler) req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) w := httptest.NewRecorder() handler.ServeHTTP(w, req) require.Equal(t, http.StatusNoContent, w.Code) require.Eventually(t, func() bool { return len(rec.customerDeletes()) > 0 }, 5*time.Second, 10*time.Millisecond, "expected DeleteCustomer to be called") require.Equal(t, []string{"cus_shared_123"}, rec.customerDeletes(), "a shared Square customer must be deleted exactly once") require.ElementsMatch(t, []string{"ccof:card_one_123", "ccof:card_two_456"}, rec.cardDeletes(), "both saved cards must be disabled at Square") } // ============================================================================= // DeleteAccountHandler — Square cleanup only after local tx commit // ============================================================================= // TestDeleteAccount_SquareCleanupNotDispatchedOnTxFailure verifies the fix that // dispatches the Square cleanup goroutine only AFTER the local // anonymization/deletion transaction commits: when the local tx fails (here an // admin_notifications row references the guest with a RESTRICT FK, so // delete_guest_user's DELETE FROM users errors), DeleteCardOnFile/DeleteCustomer // must NOT be called — a failed local tx leaves external state intact for retry. func TestDeleteAccount_SquareCleanupNotDispatchedOnTxFailure(t *testing.T) { savedSquareClient := payments.SquareClient rec := &recordingSquareClient{SquareClient: square.NewDevClient()} payments.SquareClient = rec t.Cleanup(func() { payments.SquareClient = savedSquareClient }) ctx, tx := testutils.SetupTestTx(t) guestID, err := fixtures.CreateTestGuestUser(tx) require.NoError(t, err) // Saved card so the pre-tx snapshot captures a card/customer to scrub — if // the goroutine were (wrongly) dispatched before the tx, it would record a // call here. _, err = tx.Exec(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, $2, 'cus_fail_tx_123', 'Visa', '4242', 12, 2030, 'fp1', true) `, guestID, "ccof:card_fail_tx") require.NoError(t, err) // admin_notifications.user_id has a RESTRICT FK on users(id): seeding a row // makes delete_guest_user's DELETE FROM users fail, so the local tx errors. _, err = tx.Exec(ctx, ` INSERT INTO admin_notifications (reason, user_id) VALUES ('pending_booking', $1) `, guestID) require.NoError(t, err) handler := http.HandlerFunc(DeleteAccountHandler) req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, guestID)) w := httptest.NewRecorder() handler.ServeHTTP(w, req) require.Equal(t, http.StatusInternalServerError, w.Code, "the failed local tx must surface a 500") // Give a (wrongly dispatched) cleanup goroutine time to record its calls. time.Sleep(100 * time.Millisecond) require.Equal(t, 0, len(rec.cardDeletes()), "DeleteCardOnFile must NOT be called when the local tx failed") require.Equal(t, 0, len(rec.customerDeletes()), "DeleteCustomer must NOT be called when the local tx failed") } // ============================================================================= // DeleteAccountHandler — shared Square customer edge (cross-user email dedup) // ============================================================================= // TestDeleteAccount_SkipsSharedSquareCustomer verifies the fix that guards // DeleteCustomer behind a "still referenced by another account" check: Square // customers are provisioned per-user from a deterministic email-derived key, // but the UNIQUE(email) index excludes guest accounts, so a guest and a // registered user sharing an email can land on the SAME Square customer profile // (Square dedups within its idempotency window). Deleting one account must NOT // delete the shared profile — the other account's saved-card charges would // break. The other user's saved-card row must be left untouched. func TestDeleteAccount_SkipsSharedSquareCustomer(t *testing.T) { savedSquareClient := payments.SquareClient rec := &recordingSquareClient{SquareClient: square.NewDevClient()} payments.SquareClient = rec t.Cleanup(func() { payments.SquareClient = savedSquareClient }) ctx, tx := testutils.SetupTestTx(t) userA, err := fixtures.CreateTestUser(tx) require.NoError(t, err) userB, err := fixtures.CreateTestUser(tx) require.NoError(t, err) for _, tc := range []struct{ userID, cardID string }{ {userA, "ccof:card_user_a"}, {userB, "ccof:card_user_b"}, } { _, err := tx.Exec(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, $2, 'cus_shared_cross_user', 'Visa', '4242', 12, 2030, 'fp1', true) `, tc.userID, tc.cardID) require.NoError(t, err) } // Commit the setup so the cleanup goroutine's pool-level reference check // (background context, outside the per-test tx) can see user B's row. pgxTx := db.TxFromContext(ctx) require.NotNil(t, pgxTx, "no transaction in context") require.NoError(t, pgxTx.Commit(ctx)) // The committed rows live in the SHARED test pool — clean them up. t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM user_saved_cards WHERE square_customer_id = 'cus_shared_cross_user'`) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id IN ($1, $2)`, userA, userB) }) handler := http.HandlerFunc(DeleteAccountHandler) req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userA)) w := httptest.NewRecorder() handler.ServeHTTP(w, req) require.Equal(t, http.StatusNoContent, w.Code) // The goroutine runs the card loop first — wait for it to record user A's // card deletion so we know the cleanup ran, then assert the shared customer // was NOT deleted. require.Eventually(t, func() bool { return len(rec.cardDeletes()) > 0 }, 5*time.Second, 10*time.Millisecond, "expected the Square cleanup goroutine to attempt card deletion") require.Never(t, func() bool { return len(rec.customerDeletes()) > 0 }, 500*time.Millisecond, 10*time.Millisecond, "DeleteCustomer must NOT be called for a Square customer still referenced by another account") // User B's saved card is untouched (still references the shared customer). var bUserID string var bCustomerID sql.NullString var bDeletedAt sql.NullTime err = db.Conn.QueryRow(context.Background(), ` SELECT user_id, square_customer_id, deleted_at FROM user_saved_cards WHERE square_card_id = 'ccof:card_user_b' `).Scan(&bUserID, &bCustomerID, &bDeletedAt) require.NoError(t, err) require.Equal(t, userB, bUserID) require.True(t, bCustomerID.Valid && bCustomerID.String == "cus_shared_cross_user", "user B's row must keep the shared square_customer_id") require.False(t, bDeletedAt.Valid, "user B's row must not be soft-deleted") } // ============================================================================= // DeleteAccountHandler — process-local Square customer cache invalidation // ============================================================================= // TestDeleteAccount_InvalidatesSquareCustomerCache verifies the GDPR erasure // flow drops the user's process-local Square customer cache entry: after // DeleteAccountHandler anonymizes the account (NULLing square_customer_id on // saved cards and deleting the customer at Square), a later save-card flow for // the same (anonymized) user must re-mint a fresh Square customer instead of // reusing the deleted one's stale cached id. func TestDeleteAccount_InvalidatesSquareCustomerCache(t *testing.T) { savedSquareClient := payments.SquareClient payments.SquareClient = square.NewDevClient() t.Cleanup(func() { payments.SquareClient = savedSquareClient }) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) // A saved card gives the provisioning path a persistence point. _, 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, 'ccof:cache_erasure_card', 'Visa', '4242', 12, 2030, 'fp1', true) `, userID) require.NoError(t, err) t.Cleanup(func() { payments.InvalidateSquareCustomerCache(userID) }) svc := payments.NewPaymentService() originalID, err := svc.EnsureSquareCustomer(ctx, userID) require.NoError(t, err) require.NotEmpty(t, originalID, "a Square customer must be provisioned and cached for the save-card user") handler := http.HandlerFunc(DeleteAccountHandler) req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) w := httptest.NewRecorder() handler.ServeHTTP(w, req) require.Equal(t, http.StatusNoContent, w.Code) // The account is anonymized (email → anon-{id}@anon.invalid) and // square_customer_id is NULLed; the handler must also have invalidated the // process-local cache. A subsequent ensureSquareCustomer therefore // re-queries the NULLed DB and mints a fresh customer from the anonymized // email — a different id. Without the invalidation it would return the // stale originalID, resurrecting the erased identity in memory. reprovisioned, err := svc.EnsureSquareCustomer(ctx, userID) require.NoError(t, err) require.NotEqual(t, originalID, reprovisioned, "an erased user must not reuse the deleted Square customer id from the cache") }