From a39381ec3d0641020f4b822f7762e1d3fe9b071a Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Sat, 24 Jan 2026 06:26:49 +0000 Subject: [PATCH 1/4] feat: add twitter_handle to profiles with case-insensitive uniqueness --- backend/migrations/005_add_twitter_handle.sql | 2 + .../application/commands/create_profile.rs | 1 + .../application/commands/update_profile.rs | 26 +++++++++++ backend/src/application/dtos/profile_dtos.rs | 2 + .../application/queries/get_all_profiles.rs | 1 + .../src/application/queries/get_profile.rs | 1 + backend/src/domain/entities/profile.rs | 2 + .../domain/repositories/profile_repository.rs | 4 ++ .../postgres_profile_repository.rs | 46 ++++++++++++++++--- backend/tests/profile_tests.rs | 27 +++++++++++ 10 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 backend/migrations/005_add_twitter_handle.sql diff --git a/backend/migrations/005_add_twitter_handle.sql b/backend/migrations/005_add_twitter_handle.sql new file mode 100644 index 0000000..85664db --- /dev/null +++ b/backend/migrations/005_add_twitter_handle.sql @@ -0,0 +1,2 @@ +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS twitter_handle TEXT; +CREATE UNIQUE INDEX IF NOT EXISTS unique_twitter_handle_lower ON profiles (LOWER(twitter_handle)); diff --git a/backend/src/application/commands/create_profile.rs b/backend/src/application/commands/create_profile.rs index ba93ed0..ad54b34 100644 --- a/backend/src/application/commands/create_profile.rs +++ b/backend/src/application/commands/create_profile.rs @@ -35,6 +35,7 @@ pub async fn create_profile( description: profile.description, avatar_url: profile.avatar_url, github_login: profile.github_login, + twitter_handle: profile.twitter_handle, created_at: profile.created_at, updated_at: profile.updated_at, }) diff --git a/backend/src/application/commands/update_profile.rs b/backend/src/application/commands/update_profile.rs index e94b524..3244273 100644 --- a/backend/src/application/commands/update_profile.rs +++ b/backend/src/application/commands/update_profile.rs @@ -43,6 +43,31 @@ pub async fn update_profile( profile.github_login = Some(trimmed.to_string()); } } + if let Some(ref handle) = request.twitter_handle { + let trimmed = handle.trim(); + + // Allow empty handles (set to None) + if trimmed.is_empty() { + profile.twitter_handle = None; + } else { + // Validate format for non-empty handles (Twitter/X handle: 1-15 alphanumeric + underscores) + let valid_format = regex::Regex::new(r"^[a-zA-Z0-9_]{1,15}$").unwrap(); + if !valid_format.is_match(trimmed) { + return Err("Invalid Twitter handle format".to_string()); + } + if let Some(conflicting_profile) = profile_repository + .find_by_twitter_handle(trimmed) + .await + .map_err(|e| e.to_string())? + { + // Only conflict if it's not the current user's profile + if conflicting_profile.address != wallet_address { + return Err("Twitter handle already taken".to_string()); + } + } + profile.twitter_handle = Some(trimmed.to_string()); + } + } profile_repository .update(&profile) .await @@ -54,6 +79,7 @@ pub async fn update_profile( description: profile.description, avatar_url: profile.avatar_url, github_login: profile.github_login, + twitter_handle: profile.twitter_handle, created_at: profile.created_at, updated_at: profile.updated_at, }) diff --git a/backend/src/application/dtos/profile_dtos.rs b/backend/src/application/dtos/profile_dtos.rs index c1f356e..9f4dcb6 100644 --- a/backend/src/application/dtos/profile_dtos.rs +++ b/backend/src/application/dtos/profile_dtos.rs @@ -15,6 +15,7 @@ pub struct UpdateProfileRequest { pub description: Option, pub avatar_url: Option, pub github_login: Option, + pub twitter_handle: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -24,6 +25,7 @@ pub struct ProfileResponse { pub description: Option, pub avatar_url: Option, pub github_login: Option, + pub twitter_handle: Option, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, } diff --git a/backend/src/application/queries/get_all_profiles.rs b/backend/src/application/queries/get_all_profiles.rs index ed42faf..ae26048 100644 --- a/backend/src/application/queries/get_all_profiles.rs +++ b/backend/src/application/queries/get_all_profiles.rs @@ -18,6 +18,7 @@ pub async fn get_all_profiles( description: profile.description, avatar_url: profile.avatar_url, github_login: profile.github_login, + twitter_handle: profile.twitter_handle, created_at: profile.created_at, updated_at: profile.updated_at, }) diff --git a/backend/src/application/queries/get_profile.rs b/backend/src/application/queries/get_profile.rs index f4593ab..3837014 100644 --- a/backend/src/application/queries/get_profile.rs +++ b/backend/src/application/queries/get_profile.rs @@ -21,6 +21,7 @@ pub async fn get_profile( description: profile.description, avatar_url: profile.avatar_url, github_login: profile.github_login, + twitter_handle: profile.twitter_handle, created_at: profile.created_at, updated_at: profile.updated_at, }) diff --git a/backend/src/domain/entities/profile.rs b/backend/src/domain/entities/profile.rs index fd42982..667d9ac 100644 --- a/backend/src/domain/entities/profile.rs +++ b/backend/src/domain/entities/profile.rs @@ -10,6 +10,7 @@ pub struct Profile { pub description: Option, pub avatar_url: Option, pub github_login: Option, + pub twitter_handle: Option, pub login_nonce: i64, pub created_at: DateTime, pub updated_at: DateTime, @@ -24,6 +25,7 @@ impl Profile { description: None, avatar_url: None, github_login: None, + twitter_handle: None, login_nonce: 1, created_at: now, updated_at: now, diff --git a/backend/src/domain/repositories/profile_repository.rs b/backend/src/domain/repositories/profile_repository.rs index 3658458..8462497 100644 --- a/backend/src/domain/repositories/profile_repository.rs +++ b/backend/src/domain/repositories/profile_repository.rs @@ -16,6 +16,10 @@ pub trait ProfileRepository: Send + Sync { &self, github_login: &str, ) -> Result, Box>; + async fn find_by_twitter_handle( + &self, + twitter_handle: &str, + ) -> Result, Box>; async fn get_login_nonce_by_wallet_address( &self, address: &WalletAddress, diff --git a/backend/src/infrastructure/repositories/postgres_profile_repository.rs b/backend/src/infrastructure/repositories/postgres_profile_repository.rs index 3eb691d..72bb3e7 100644 --- a/backend/src/infrastructure/repositories/postgres_profile_repository.rs +++ b/backend/src/infrastructure/repositories/postgres_profile_repository.rs @@ -24,7 +24,7 @@ impl ProfileRepository for PostgresProfileRepository { ) -> Result, Box> { let row = sqlx::query!( r#" - SELECT address, name, description, avatar_url, github_login, created_at, updated_at + SELECT address, name, description, avatar_url, github_login, twitter_handle, created_at, updated_at FROM profiles WHERE address = $1 "#, @@ -40,6 +40,7 @@ impl ProfileRepository for PostgresProfileRepository { description: r.description, avatar_url: r.avatar_url, github_login: r.github_login, + twitter_handle: r.twitter_handle, login_nonce: 0, // Not needed for regular profile queries created_at: r.created_at.unwrap(), updated_at: r.updated_at.unwrap(), @@ -49,7 +50,7 @@ impl ProfileRepository for PostgresProfileRepository { async fn find_all(&self) -> Result, Box> { let rows = sqlx::query!( r#" - SELECT address, name, description, avatar_url, github_login, created_at, updated_at + SELECT address, name, description, avatar_url, github_login, twitter_handle, created_at, updated_at FROM profiles "#, ) @@ -65,6 +66,7 @@ impl ProfileRepository for PostgresProfileRepository { description: r.description, avatar_url: r.avatar_url, github_login: r.github_login, + twitter_handle: r.twitter_handle, login_nonce: 0, // Not needed for regular profile queries created_at: r.created_at.unwrap(), updated_at: r.updated_at.unwrap(), @@ -75,14 +77,15 @@ impl ProfileRepository for PostgresProfileRepository { async fn create(&self, profile: &Profile) -> Result<(), Box> { sqlx::query!( r#" - INSERT INTO profiles (address, name, description, avatar_url, github_login, login_nonce, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO profiles (address, name, description, avatar_url, github_login, twitter_handle, login_nonce, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) "#, profile.address.as_str(), profile.name, profile.description, profile.avatar_url, profile.github_login, + profile.twitter_handle, profile.login_nonce, profile.created_at, profile.updated_at @@ -98,7 +101,7 @@ impl ProfileRepository for PostgresProfileRepository { sqlx::query!( r#" UPDATE profiles - SET name = $2, description = $3, avatar_url = $4, github_login = $5, updated_at = $6 + SET name = $2, description = $3, avatar_url = $4, github_login = $5, twitter_handle = $6, updated_at = $7 WHERE address = $1 "#, profile.address.as_str(), @@ -106,6 +109,7 @@ impl ProfileRepository for PostgresProfileRepository { profile.description, profile.avatar_url, profile.github_login, + profile.twitter_handle, profile.updated_at ) .execute(&self.pool) @@ -136,7 +140,7 @@ impl ProfileRepository for PostgresProfileRepository { ) -> Result, Box> { let row = sqlx::query!( r#" - SELECT address, name, description, avatar_url, github_login, created_at, updated_at + SELECT address, name, description, avatar_url, github_login, twitter_handle, created_at, updated_at FROM profiles WHERE LOWER(github_login) = LOWER($1) "#, @@ -152,6 +156,36 @@ impl ProfileRepository for PostgresProfileRepository { description: r.description, avatar_url: r.avatar_url, github_login: r.github_login, + twitter_handle: r.twitter_handle, + login_nonce: 0, // Not needed for regular profile queries + created_at: r.created_at.unwrap(), + updated_at: r.updated_at.unwrap(), + })) + } + + async fn find_by_twitter_handle( + &self, + twitter_handle: &str, + ) -> Result, Box> { + let row = sqlx::query!( + r#" + SELECT address, name, description, avatar_url, github_login, twitter_handle, created_at, updated_at + FROM profiles + WHERE LOWER(twitter_handle) = LOWER($1) + "#, + twitter_handle + ) + .fetch_optional(&self.pool) + .await + .map_err(|e| Box::new(e) as Box)?; + + Ok(row.map(|r| Profile { + address: WalletAddress(r.address), + name: r.name, + description: r.description, + avatar_url: r.avatar_url, + github_login: r.github_login, + twitter_handle: r.twitter_handle, login_nonce: 0, // Not needed for regular profile queries created_at: r.created_at.unwrap(), updated_at: r.updated_at.unwrap(), diff --git a/backend/tests/profile_tests.rs b/backend/tests/profile_tests.rs index f92a54c..df49f95 100644 --- a/backend/tests/profile_tests.rs +++ b/backend/tests/profile_tests.rs @@ -61,6 +61,22 @@ mod github_handle_tests { .cloned()) } + async fn find_by_twitter_handle( + &self, + twitter_handle: &str, + ) -> Result, Box> { + let lower = twitter_handle.to_lowercase(); + let list = self.profiles.lock().unwrap(); + Ok(list + .iter() + .find(|&p| { + p.twitter_handle + .as_ref() + .is_some_and(|h| h.to_lowercase() == lower) + }) + .cloned()) + } + async fn get_login_nonce_by_wallet_address( &self, _address: &WalletAddress, @@ -86,6 +102,7 @@ mod github_handle_tests { description: None, avatar_url: None, github_login: None, + twitter_handle: None, login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), @@ -100,6 +117,7 @@ mod github_handle_tests { description: None, avatar_url: None, github_login: Some("GitUser123".into()), + twitter_handle: None, }; let result = update_profile(repo.clone(), profile.address.to_string(), req).await; @@ -117,6 +135,7 @@ mod github_handle_tests { description: None, avatar_url: None, github_login: None, + twitter_handle: None, login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), @@ -131,6 +150,7 @@ mod github_handle_tests { description: None, avatar_url: None, github_login: Some("bad@name".into()), + twitter_handle: None, }; let err = update_profile(repo.clone(), profile.address.to_string(), req).await; @@ -149,6 +169,7 @@ mod github_handle_tests { description: None, avatar_url: None, github_login: Some("Alice".into()), + twitter_handle: None, login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), @@ -160,6 +181,7 @@ mod github_handle_tests { description: None, avatar_url: None, github_login: None, + twitter_handle: None, login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), @@ -175,6 +197,7 @@ mod github_handle_tests { description: None, avatar_url: None, github_login: Some("alice".into()), + twitter_handle: None, }; let err = update_profile(repo.clone(), profile2.address.to_string(), req).await; @@ -192,6 +215,7 @@ mod github_handle_tests { description: None, avatar_url: None, github_login: Some("BobUser".into()), + twitter_handle: None, login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), @@ -206,6 +230,7 @@ mod github_handle_tests { description: None, avatar_url: None, github_login: Some("".into()), + twitter_handle: None, }; let result = update_profile(repo.clone(), profile.address.to_string(), req).await; @@ -223,6 +248,7 @@ mod github_handle_tests { description: None, avatar_url: None, github_login: Some("CharlieGit".into()), + twitter_handle: None, login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), @@ -237,6 +263,7 @@ mod github_handle_tests { description: None, avatar_url: None, github_login: Some("CharlieGit".into()), + twitter_handle: None, }; let result = update_profile(repo.clone(), profile.address.to_string(), req).await; From 0464fb53e2fee0181630ca3bca35f353ae36b572 Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Sat, 24 Jan 2026 07:17:57 +0000 Subject: [PATCH 2/4] chore: update sqlx query cache for twitter_handle --- ...5952a127f82436d0ddc0b71f7afb3b530462.json} | 5 +- ...2bc19ea56fc80bcaaf030a058c85414ea2a7.json} | 12 +++- ...ec7583e1b104a6ba83184a7841b2f198520d.json} | 5 +- ...f501cd3cc1ad7eb72b0ed7edc853035b14c9.json} | 12 +++- ...36c7710eac0b12924c32843d201def30961e6.json | 64 +++++++++++++++++++ ...51a9cb3d50d557d90c55b95195b1dbbd09b2.json} | 12 +++- 6 files changed, 97 insertions(+), 13 deletions(-) rename backend/.sqlx/{query-e4c05cdcfce8dddaf75c30de7e20d020c23a6650ec0bac46c56c30bc3d9c8142.json => query-1acdd97ec41e3fa9cc9319bf680c5952a127f82436d0ddc0b71f7afb3b530462.json} (57%) rename backend/.sqlx/{query-b521c6c7f362753693d7059c6815de444a5c6aadc1a9950d9d71f49f52dee768.json => query-7a8afc2b7d71942781088cdd0a2b2bc19ea56fc80bcaaf030a058c85414ea2a7.json} (76%) rename backend/.sqlx/{query-5c8b9a2a1431a71613c6fd9d06e7f1dc430db4e2256fad1d38a33e31ef536810.json => query-a582b89bc07b2332c1b196a4ade6ec7583e1b104a6ba83184a7841b2f198520d.json} (56%) rename backend/.sqlx/{query-177358fec702a5f78c1ff0dbd5eed42fa868487c84ebef42dfcf695e9ce42725.json => query-ba666476f8eec313de6b35bd1066f501cd3cc1ad7eb72b0ed7edc853035b14c9.json} (75%) create mode 100644 backend/.sqlx/query-d70adcade77f15e4b624296cbfe36c7710eac0b12924c32843d201def30961e6.json rename backend/.sqlx/{query-fd6f338fcae9c81fbf1d7590574fa950a74fa68daabb48c80a0a7754e4066987.json => query-d8259958a9c38fd2c6b0ad35793c51a9cb3d50d557d90c55b95195b1dbbd09b2.json} (74%) diff --git a/backend/.sqlx/query-e4c05cdcfce8dddaf75c30de7e20d020c23a6650ec0bac46c56c30bc3d9c8142.json b/backend/.sqlx/query-1acdd97ec41e3fa9cc9319bf680c5952a127f82436d0ddc0b71f7afb3b530462.json similarity index 57% rename from backend/.sqlx/query-e4c05cdcfce8dddaf75c30de7e20d020c23a6650ec0bac46c56c30bc3d9c8142.json rename to backend/.sqlx/query-1acdd97ec41e3fa9cc9319bf680c5952a127f82436d0ddc0b71f7afb3b530462.json index 86f6186..93d81d9 100644 --- a/backend/.sqlx/query-e4c05cdcfce8dddaf75c30de7e20d020c23a6650ec0bac46c56c30bc3d9c8142.json +++ b/backend/.sqlx/query-1acdd97ec41e3fa9cc9319bf680c5952a127f82436d0ddc0b71f7afb3b530462.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO profiles (address, name, description, avatar_url, github_login, login_nonce, created_at, updated_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ", + "query": "\n INSERT INTO profiles (address, name, description, avatar_url, github_login, twitter_handle, login_nonce, created_at, updated_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n ", "describe": { "columns": [], "parameters": { @@ -10,6 +10,7 @@ "Text", "Text", "Text", + "Text", "Int8", "Timestamptz", "Timestamptz" @@ -17,5 +18,5 @@ }, "nullable": [] }, - "hash": "e4c05cdcfce8dddaf75c30de7e20d020c23a6650ec0bac46c56c30bc3d9c8142" + "hash": "1acdd97ec41e3fa9cc9319bf680c5952a127f82436d0ddc0b71f7afb3b530462" } diff --git a/backend/.sqlx/query-b521c6c7f362753693d7059c6815de444a5c6aadc1a9950d9d71f49f52dee768.json b/backend/.sqlx/query-7a8afc2b7d71942781088cdd0a2b2bc19ea56fc80bcaaf030a058c85414ea2a7.json similarity index 76% rename from backend/.sqlx/query-b521c6c7f362753693d7059c6815de444a5c6aadc1a9950d9d71f49f52dee768.json rename to backend/.sqlx/query-7a8afc2b7d71942781088cdd0a2b2bc19ea56fc80bcaaf030a058c85414ea2a7.json index 0e00234..63e4b62 100644 --- a/backend/.sqlx/query-b521c6c7f362753693d7059c6815de444a5c6aadc1a9950d9d71f49f52dee768.json +++ b/backend/.sqlx/query-7a8afc2b7d71942781088cdd0a2b2bc19ea56fc80bcaaf030a058c85414ea2a7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT address, name, description, avatar_url, github_login, created_at, updated_at\n FROM profiles\n ", + "query": "\n SELECT address, name, description, avatar_url, github_login, twitter_handle, created_at, updated_at\n FROM profiles\n ", "describe": { "columns": [ { @@ -30,11 +30,16 @@ }, { "ordinal": 5, + "name": "twitter_handle", + "type_info": "Text" + }, + { + "ordinal": 6, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 6, + "ordinal": 7, "name": "updated_at", "type_info": "Timestamptz" } @@ -49,8 +54,9 @@ true, true, true, + true, true ] }, - "hash": "b521c6c7f362753693d7059c6815de444a5c6aadc1a9950d9d71f49f52dee768" + "hash": "7a8afc2b7d71942781088cdd0a2b2bc19ea56fc80bcaaf030a058c85414ea2a7" } diff --git a/backend/.sqlx/query-5c8b9a2a1431a71613c6fd9d06e7f1dc430db4e2256fad1d38a33e31ef536810.json b/backend/.sqlx/query-a582b89bc07b2332c1b196a4ade6ec7583e1b104a6ba83184a7841b2f198520d.json similarity index 56% rename from backend/.sqlx/query-5c8b9a2a1431a71613c6fd9d06e7f1dc430db4e2256fad1d38a33e31ef536810.json rename to backend/.sqlx/query-a582b89bc07b2332c1b196a4ade6ec7583e1b104a6ba83184a7841b2f198520d.json index 81c5572..041bd11 100644 --- a/backend/.sqlx/query-5c8b9a2a1431a71613c6fd9d06e7f1dc430db4e2256fad1d38a33e31ef536810.json +++ b/backend/.sqlx/query-a582b89bc07b2332c1b196a4ade6ec7583e1b104a6ba83184a7841b2f198520d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE profiles\n SET name = $2, description = $3, avatar_url = $4, github_login = $5, updated_at = $6\n WHERE address = $1\n ", + "query": "\n UPDATE profiles\n SET name = $2, description = $3, avatar_url = $4, github_login = $5, twitter_handle = $6, updated_at = $7\n WHERE address = $1\n ", "describe": { "columns": [], "parameters": { @@ -10,10 +10,11 @@ "Text", "Text", "Text", + "Text", "Timestamptz" ] }, "nullable": [] }, - "hash": "5c8b9a2a1431a71613c6fd9d06e7f1dc430db4e2256fad1d38a33e31ef536810" + "hash": "a582b89bc07b2332c1b196a4ade6ec7583e1b104a6ba83184a7841b2f198520d" } diff --git a/backend/.sqlx/query-177358fec702a5f78c1ff0dbd5eed42fa868487c84ebef42dfcf695e9ce42725.json b/backend/.sqlx/query-ba666476f8eec313de6b35bd1066f501cd3cc1ad7eb72b0ed7edc853035b14c9.json similarity index 75% rename from backend/.sqlx/query-177358fec702a5f78c1ff0dbd5eed42fa868487c84ebef42dfcf695e9ce42725.json rename to backend/.sqlx/query-ba666476f8eec313de6b35bd1066f501cd3cc1ad7eb72b0ed7edc853035b14c9.json index 020138d..1036378 100644 --- a/backend/.sqlx/query-177358fec702a5f78c1ff0dbd5eed42fa868487c84ebef42dfcf695e9ce42725.json +++ b/backend/.sqlx/query-ba666476f8eec313de6b35bd1066f501cd3cc1ad7eb72b0ed7edc853035b14c9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT address, name, description, avatar_url, github_login, created_at, updated_at\n FROM profiles\n WHERE LOWER(github_login) = LOWER($1)\n ", + "query": "\n SELECT address, name, description, avatar_url, github_login, twitter_handle, created_at, updated_at\n FROM profiles\n WHERE address = $1\n ", "describe": { "columns": [ { @@ -30,11 +30,16 @@ }, { "ordinal": 5, + "name": "twitter_handle", + "type_info": "Text" + }, + { + "ordinal": 6, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 6, + "ordinal": 7, "name": "updated_at", "type_info": "Timestamptz" } @@ -51,8 +56,9 @@ true, true, true, + true, true ] }, - "hash": "177358fec702a5f78c1ff0dbd5eed42fa868487c84ebef42dfcf695e9ce42725" + "hash": "ba666476f8eec313de6b35bd1066f501cd3cc1ad7eb72b0ed7edc853035b14c9" } diff --git a/backend/.sqlx/query-d70adcade77f15e4b624296cbfe36c7710eac0b12924c32843d201def30961e6.json b/backend/.sqlx/query-d70adcade77f15e4b624296cbfe36c7710eac0b12924c32843d201def30961e6.json new file mode 100644 index 0000000..a85e90d --- /dev/null +++ b/backend/.sqlx/query-d70adcade77f15e4b624296cbfe36c7710eac0b12924c32843d201def30961e6.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT address, name, description, avatar_url, github_login, twitter_handle, created_at, updated_at\n FROM profiles\n WHERE LOWER(twitter_handle) = LOWER($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "address", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "avatar_url", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "github_login", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "twitter_handle", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "d70adcade77f15e4b624296cbfe36c7710eac0b12924c32843d201def30961e6" +} diff --git a/backend/.sqlx/query-fd6f338fcae9c81fbf1d7590574fa950a74fa68daabb48c80a0a7754e4066987.json b/backend/.sqlx/query-d8259958a9c38fd2c6b0ad35793c51a9cb3d50d557d90c55b95195b1dbbd09b2.json similarity index 74% rename from backend/.sqlx/query-fd6f338fcae9c81fbf1d7590574fa950a74fa68daabb48c80a0a7754e4066987.json rename to backend/.sqlx/query-d8259958a9c38fd2c6b0ad35793c51a9cb3d50d557d90c55b95195b1dbbd09b2.json index 7e5ec3a..0aa82a8 100644 --- a/backend/.sqlx/query-fd6f338fcae9c81fbf1d7590574fa950a74fa68daabb48c80a0a7754e4066987.json +++ b/backend/.sqlx/query-d8259958a9c38fd2c6b0ad35793c51a9cb3d50d557d90c55b95195b1dbbd09b2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT address, name, description, avatar_url, github_login, created_at, updated_at\n FROM profiles\n WHERE address = $1\n ", + "query": "\n SELECT address, name, description, avatar_url, github_login, twitter_handle, created_at, updated_at\n FROM profiles\n WHERE LOWER(github_login) = LOWER($1)\n ", "describe": { "columns": [ { @@ -30,11 +30,16 @@ }, { "ordinal": 5, + "name": "twitter_handle", + "type_info": "Text" + }, + { + "ordinal": 6, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 6, + "ordinal": 7, "name": "updated_at", "type_info": "Timestamptz" } @@ -51,8 +56,9 @@ true, true, true, + true, true ] }, - "hash": "fd6f338fcae9c81fbf1d7590574fa950a74fa68daabb48c80a0a7754e4066987" + "hash": "d8259958a9c38fd2c6b0ad35793c51a9cb3d50d557d90c55b95195b1dbbd09b2" } From 77f6e594b0eae31b18941b091f88badd694e20e9 Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Mon, 26 Jan 2026 16:13:38 +0000 Subject: [PATCH 3/4] test: add twitter_handle tests and manual test script --- backend/tests/profile_tests.rs | 108 +++++++++++++++++++++++++++++ scripts/test_twitter_handle.sh | 122 +++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100755 scripts/test_twitter_handle.sh diff --git a/backend/tests/profile_tests.rs b/backend/tests/profile_tests.rs index df49f95..73b8042 100644 --- a/backend/tests/profile_tests.rs +++ b/backend/tests/profile_tests.rs @@ -271,4 +271,112 @@ mod github_handle_tests { let resp = result.unwrap(); assert_eq!(resp.github_login.unwrap(), "CharlieGit"); } + + #[tokio::test] + async fn valid_twitter_handle_succeeds() { + let profile = Profile { + address: WalletAddress::new("0x1234567890123456789012345678901234567896".to_string()) + .unwrap(), + name: Some("Dave".into()), + description: None, + avatar_url: None, + github_login: None, + twitter_handle: None, + login_nonce: 1, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let repo = Arc::new(FakeRepo { + profiles: std::sync::Mutex::new(vec![profile.clone()]), + }); + + let req = UpdateProfileRequest { + name: None, + description: None, + avatar_url: None, + github_login: None, + twitter_handle: Some("elonmusk".into()), + }; + + let result = update_profile(repo.clone(), profile.address.to_string(), req).await; + assert!(result.is_ok()); + let resp = result.unwrap(); + assert_eq!(resp.twitter_handle.unwrap(), "elonmusk"); + } + + #[tokio::test] + async fn invalid_twitter_handle_rejected() { + let profile = Profile { + address: WalletAddress::new("0x1234567890123456789012345678901234567897".to_string()) + .unwrap(), + name: None, + description: None, + avatar_url: None, + github_login: None, + twitter_handle: None, + login_nonce: 1, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let repo = Arc::new(FakeRepo { + profiles: std::sync::Mutex::new(vec![profile.clone()]), + }); + + // Twitter handles can't have @ or be longer than 15 chars + let req = UpdateProfileRequest { + name: None, + description: None, + avatar_url: None, + github_login: None, + twitter_handle: Some("@invalid".into()), + }; + + let err = update_profile(repo.clone(), profile.address.to_string(), req).await; + assert!(err.is_err()); + assert!(err.unwrap_err().contains("Invalid Twitter handle format")); + } + + #[tokio::test] + async fn twitter_handle_conflict_rejected() { + let profile1 = Profile { + address: WalletAddress::new("0x1234567890123456789012345678901234567898".to_string()) + .unwrap(), + name: None, + description: None, + avatar_url: None, + github_login: None, + twitter_handle: Some("TakenHandle".into()), + login_nonce: 1, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let profile2 = Profile { + address: WalletAddress::new("0x1234567890123456789012345678901234567899".to_string()) + .unwrap(), + name: None, + description: None, + avatar_url: None, + github_login: None, + twitter_handle: None, + login_nonce: 1, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let repo = Arc::new(FakeRepo { + profiles: std::sync::Mutex::new(vec![profile1.clone(), profile2.clone()]), + }); + + // Try to claim "takenhandle" (lowercase) from profile2 → conflict + let req = UpdateProfileRequest { + name: None, + description: None, + avatar_url: None, + github_login: None, + twitter_handle: Some("takenhandle".into()), + }; + + let err = update_profile(repo.clone(), profile2.address.to_string(), req).await; + assert!(err.is_err()); + assert!(err.unwrap_err().contains("Twitter handle already taken")); + } } diff --git a/scripts/test_twitter_handle.sh b/scripts/test_twitter_handle.sh new file mode 100755 index 0000000..0150c07 --- /dev/null +++ b/scripts/test_twitter_handle.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Test twitter_handle profile update via API: +# - Get profile +# - Update profile with twitter_handle +# Requirements: curl, node, npm. Installs ethers locally into /tmp by default. +# +# Inputs (env): +# PUBLIC_ADDRESS (required) - wallet address +# PRIVATE_KEY (required) - wallet private key (0x-prefixed) +# API_URL (optional) - defaults to http://localhost:3001 +# TWITTER_HANDLE (optional) - defaults to "testhandle" + +API_URL="${API_URL:-http://localhost:3001}" +ADDRESS="${PUBLIC_ADDRESS:-}" +PRIVATE_KEY="${PRIVATE_KEY:-}" + +if [[ -z "${ADDRESS}" ]]; then + read -r -p "Enter PUBLIC_ADDRESS (0x...): " ADDRESS +fi +if [[ -z "${PRIVATE_KEY}" ]]; then + read -r -s -p "Enter PRIVATE_KEY (0x..., hidden): " PRIVATE_KEY + echo +fi +if [[ -z "${ADDRESS}" || -z "${PRIVATE_KEY}" ]]; then + echo "PUBLIC_ADDRESS and PRIVATE_KEY are required. Aborting." + exit 1 +fi + +TWITTER_HANDLE="${TWITTER_HANDLE:-testhandle}" + +# Ensure we have ethers available +TOOLS_DIR="${TOOLS_DIR:-/tmp/theguildgenesis-login}" +export NODE_PATH="${TOOLS_DIR}/node_modules${NODE_PATH:+:${NODE_PATH}}" +export PATH="${TOOLS_DIR}/node_modules/.bin:${PATH}" +if ! node -e "require('ethers')" >/dev/null 2>&1; then + echo "Installing ethers@6 to ${TOOLS_DIR}..." + mkdir -p "${TOOLS_DIR}" + npm install --prefix "${TOOLS_DIR}" ethers@6 >/dev/null +fi + +echo "Fetching nonce for ${ADDRESS}..." +nonce_resp="$(curl -sS "${API_URL}/auth/nonce/${ADDRESS}")" +echo "Nonce response: ${nonce_resp}" +nonce="$(RESP="${nonce_resp}" python3 - <<'PY' +import json, os +data = json.loads(os.environ["RESP"]) +print(data["nonce"]) +PY +)" +if [[ -z "${nonce}" ]]; then + echo "Failed to parse nonce from response" + exit 1 +fi + +message=$'Sign this message to authenticate with The Guild.\n\nNonce: '"${nonce}" + +echo "Signing nonce..." +signature="$( + ADDRESS="${ADDRESS}" PRIVATE_KEY="${PRIVATE_KEY}" MESSAGE="${message}" \ + node - <<'NODE' +const { Wallet } = require('ethers'); +const address = process.env.ADDRESS; +const pk = process.env.PRIVATE_KEY; +const message = process.env.MESSAGE; +if (!address || !pk || !message) { + console.error("Missing ADDRESS, PRIVATE_KEY or MESSAGE"); + process.exit(1); +} +const wallet = new Wallet(pk); +if (wallet.address.toLowerCase() !== address.toLowerCase()) { + console.error(`Private key does not match address.`); + process.exit(1); +} +(async () => { + const sig = await wallet.signMessage(message); + console.log(sig); +})(); +NODE +)" + +echo "Fetching current profile..." +get_tmp="$(mktemp)" +get_status="$(curl -sS -o "${get_tmp}" -w "%{http_code}" "${API_URL}/profile/${ADDRESS}")" +get_resp="$(cat "${get_tmp}")" +rm -f "${get_tmp}" +echo "GET profile HTTP ${get_status}: ${get_resp}" + +update_payload=$(cat < Date: Tue, 27 Jan 2026 10:54:18 +0000 Subject: [PATCH 4/4] test: use tushar instead of elonmusk --- backend/tests/profile_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tests/profile_tests.rs b/backend/tests/profile_tests.rs index 73b8042..9ba38d0 100644 --- a/backend/tests/profile_tests.rs +++ b/backend/tests/profile_tests.rs @@ -295,13 +295,13 @@ mod github_handle_tests { description: None, avatar_url: None, github_login: None, - twitter_handle: Some("elonmusk".into()), + twitter_handle: Some("tushar".into()), }; let result = update_profile(repo.clone(), profile.address.to_string(), req).await; assert!(result.is_ok()); let resp = result.unwrap(); - assert_eq!(resp.twitter_handle.unwrap(), "elonmusk"); + assert_eq!(resp.twitter_handle.unwrap(), "tushar"); } #[tokio::test]