@@ -21,10 +21,13 @@ use crate::{
2121 eth_association:: {
2222 AssociateEthAddressRequest , AssociateEthAddressResponse , EthAssociation , EthAssociationInput ,
2323 } ,
24+ x_association:: { AssociateXHandleRequest , XAssociation , XAssociationInput } ,
2425 } ,
2526 AppError ,
2627} ;
2728
29+ use rusx:: resources:: { user:: UserParams , UserField } ;
30+
2831use super :: SuccessResponse ;
2932
3033#[ derive( Debug , thiserror:: Error ) ]
@@ -222,6 +225,65 @@ pub async fn associate_eth_address(
222225 Ok ( Json ( response) )
223226}
224227
228+ pub async fn associate_x_handle (
229+ State ( state) : State < AppState > ,
230+ Extension ( user) : Extension < Address > ,
231+ Json ( payload) : Json < AssociateXHandleRequest > ,
232+ ) -> Result < NoContent , AppError > {
233+ tracing:: info!(
234+ "Received X handle association request for quan_address: {} -> username: {}" ,
235+ user. quan_address. 0 ,
236+ payload. username,
237+ ) ;
238+
239+ let mut params = UserParams :: new ( ) ;
240+ params. user_fields = Some ( vec ! [ UserField :: Description , UserField :: Username ] ) ;
241+
242+ let user_resp = state
243+ . twitter_gateway
244+ . users ( )
245+ . get_by_username ( & payload. username , Some ( params) )
246+ . await
247+ . map_err ( |e| {
248+ tracing:: error!( "Failed to fetch user by username {}: {:?}" , payload. username, e) ;
249+ AppError :: Handler ( HandlerError :: Address ( AddressHandlerError :: InvalidQueryParams ( format ! (
250+ "Failed to verify Twitter username: {}" ,
251+ e
252+ ) ) ) )
253+ } ) ?;
254+
255+ let twitter_user = user_resp. data . ok_or_else ( || {
256+ AppError :: Handler ( HandlerError :: Address ( AddressHandlerError :: InvalidQueryParams (
257+ "Twitter user not found" . to_string ( ) ,
258+ ) ) )
259+ } ) ?;
260+
261+ let bio = twitter_user. description . unwrap_or_default ( ) ;
262+ let x_bio_mention = state. config . get_x_bio_mention ( ) ;
263+ if !bio. to_lowercase ( ) . contains ( & x_bio_mention. to_lowercase ( ) ) {
264+ return Err ( AppError :: Handler ( HandlerError :: Address (
265+ AddressHandlerError :: Unauthorized ( format ! (
266+ "Twitter bio must contain '{}' to verify ownership" ,
267+ x_bio_mention
268+ ) ) ,
269+ ) ) ) ;
270+ }
271+
272+ let new_association = XAssociation :: new ( XAssociationInput {
273+ quan_address : user. quan_address . 0 ,
274+ username : twitter_user. username ,
275+ } ) ?;
276+
277+ state. db . x_associations . create ( & new_association) . await ?;
278+ tracing:: info!(
279+ "Created association for quan_address {} with X username {}" ,
280+ new_association. quan_address. 0 ,
281+ new_association. username
282+ ) ;
283+
284+ Ok ( NoContent )
285+ }
286+
225287pub async fn update_eth_address (
226288 State ( state) : State < AppState > ,
227289 Extension ( user) : Extension < Address > ,
@@ -377,6 +439,207 @@ mod tests {
377439 use tower:: ServiceExt ;
378440 use uuid:: Uuid ; // Required for .oneshot()
379441
442+ use rusx:: {
443+ resources:: {
444+ user:: { User , UserApi } ,
445+ TwitterApiResponse ,
446+ } ,
447+ MockTwitterGateway , MockUserApi ,
448+ } ;
449+ use std:: sync:: Arc ;
450+
451+ #[ tokio:: test]
452+ async fn test_associate_x_handle_success ( ) {
453+ let mut state = create_test_app_state ( ) . await ;
454+ reset_database ( & state. db . pool ) . await ;
455+
456+ // 1. Setup User & Token
457+ let user = create_persisted_address ( & state. db . addresses , "108" ) . await ;
458+ let token = generate_test_token ( & state. config . jwt . secret , & user. quan_address . 0 ) ;
459+
460+ // 2. Mock Twitter Gateway
461+ let mut mock_gateway = MockTwitterGateway :: new ( ) ;
462+ let mut mock_user_api = MockUserApi :: new ( ) ;
463+
464+ // Expect get_by_username
465+ let bio_mention = state. config . get_x_bio_mention ( ) . to_string ( ) ;
466+ mock_user_api. expect_get_by_username ( ) . returning ( move |_, _| {
467+ Ok ( TwitterApiResponse {
468+ data : Some ( User {
469+ id : "u1" . to_string ( ) ,
470+ name : "Test User" . to_string ( ) ,
471+ username : "test_user" . to_string ( ) ,
472+ description : Some ( format ! ( "I love {}" , bio_mention) ) , // Contains keyword from config
473+ public_metrics : None ,
474+ } ) ,
475+ includes : None ,
476+ meta : None ,
477+ } )
478+ } ) ;
479+
480+ let user_api_arc: Arc < dyn UserApi > = Arc :: new ( mock_user_api) ;
481+ mock_gateway. expect_users ( ) . return_const ( user_api_arc) ;
482+
483+ state. twitter_gateway = Arc :: new ( mock_gateway) ;
484+
485+ // 3. Setup Router
486+ let router = Router :: new ( )
487+ . route ( "/associate-x" , post ( associate_x_handle) )
488+ . layer ( middleware:: from_fn_with_state ( state. clone ( ) , jwt_auth) )
489+ . with_state ( state. clone ( ) ) ;
490+
491+ // 4. Request
492+ let payload = json ! ( { "username" : "test_user" } ) ;
493+ let response = router
494+ . oneshot (
495+ Request :: builder ( )
496+ . method ( "POST" )
497+ . uri ( "/associate-x" )
498+ . header ( http:: header:: CONTENT_TYPE , "application/json" )
499+ . header ( http:: header:: AUTHORIZATION , format ! ( "Bearer {}" , token) )
500+ . body ( Body :: from ( serde_json:: to_string ( & payload) . unwrap ( ) ) )
501+ . unwrap ( ) ,
502+ )
503+ . await
504+ . unwrap ( ) ;
505+
506+ // 5. Assert
507+ assert_eq ! ( response. status( ) , StatusCode :: NO_CONTENT ) ;
508+
509+ // Check DB
510+ let assoc = state
511+ . db
512+ . x_associations
513+ . find_by_address ( & user. quan_address )
514+ . await
515+ . unwrap ( ) ;
516+ assert ! ( assoc. is_some( ) ) ;
517+ assert_eq ! ( assoc. unwrap( ) . username, "test_user" ) ;
518+ }
519+
520+ #[ tokio:: test]
521+ async fn test_associate_x_handle_fails_bad_bio ( ) {
522+ let mut state = create_test_app_state ( ) . await ;
523+ reset_database ( & state. db . pool ) . await ;
524+
525+ let user = create_persisted_address ( & state. db . addresses , "109" ) . await ;
526+ let token = generate_test_token ( & state. config . jwt . secret , & user. quan_address . 0 ) ;
527+
528+ let mut mock_gateway = MockTwitterGateway :: new ( ) ;
529+ let mut mock_user_api = MockUserApi :: new ( ) ;
530+
531+ mock_user_api. expect_get_by_username ( ) . returning ( |_, _| {
532+ Ok ( TwitterApiResponse {
533+ data : Some ( User {
534+ id : "u1" . to_string ( ) ,
535+ name : "Test User" . to_string ( ) ,
536+ username : "test_user" . to_string ( ) ,
537+ description : Some ( "No keyword here" . to_string ( ) ) , // Missing keyword
538+ public_metrics : None ,
539+ } ) ,
540+ includes : None ,
541+ meta : None ,
542+ } )
543+ } ) ;
544+
545+ let user_api_arc: Arc < dyn UserApi > = Arc :: new ( mock_user_api) ;
546+ mock_gateway. expect_users ( ) . return_const ( user_api_arc) ;
547+ state. twitter_gateway = Arc :: new ( mock_gateway) ;
548+
549+ let router = Router :: new ( )
550+ . route ( "/associate-x" , post ( associate_x_handle) )
551+ . layer ( middleware:: from_fn_with_state ( state. clone ( ) , jwt_auth) )
552+ . with_state ( state) ;
553+
554+ let payload = json ! ( { "username" : "test_user" } ) ;
555+ let response = router
556+ . oneshot (
557+ Request :: builder ( )
558+ . method ( "POST" )
559+ . uri ( "/associate-x" )
560+ . header ( http:: header:: CONTENT_TYPE , "application/json" )
561+ . header ( http:: header:: AUTHORIZATION , format ! ( "Bearer {}" , token) )
562+ . body ( Body :: from ( serde_json:: to_string ( & payload) . unwrap ( ) ) )
563+ . unwrap ( ) ,
564+ )
565+ . await
566+ . unwrap ( ) ;
567+
568+ // Should return 401 Unauthorized
569+ assert_eq ! ( response. status( ) , StatusCode :: UNAUTHORIZED ) ;
570+ }
571+
572+ #[ tokio:: test]
573+ async fn test_associate_x_handle_case_insensitive_success ( ) {
574+ let mut state = create_test_app_state ( ) . await ;
575+ reset_database ( & state. db . pool ) . await ;
576+
577+ // 1. Setup User & Token
578+ let user = create_persisted_address ( & state. db . addresses , "110" ) . await ;
579+ let token = generate_test_token ( & state. config . jwt . secret , & user. quan_address . 0 ) ;
580+
581+ // 2. Mock Twitter Gateway
582+ let mut mock_gateway = MockTwitterGateway :: new ( ) ;
583+ let mut mock_user_api = MockUserApi :: new ( ) ;
584+
585+ // Expect get_by_username
586+ let bio_mention = state. config . get_x_bio_mention ( ) . to_string ( ) ;
587+ // Create a lowercase version of the mention for the bio
588+ let lowercase_bio_mention = bio_mention. to_lowercase ( ) ;
589+
590+ mock_user_api. expect_get_by_username ( ) . returning ( move |_, _| {
591+ Ok ( TwitterApiResponse {
592+ data : Some ( User {
593+ id : "u1" . to_string ( ) ,
594+ name : "Test User" . to_string ( ) ,
595+ username : "test_user" . to_string ( ) ,
596+ description : Some ( format ! ( "I love {}" , lowercase_bio_mention) ) , // Contains lowercase keyword
597+ public_metrics : None ,
598+ } ) ,
599+ includes : None ,
600+ meta : None ,
601+ } )
602+ } ) ;
603+
604+ let user_api_arc: Arc < dyn UserApi > = Arc :: new ( mock_user_api) ;
605+ mock_gateway. expect_users ( ) . return_const ( user_api_arc) ;
606+
607+ state. twitter_gateway = Arc :: new ( mock_gateway) ;
608+
609+ // 3. Setup Router
610+ let router = Router :: new ( )
611+ . route ( "/associate-x" , post ( associate_x_handle) )
612+ . layer ( middleware:: from_fn_with_state ( state. clone ( ) , jwt_auth) )
613+ . with_state ( state. clone ( ) ) ;
614+
615+ // 4. Request
616+ let payload = json ! ( { "username" : "test_user" } ) ;
617+ let response = router
618+ . oneshot (
619+ Request :: builder ( )
620+ . method ( "POST" )
621+ . uri ( "/associate-x" )
622+ . header ( http:: header:: CONTENT_TYPE , "application/json" )
623+ . header ( http:: header:: AUTHORIZATION , format ! ( "Bearer {}" , token) )
624+ . body ( Body :: from ( serde_json:: to_string ( & payload) . unwrap ( ) ) )
625+ . unwrap ( ) ,
626+ )
627+ . await
628+ . unwrap ( ) ;
629+
630+ // 5. Assert - Should be successful even with different case
631+ assert_eq ! ( response. status( ) , StatusCode :: NO_CONTENT ) ;
632+
633+ // Check DB
634+ let assoc = state
635+ . db
636+ . x_associations
637+ . find_by_address ( & user. quan_address )
638+ . await
639+ . unwrap ( ) ;
640+ assert ! ( assoc. is_some( ) ) ;
641+ }
642+
380643 #[ tokio:: test]
381644 async fn test_update_eth_address_success ( ) {
382645 let state = create_test_app_state ( ) . await ;
0 commit comments