From c627bfc036b459abeddc136947e2ca71ede4dbe8 Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Fri, 27 Feb 2026 09:41:59 +0000 Subject: [PATCH 1/2] feat: add linkedin account to profiles --- ...6a0507fc3079724bec3eddb594dffc35b7bc.json} | 12 +- ...ba4d930f3708ddcdefba47232de3c9f3299c.json} | 12 +- ...707525105d413d39a212c5fc9303cf93c5b8.json} | 5 +- ...0ab01b72d06f4546d23a457bce446c816364.json} | 5 +- ...993b0705c357513333d11f85478f93d2d068.json} | 12 +- ...1271cef2248c19fec642fb39d9e69af81bc74.json | 70 +++++++++++ ...c5c15807573a8fa73c11fedb53b00c5c4509.json} | 12 +- .../migrations/007_add_linkedin_account.sql | 2 + .../application/commands/create_profile.rs | 1 + .../application/commands/update_profile.rs | 28 +++++ 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 | 50 ++++++-- backend/tests/profile_tests.rs | 113 ++++++++++++++++++ 17 files changed, 309 insertions(+), 23 deletions(-) rename backend/.sqlx/{query-d8259958a9c38fd2c6b0ad35793c51a9cb3d50d557d90c55b95195b1dbbd09b2.json => query-18a941abe96017e4e2821c4fb19b6a0507fc3079724bec3eddb594dffc35b7bc.json} (75%) rename backend/.sqlx/{query-ba666476f8eec313de6b35bd1066f501cd3cc1ad7eb72b0ed7edc853035b14c9.json => query-256e9ed74cc3b0d545a12a411d15ba4d930f3708ddcdefba47232de3c9f3299c.json} (75%) rename backend/.sqlx/{query-a582b89bc07b2332c1b196a4ade6ec7583e1b104a6ba83184a7841b2f198520d.json => query-c32c56360547eeb07fcdf55eeb19707525105d413d39a212c5fc9303cf93c5b8.json} (66%) rename backend/.sqlx/{query-1acdd97ec41e3fa9cc9319bf680c5952a127f82436d0ddc0b71f7afb3b530462.json => query-ca1107c288ed29a6f6b515ba55bf0ab01b72d06f4546d23a457bce446c816364.json} (56%) rename backend/.sqlx/{query-d70adcade77f15e4b624296cbfe36c7710eac0b12924c32843d201def30961e6.json => query-d1cd034daa1d27503453e39e1798993b0705c357513333d11f85478f93d2d068.json} (76%) create mode 100644 backend/.sqlx/query-e179be7ac3fb9f92e828aaf23d61271cef2248c19fec642fb39d9e69af81bc74.json rename backend/.sqlx/{query-7a8afc2b7d71942781088cdd0a2b2bc19ea56fc80bcaaf030a058c85414ea2a7.json => query-e3ae1657b22e7882866d43271cbcc5c15807573a8fa73c11fedb53b00c5c4509.json} (77%) create mode 100644 backend/migrations/007_add_linkedin_account.sql diff --git a/backend/.sqlx/query-d8259958a9c38fd2c6b0ad35793c51a9cb3d50d557d90c55b95195b1dbbd09b2.json b/backend/.sqlx/query-18a941abe96017e4e2821c4fb19b6a0507fc3079724bec3eddb594dffc35b7bc.json similarity index 75% rename from backend/.sqlx/query-d8259958a9c38fd2c6b0ad35793c51a9cb3d50d557d90c55b95195b1dbbd09b2.json rename to backend/.sqlx/query-18a941abe96017e4e2821c4fb19b6a0507fc3079724bec3eddb594dffc35b7bc.json index 0aa82a8..50ae27e 100644 --- a/backend/.sqlx/query-d8259958a9c38fd2c6b0ad35793c51a9cb3d50d557d90c55b95195b1dbbd09b2.json +++ b/backend/.sqlx/query-18a941abe96017e4e2821c4fb19b6a0507fc3079724bec3eddb594dffc35b7bc.json @@ -1,6 +1,6 @@ { "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(github_login) = LOWER($1)\n ", + "query": "\n SELECT address, name, description, avatar_url, github_login, twitter_handle, linkedin_account, created_at, updated_at\n FROM profiles\n WHERE LOWER(github_login) = LOWER($1)\n ", "describe": { "columns": [ { @@ -35,11 +35,16 @@ }, { "ordinal": 6, + "name": "linkedin_account", + "type_info": "Text" + }, + { + "ordinal": 7, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 7, + "ordinal": 8, "name": "updated_at", "type_info": "Timestamptz" } @@ -57,8 +62,9 @@ true, true, true, + true, true ] }, - "hash": "d8259958a9c38fd2c6b0ad35793c51a9cb3d50d557d90c55b95195b1dbbd09b2" + "hash": "18a941abe96017e4e2821c4fb19b6a0507fc3079724bec3eddb594dffc35b7bc" } diff --git a/backend/.sqlx/query-ba666476f8eec313de6b35bd1066f501cd3cc1ad7eb72b0ed7edc853035b14c9.json b/backend/.sqlx/query-256e9ed74cc3b0d545a12a411d15ba4d930f3708ddcdefba47232de3c9f3299c.json similarity index 75% rename from backend/.sqlx/query-ba666476f8eec313de6b35bd1066f501cd3cc1ad7eb72b0ed7edc853035b14c9.json rename to backend/.sqlx/query-256e9ed74cc3b0d545a12a411d15ba4d930f3708ddcdefba47232de3c9f3299c.json index 1036378..e96ca3f 100644 --- a/backend/.sqlx/query-ba666476f8eec313de6b35bd1066f501cd3cc1ad7eb72b0ed7edc853035b14c9.json +++ b/backend/.sqlx/query-256e9ed74cc3b0d545a12a411d15ba4d930f3708ddcdefba47232de3c9f3299c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT address, name, description, avatar_url, github_login, twitter_handle, created_at, updated_at\n FROM profiles\n WHERE address = $1\n ", + "query": "\n SELECT address, name, description, avatar_url, github_login, twitter_handle, linkedin_account, created_at, updated_at\n FROM profiles\n WHERE LOWER(twitter_handle) = LOWER($1)\n ", "describe": { "columns": [ { @@ -35,11 +35,16 @@ }, { "ordinal": 6, + "name": "linkedin_account", + "type_info": "Text" + }, + { + "ordinal": 7, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 7, + "ordinal": 8, "name": "updated_at", "type_info": "Timestamptz" } @@ -57,8 +62,9 @@ true, true, true, + true, true ] }, - "hash": "ba666476f8eec313de6b35bd1066f501cd3cc1ad7eb72b0ed7edc853035b14c9" + "hash": "256e9ed74cc3b0d545a12a411d15ba4d930f3708ddcdefba47232de3c9f3299c" } diff --git a/backend/.sqlx/query-a582b89bc07b2332c1b196a4ade6ec7583e1b104a6ba83184a7841b2f198520d.json b/backend/.sqlx/query-c32c56360547eeb07fcdf55eeb19707525105d413d39a212c5fc9303cf93c5b8.json similarity index 66% rename from backend/.sqlx/query-a582b89bc07b2332c1b196a4ade6ec7583e1b104a6ba83184a7841b2f198520d.json rename to backend/.sqlx/query-c32c56360547eeb07fcdf55eeb19707525105d413d39a212c5fc9303cf93c5b8.json index 041bd11..b212dc3 100644 --- a/backend/.sqlx/query-a582b89bc07b2332c1b196a4ade6ec7583e1b104a6ba83184a7841b2f198520d.json +++ b/backend/.sqlx/query-c32c56360547eeb07fcdf55eeb19707525105d413d39a212c5fc9303cf93c5b8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "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 ", + "query": "\n UPDATE profiles\n SET name = $2, description = $3, avatar_url = $4, github_login = $5, twitter_handle = $6, linkedin_account = $7, updated_at = $8\n WHERE address = $1\n ", "describe": { "columns": [], "parameters": { @@ -11,10 +11,11 @@ "Text", "Text", "Text", + "Text", "Timestamptz" ] }, "nullable": [] }, - "hash": "a582b89bc07b2332c1b196a4ade6ec7583e1b104a6ba83184a7841b2f198520d" + "hash": "c32c56360547eeb07fcdf55eeb19707525105d413d39a212c5fc9303cf93c5b8" } diff --git a/backend/.sqlx/query-1acdd97ec41e3fa9cc9319bf680c5952a127f82436d0ddc0b71f7afb3b530462.json b/backend/.sqlx/query-ca1107c288ed29a6f6b515ba55bf0ab01b72d06f4546d23a457bce446c816364.json similarity index 56% rename from backend/.sqlx/query-1acdd97ec41e3fa9cc9319bf680c5952a127f82436d0ddc0b71f7afb3b530462.json rename to backend/.sqlx/query-ca1107c288ed29a6f6b515ba55bf0ab01b72d06f4546d23a457bce446c816364.json index 93d81d9..d60d2d9 100644 --- a/backend/.sqlx/query-1acdd97ec41e3fa9cc9319bf680c5952a127f82436d0ddc0b71f7afb3b530462.json +++ b/backend/.sqlx/query-ca1107c288ed29a6f6b515ba55bf0ab01b72d06f4546d23a457bce446c816364.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "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 ", + "query": "\n INSERT INTO profiles (address, name, description, avatar_url, github_login, twitter_handle, linkedin_account, login_nonce, created_at, updated_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n ", "describe": { "columns": [], "parameters": { @@ -11,6 +11,7 @@ "Text", "Text", "Text", + "Text", "Int8", "Timestamptz", "Timestamptz" @@ -18,5 +19,5 @@ }, "nullable": [] }, - "hash": "1acdd97ec41e3fa9cc9319bf680c5952a127f82436d0ddc0b71f7afb3b530462" + "hash": "ca1107c288ed29a6f6b515ba55bf0ab01b72d06f4546d23a457bce446c816364" } diff --git a/backend/.sqlx/query-d70adcade77f15e4b624296cbfe36c7710eac0b12924c32843d201def30961e6.json b/backend/.sqlx/query-d1cd034daa1d27503453e39e1798993b0705c357513333d11f85478f93d2d068.json similarity index 76% rename from backend/.sqlx/query-d70adcade77f15e4b624296cbfe36c7710eac0b12924c32843d201def30961e6.json rename to backend/.sqlx/query-d1cd034daa1d27503453e39e1798993b0705c357513333d11f85478f93d2d068.json index a85e90d..b7ae91c 100644 --- a/backend/.sqlx/query-d70adcade77f15e4b624296cbfe36c7710eac0b12924c32843d201def30961e6.json +++ b/backend/.sqlx/query-d1cd034daa1d27503453e39e1798993b0705c357513333d11f85478f93d2d068.json @@ -1,6 +1,6 @@ { "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 ", + "query": "\n SELECT address, name, description, avatar_url, github_login, twitter_handle, linkedin_account, created_at, updated_at\n FROM profiles\n WHERE address = $1\n ", "describe": { "columns": [ { @@ -35,11 +35,16 @@ }, { "ordinal": 6, + "name": "linkedin_account", + "type_info": "Text" + }, + { + "ordinal": 7, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 7, + "ordinal": 8, "name": "updated_at", "type_info": "Timestamptz" } @@ -57,8 +62,9 @@ true, true, true, + true, true ] }, - "hash": "d70adcade77f15e4b624296cbfe36c7710eac0b12924c32843d201def30961e6" + "hash": "d1cd034daa1d27503453e39e1798993b0705c357513333d11f85478f93d2d068" } diff --git a/backend/.sqlx/query-e179be7ac3fb9f92e828aaf23d61271cef2248c19fec642fb39d9e69af81bc74.json b/backend/.sqlx/query-e179be7ac3fb9f92e828aaf23d61271cef2248c19fec642fb39d9e69af81bc74.json new file mode 100644 index 0000000..ebaf481 --- /dev/null +++ b/backend/.sqlx/query-e179be7ac3fb9f92e828aaf23d61271cef2248c19fec642fb39d9e69af81bc74.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT address, name, description, avatar_url, github_login, twitter_handle, linkedin_account, created_at, updated_at\n FROM profiles\n WHERE LOWER(linkedin_account) = 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": "linkedin_account", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "e179be7ac3fb9f92e828aaf23d61271cef2248c19fec642fb39d9e69af81bc74" +} diff --git a/backend/.sqlx/query-7a8afc2b7d71942781088cdd0a2b2bc19ea56fc80bcaaf030a058c85414ea2a7.json b/backend/.sqlx/query-e3ae1657b22e7882866d43271cbcc5c15807573a8fa73c11fedb53b00c5c4509.json similarity index 77% rename from backend/.sqlx/query-7a8afc2b7d71942781088cdd0a2b2bc19ea56fc80bcaaf030a058c85414ea2a7.json rename to backend/.sqlx/query-e3ae1657b22e7882866d43271cbcc5c15807573a8fa73c11fedb53b00c5c4509.json index 63e4b62..ae61b1c 100644 --- a/backend/.sqlx/query-7a8afc2b7d71942781088cdd0a2b2bc19ea56fc80bcaaf030a058c85414ea2a7.json +++ b/backend/.sqlx/query-e3ae1657b22e7882866d43271cbcc5c15807573a8fa73c11fedb53b00c5c4509.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT address, name, description, avatar_url, github_login, twitter_handle, created_at, updated_at\n FROM profiles\n ", + "query": "\n SELECT address, name, description, avatar_url, github_login, twitter_handle, linkedin_account, created_at, updated_at\n FROM profiles\n ", "describe": { "columns": [ { @@ -35,11 +35,16 @@ }, { "ordinal": 6, + "name": "linkedin_account", + "type_info": "Text" + }, + { + "ordinal": 7, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 7, + "ordinal": 8, "name": "updated_at", "type_info": "Timestamptz" } @@ -55,8 +60,9 @@ true, true, true, + true, true ] }, - "hash": "7a8afc2b7d71942781088cdd0a2b2bc19ea56fc80bcaaf030a058c85414ea2a7" + "hash": "e3ae1657b22e7882866d43271cbcc5c15807573a8fa73c11fedb53b00c5c4509" } diff --git a/backend/migrations/007_add_linkedin_account.sql b/backend/migrations/007_add_linkedin_account.sql new file mode 100644 index 0000000..b86db30 --- /dev/null +++ b/backend/migrations/007_add_linkedin_account.sql @@ -0,0 +1,2 @@ +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS linkedin_account TEXT; +CREATE UNIQUE INDEX IF NOT EXISTS unique_linkedin_account_lower ON profiles (LOWER(linkedin_account)); diff --git a/backend/src/application/commands/create_profile.rs b/backend/src/application/commands/create_profile.rs index ad54b34..f157dd6 100644 --- a/backend/src/application/commands/create_profile.rs +++ b/backend/src/application/commands/create_profile.rs @@ -36,6 +36,7 @@ pub async fn create_profile( avatar_url: profile.avatar_url, github_login: profile.github_login, twitter_handle: profile.twitter_handle, + linkedin_account: profile.linkedin_account, 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 3244273..4ab6c9a 100644 --- a/backend/src/application/commands/update_profile.rs +++ b/backend/src/application/commands/update_profile.rs @@ -68,6 +68,33 @@ pub async fn update_profile( profile.twitter_handle = Some(trimmed.to_string()); } } + if let Some(ref account) = request.linkedin_account { + let trimmed = account.trim(); + + // Allow empty accounts (set to None) + if trimmed.is_empty() { + profile.linkedin_account = None; + } else { + // Validate format for non-empty LinkedIn accounts + let valid_format = regex::Regex::new(r"^[a-zA-Z0-9-]{3,100}$").unwrap(); + if !valid_format.is_match(trimmed) { + return Err("Invalid LinkedIn account format".to_string()); + } + + let normalized = trimmed.to_lowercase(); + if let Some(conflicting_profile) = profile_repository + .find_by_linkedin_account(normalized.as_str()) + .await + .map_err(|e| e.to_string())? + { + if conflicting_profile.address != wallet_address { + return Err("LinkedIn account already taken".to_string()); + } + } + + profile.linkedin_account = Some(trimmed.to_string()); + } + } profile_repository .update(&profile) .await @@ -80,6 +107,7 @@ pub async fn update_profile( avatar_url: profile.avatar_url, github_login: profile.github_login, twitter_handle: profile.twitter_handle, + linkedin_account: profile.linkedin_account, 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 9f4dcb6..f8e91b9 100644 --- a/backend/src/application/dtos/profile_dtos.rs +++ b/backend/src/application/dtos/profile_dtos.rs @@ -16,6 +16,7 @@ pub struct UpdateProfileRequest { pub avatar_url: Option, pub github_login: Option, pub twitter_handle: Option, + pub linkedin_account: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -26,6 +27,7 @@ pub struct ProfileResponse { pub avatar_url: Option, pub github_login: Option, pub twitter_handle: Option, + pub linkedin_account: 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 ae26048..d462bf0 100644 --- a/backend/src/application/queries/get_all_profiles.rs +++ b/backend/src/application/queries/get_all_profiles.rs @@ -19,6 +19,7 @@ pub async fn get_all_profiles( avatar_url: profile.avatar_url, github_login: profile.github_login, twitter_handle: profile.twitter_handle, + linkedin_account: profile.linkedin_account, 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 3837014..03bf8da 100644 --- a/backend/src/application/queries/get_profile.rs +++ b/backend/src/application/queries/get_profile.rs @@ -22,6 +22,7 @@ pub async fn get_profile( avatar_url: profile.avatar_url, github_login: profile.github_login, twitter_handle: profile.twitter_handle, + linkedin_account: profile.linkedin_account, 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 667d9ac..4c01062 100644 --- a/backend/src/domain/entities/profile.rs +++ b/backend/src/domain/entities/profile.rs @@ -11,6 +11,7 @@ pub struct Profile { pub avatar_url: Option, pub github_login: Option, pub twitter_handle: Option, + pub linkedin_account: Option, pub login_nonce: i64, pub created_at: DateTime, pub updated_at: DateTime, @@ -26,6 +27,7 @@ impl Profile { avatar_url: None, github_login: None, twitter_handle: None, + linkedin_account: 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 8462497..8ce1d81 100644 --- a/backend/src/domain/repositories/profile_repository.rs +++ b/backend/src/domain/repositories/profile_repository.rs @@ -20,6 +20,10 @@ pub trait ProfileRepository: Send + Sync { &self, twitter_handle: &str, ) -> Result, Box>; + async fn find_by_linkedin_account( + &self, + linkedin_account: &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 72bb3e7..09dc159 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, twitter_handle, created_at, updated_at + SELECT address, name, description, avatar_url, github_login, twitter_handle, linkedin_account, created_at, updated_at FROM profiles WHERE address = $1 "#, @@ -41,6 +41,7 @@ impl ProfileRepository for PostgresProfileRepository { avatar_url: r.avatar_url, github_login: r.github_login, twitter_handle: r.twitter_handle, + linkedin_account: r.linkedin_account, login_nonce: 0, // Not needed for regular profile queries created_at: r.created_at.unwrap(), updated_at: r.updated_at.unwrap(), @@ -50,7 +51,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, twitter_handle, created_at, updated_at + SELECT address, name, description, avatar_url, github_login, twitter_handle, linkedin_account, created_at, updated_at FROM profiles "#, ) @@ -67,6 +68,7 @@ impl ProfileRepository for PostgresProfileRepository { avatar_url: r.avatar_url, github_login: r.github_login, twitter_handle: r.twitter_handle, + linkedin_account: r.linkedin_account, login_nonce: 0, // Not needed for regular profile queries created_at: r.created_at.unwrap(), updated_at: r.updated_at.unwrap(), @@ -77,8 +79,8 @@ 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, twitter_handle, login_nonce, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + INSERT INTO profiles (address, name, description, avatar_url, github_login, twitter_handle, linkedin_account, login_nonce, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) "#, profile.address.as_str(), profile.name, @@ -86,6 +88,7 @@ impl ProfileRepository for PostgresProfileRepository { profile.avatar_url, profile.github_login, profile.twitter_handle, + profile.linkedin_account, profile.login_nonce, profile.created_at, profile.updated_at @@ -101,7 +104,7 @@ impl ProfileRepository for PostgresProfileRepository { sqlx::query!( r#" UPDATE profiles - SET name = $2, description = $3, avatar_url = $4, github_login = $5, twitter_handle = $6, updated_at = $7 + SET name = $2, description = $3, avatar_url = $4, github_login = $5, twitter_handle = $6, linkedin_account = $7, updated_at = $8 WHERE address = $1 "#, profile.address.as_str(), @@ -110,6 +113,7 @@ impl ProfileRepository for PostgresProfileRepository { profile.avatar_url, profile.github_login, profile.twitter_handle, + profile.linkedin_account, profile.updated_at ) .execute(&self.pool) @@ -140,7 +144,7 @@ impl ProfileRepository for PostgresProfileRepository { ) -> Result, Box> { let row = sqlx::query!( r#" - SELECT address, name, description, avatar_url, github_login, twitter_handle, created_at, updated_at + SELECT address, name, description, avatar_url, github_login, twitter_handle, linkedin_account, created_at, updated_at FROM profiles WHERE LOWER(github_login) = LOWER($1) "#, @@ -157,6 +161,7 @@ impl ProfileRepository for PostgresProfileRepository { avatar_url: r.avatar_url, github_login: r.github_login, twitter_handle: r.twitter_handle, + linkedin_account: r.linkedin_account, login_nonce: 0, // Not needed for regular profile queries created_at: r.created_at.unwrap(), updated_at: r.updated_at.unwrap(), @@ -169,7 +174,7 @@ impl ProfileRepository for PostgresProfileRepository { ) -> Result, Box> { let row = sqlx::query!( r#" - SELECT address, name, description, avatar_url, github_login, twitter_handle, created_at, updated_at + SELECT address, name, description, avatar_url, github_login, twitter_handle, linkedin_account, created_at, updated_at FROM profiles WHERE LOWER(twitter_handle) = LOWER($1) "#, @@ -186,6 +191,37 @@ impl ProfileRepository for PostgresProfileRepository { avatar_url: r.avatar_url, github_login: r.github_login, twitter_handle: r.twitter_handle, + linkedin_account: r.linkedin_account, + 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_linkedin_account( + &self, + linkedin_account: &str, + ) -> Result, Box> { + let row = sqlx::query!( + r#" + SELECT address, name, description, avatar_url, github_login, twitter_handle, linkedin_account, created_at, updated_at + FROM profiles + WHERE LOWER(linkedin_account) = LOWER($1) + "#, + linkedin_account + ) + .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, + linkedin_account: r.linkedin_account, 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 9ba38d0..6adeb75 100644 --- a/backend/tests/profile_tests.rs +++ b/backend/tests/profile_tests.rs @@ -77,6 +77,22 @@ mod github_handle_tests { .cloned()) } + async fn find_by_linkedin_account( + &self, + linkedin_account: &str, + ) -> Result, Box> { + let lower = linkedin_account.to_lowercase(); + let list = self.profiles.lock().unwrap(); + Ok(list + .iter() + .find(|&p| { + p.linkedin_account + .as_ref() + .is_some_and(|h| h.to_lowercase() == lower) + }) + .cloned()) + } + async fn get_login_nonce_by_wallet_address( &self, _address: &WalletAddress, @@ -103,6 +119,7 @@ mod github_handle_tests { avatar_url: None, github_login: None, twitter_handle: None, + linkedin_account: None, login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), @@ -118,6 +135,7 @@ mod github_handle_tests { avatar_url: None, github_login: Some("GitUser123".into()), twitter_handle: None, + linkedin_account: None, }; let result = update_profile(repo.clone(), profile.address.to_string(), req).await; @@ -136,6 +154,7 @@ mod github_handle_tests { avatar_url: None, github_login: None, twitter_handle: None, + linkedin_account: None, login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), @@ -151,6 +170,7 @@ mod github_handle_tests { avatar_url: None, github_login: Some("bad@name".into()), twitter_handle: None, + linkedin_account: None, }; let err = update_profile(repo.clone(), profile.address.to_string(), req).await; @@ -170,6 +190,7 @@ mod github_handle_tests { avatar_url: None, github_login: Some("Alice".into()), twitter_handle: None, + linkedin_account: None, login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), @@ -182,6 +203,7 @@ mod github_handle_tests { avatar_url: None, github_login: None, twitter_handle: None, + linkedin_account: None, login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), @@ -198,6 +220,7 @@ mod github_handle_tests { avatar_url: None, github_login: Some("alice".into()), twitter_handle: None, + linkedin_account: None, }; let err = update_profile(repo.clone(), profile2.address.to_string(), req).await; @@ -216,6 +239,7 @@ mod github_handle_tests { avatar_url: None, github_login: Some("BobUser".into()), twitter_handle: None, + linkedin_account: None, login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), @@ -231,6 +255,7 @@ mod github_handle_tests { avatar_url: None, github_login: Some("".into()), twitter_handle: None, + linkedin_account: None, }; let result = update_profile(repo.clone(), profile.address.to_string(), req).await; @@ -249,6 +274,7 @@ mod github_handle_tests { avatar_url: None, github_login: Some("CharlieGit".into()), twitter_handle: None, + linkedin_account: None, login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), @@ -264,6 +290,7 @@ mod github_handle_tests { avatar_url: None, github_login: Some("CharlieGit".into()), twitter_handle: None, + linkedin_account: None, }; let result = update_profile(repo.clone(), profile.address.to_string(), req).await; @@ -282,6 +309,7 @@ mod github_handle_tests { avatar_url: None, github_login: None, twitter_handle: None, + linkedin_account: None, login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), @@ -296,6 +324,7 @@ mod github_handle_tests { avatar_url: None, github_login: None, twitter_handle: Some("tushar".into()), + linkedin_account: None, }; let result = update_profile(repo.clone(), profile.address.to_string(), req).await; @@ -314,6 +343,7 @@ mod github_handle_tests { avatar_url: None, github_login: None, twitter_handle: None, + linkedin_account: None, login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), @@ -329,6 +359,7 @@ mod github_handle_tests { avatar_url: None, github_login: None, twitter_handle: Some("@invalid".into()), + linkedin_account: None, }; let err = update_profile(repo.clone(), profile.address.to_string(), req).await; @@ -346,6 +377,7 @@ mod github_handle_tests { avatar_url: None, github_login: None, twitter_handle: Some("TakenHandle".into()), + linkedin_account: None, login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), @@ -358,6 +390,7 @@ mod github_handle_tests { avatar_url: None, github_login: None, twitter_handle: None, + linkedin_account: None, login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), @@ -373,10 +406,90 @@ mod github_handle_tests { avatar_url: None, github_login: None, twitter_handle: Some("takenhandle".into()), + linkedin_account: None, }; 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")); } + + #[tokio::test] + async fn invalid_linkedin_account_rejected() { + let profile = Profile { + address: WalletAddress::new("0x2234567890123456789012345678901234567897".to_string()) + .unwrap(), + name: None, + description: None, + avatar_url: None, + github_login: None, + twitter_handle: None, + linkedin_account: 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: None, + linkedin_account: Some("bad@account".into()), + }; + + let err = update_profile(repo.clone(), profile.address.to_string(), req).await; + assert!(err.is_err()); + assert!(err.unwrap_err().contains("Invalid LinkedIn account format")); + } + + #[tokio::test] + async fn linkedin_account_conflict_rejected_case_insensitive() { + let profile1 = Profile { + address: WalletAddress::new("0x3234567890123456789012345678901234567898".to_string()) + .unwrap(), + name: None, + description: None, + avatar_url: None, + github_login: None, + twitter_handle: None, + linkedin_account: Some("LinkedInUser".into()), + login_nonce: 1, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let profile2 = Profile { + address: WalletAddress::new("0x4234567890123456789012345678901234567899".to_string()) + .unwrap(), + name: None, + description: None, + avatar_url: None, + github_login: None, + twitter_handle: None, + linkedin_account: 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()]), + }); + + let req = UpdateProfileRequest { + name: None, + description: None, + avatar_url: None, + github_login: None, + twitter_handle: None, + linkedin_account: Some("linkedinuser".into()), + }; + + let err = update_profile(repo.clone(), profile2.address.to_string(), req).await; + assert!(err.is_err()); + assert!(err.unwrap_err().contains("LinkedIn account already taken")); + } } From 79fedaa589c0763b944f4b041554380a994e20df Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Mon, 2 Mar 2026 11:13:48 +0000 Subject: [PATCH 2/2] test: add linkedin create/update coverage --- .../application/commands/create_profile.rs | 26 +++++++ backend/src/application/dtos/profile_dtos.rs | 1 + backend/tests/profile_tests.rs | 70 ++++++++++++++++++- 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/backend/src/application/commands/create_profile.rs b/backend/src/application/commands/create_profile.rs index f157dd6..3052515 100644 --- a/backend/src/application/commands/create_profile.rs +++ b/backend/src/application/commands/create_profile.rs @@ -2,6 +2,7 @@ use crate::application::dtos::profile_dtos::{CreateProfileRequest, ProfileRespon use crate::domain::entities::profile::Profile; use crate::domain::repositories::profile_repository::ProfileRepository; use crate::domain::value_objects::wallet_address::WalletAddress; +use regex; use std::sync::Arc; pub async fn create_profile( @@ -24,6 +25,31 @@ pub async fn create_profile( let mut profile = Profile::new(wallet_address.clone()); profile.update_info(Some(request.name), request.description, request.avatar_url); + if let Some(ref account) = request.linkedin_account { + let trimmed = account.trim(); + + if trimmed.is_empty() { + profile.linkedin_account = None; + } else { + let valid_format = regex::Regex::new(r"^[a-zA-Z0-9-]{3,100}$").unwrap(); + if !valid_format.is_match(trimmed) { + return Err("Invalid LinkedIn account format".to_string()); + } + + let normalized = trimmed.to_lowercase(); + if profile_repository + .find_by_linkedin_account(normalized.as_str()) + .await + .map_err(|e| e.to_string())? + .is_some() + { + return Err("LinkedIn account already taken".to_string()); + } + + profile.linkedin_account = Some(trimmed.to_string()); + } + } + profile_repository .create(&profile) .await diff --git a/backend/src/application/dtos/profile_dtos.rs b/backend/src/application/dtos/profile_dtos.rs index f8e91b9..debcc9f 100644 --- a/backend/src/application/dtos/profile_dtos.rs +++ b/backend/src/application/dtos/profile_dtos.rs @@ -7,6 +7,7 @@ pub struct CreateProfileRequest { pub name: String, pub description: Option, pub avatar_url: Option, + pub linkedin_account: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/backend/tests/profile_tests.rs b/backend/tests/profile_tests.rs index 6adeb75..cf94e9e 100644 --- a/backend/tests/profile_tests.rs +++ b/backend/tests/profile_tests.rs @@ -1,7 +1,10 @@ #[cfg(test)] mod github_handle_tests { + use guild_backend::application::commands::create_profile::create_profile; use guild_backend::application::commands::update_profile::update_profile; - use guild_backend::application::dtos::profile_dtos::UpdateProfileRequest; + use guild_backend::application::dtos::profile_dtos::{ + CreateProfileRequest, UpdateProfileRequest, + }; use guild_backend::domain::entities::profile::Profile; use guild_backend::domain::repositories::profile_repository::ProfileRepository; use guild_backend::domain::value_objects::WalletAddress; @@ -27,8 +30,10 @@ mod github_handle_tests { Ok(list.clone()) } - async fn create(&self, _profile: &Profile) -> Result<(), Box> { - unimplemented!() + async fn create(&self, profile: &Profile) -> Result<(), Box> { + let mut list = self.profiles.lock().unwrap(); + list.push(profile.clone()); + Ok(()) } async fn update(&self, profile: &Profile) -> Result<(), Box> { @@ -108,6 +113,31 @@ mod github_handle_tests { } } + #[tokio::test] + async fn create_profile_with_linkedin_account_succeeds() { + let repo = Arc::new(FakeRepo { + profiles: std::sync::Mutex::new(vec![]), + }); + + let req = CreateProfileRequest { + name: "Alice".into(), + description: Some("Profile with LinkedIn".into()), + avatar_url: None, + linkedin_account: Some("LinkedInUser123".into()), + }; + + let result = create_profile( + repo.clone(), + "0x5234567890123456789012345678901234567890".to_string(), + req, + ) + .await; + + assert!(result.is_ok()); + let resp = result.unwrap(); + assert_eq!(resp.linkedin_account, Some("LinkedInUser123".to_string())); + } + #[tokio::test] async fn valid_github_handle_succeeds() { // Setup repo with a user @@ -492,4 +522,38 @@ mod github_handle_tests { assert!(err.is_err()); assert!(err.unwrap_err().contains("LinkedIn account already taken")); } + + #[tokio::test] + async fn valid_linkedin_account_update_succeeds() { + let profile = Profile { + address: WalletAddress::new("0x6234567890123456789012345678901234567890".to_string()) + .unwrap(), + name: Some("Eve".into()), + description: None, + avatar_url: None, + github_login: None, + twitter_handle: None, + linkedin_account: 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: None, + linkedin_account: Some("Eve-LinkedIn".into()), + }; + + let result = update_profile(repo.clone(), profile.address.to_string(), req).await; + assert!(result.is_ok()); + let resp = result.unwrap(); + assert_eq!(resp.linkedin_account, Some("Eve-LinkedIn".to_string())); + } }