diff --git a/src/http/mod.rs b/src/http/mod.rs index 5212af92..b577f8b7 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -19,6 +19,7 @@ mod project; mod support_ticket; mod transaction; mod types; +mod validator; #[derive(Clone)] pub struct AppState { @@ -53,6 +54,7 @@ pub fn api_router(app_state: AppState) -> Router { .merge(support_ticket::router()) .merge(escrow::router()) .merge(newsletter::router()) + .merge(validator::router()) .layer(trace_layer) .layer(request_id_layer) .layer(propagate_request_id_layer) @@ -80,8 +82,5 @@ async fn shutdown_signal() { #[cfg(not(unix))] let terminate = std::future::pending::<()>(); - tokio::select! { - _ = ctrl_c => {}, - _ = terminate => {}, - } + tokio::select! {_ = ctrl_c => {}, _ = terminate => {},} } diff --git a/src/http/validator/delete_profile.rs b/src/http/validator/delete_profile.rs new file mode 100644 index 00000000..b652367d --- /dev/null +++ b/src/http/validator/delete_profile.rs @@ -0,0 +1,148 @@ +use crate::{ + AppState, Error, Result, + http::validator::{DeleteValidatorProfileRequest, DeleteValidatorProfileResponse}, +}; +use axum::{Json, extract::State, http::StatusCode}; +use garde::Validate; +use sqlx::PgPool; +use uuid::Uuid; + +#[tracing::instrument(name = "Delete Validator Profile", skip(state, request))] +pub async fn delete_validator_profile( + State(state): State, + Json(request): Json, +) -> Result<(StatusCode, Json)> { + request.validate()?; + + tracing::info!( + wallet_address = %request.wallet_address, + "Attempting to delete validator profile" + ); + + // First, verify the validator profile exists + let validator = + get_validator_by_wallet_address(&state.db.pool, &request.wallet_address).await?; + + if validator.is_none() { + tracing::warn!( + wallet_address = %request.wallet_address, + "Validator profile not found" + ); + return Err(Error::NotFound); + } + + let validator = validator.unwrap(); + let deletion_time = chrono::Utc::now(); + + // Perform the deletion with all related data cleanup + delete_validator_and_related_data(&state.db.pool, &validator.id, &request.wallet_address) + .await?; + + tracing::info!( + validator_id = %validator.id, + wallet_address = %request.wallet_address, + "Successfully deleted validator profile and all related data" + ); + + Ok(( + StatusCode::OK, + Json(DeleteValidatorProfileResponse { + message: "Validator profile successfully deleted".to_string(), + validator_id: validator.id, + deleted_at: deletion_time, + }), + )) +} + +#[derive(Debug, sqlx::FromRow)] +struct ValidatorInfo { + id: Uuid, +} + +async fn get_validator_by_wallet_address( + pool: &PgPool, + wallet_address: &str, +) -> Result> { + let validator = sqlx::query_as::<_, ValidatorInfo>( + r#" + SELECT id + FROM validator_profiles + WHERE wallet_address = $1 + "#, + ) + .bind(wallet_address) + .fetch_optional(pool) + .await?; + + Ok(validator) +} + +async fn delete_validator_and_related_data( + pool: &PgPool, + validator_id: &Uuid, + wallet_address: &str, +) -> Result<()> { + let mut tx = pool.begin().await?; + + // Delete from validator_expertise (many-to-many relationship) + let expertise_deleted = sqlx::query("DELETE FROM validator_expertise WHERE validator_id = $1") + .bind(validator_id) + .execute(&mut *tx) + .await?; + + tracing::debug!( + validator_id = %validator_id, + expertise_rows_deleted = expertise_deleted.rows_affected(), + "Deleted validator expertise relationships" + ); + + // Delete from validator_programming_languages (many-to-many relationship) + let languages_deleted = + sqlx::query("DELETE FROM validator_programming_languages WHERE validator_id = $1") + .bind(validator_id) + .execute(&mut *tx) + .await?; + + tracing::debug!( + validator_id = %validator_id, + language_rows_deleted = languages_deleted.rows_affected(), + "Deleted validator programming language relationships" + ); + + // Finally, delete the validator profile itself + let profile_deleted = sqlx::query("DELETE FROM validator_profiles WHERE id = $1") + .bind(validator_id) + .execute(&mut *tx) + .await?; + + if profile_deleted.rows_affected() == 0 { + tracing::error!( + validator_id = %validator_id, + "Failed to delete validator profile - no rows affected" + ); + return Err(Error::InternalServerError(anyhow::anyhow!( + "Failed to delete validator profile" + ))); + } + + // Also remove from escrow_users if they exist there + let escrow_deleted = sqlx::query("DELETE FROM escrow_users WHERE wallet_address = $1") + .bind(wallet_address) + .execute(&mut *tx) + .await?; + + tracing::debug!( + validator_id = %validator_id, + escrow_deleted = escrow_deleted.rows_affected(), + "Deleted validator from escrow_users if present" + ); + + tx.commit().await?; + + tracing::info!( + validator_id = %validator_id, + "Successfully committed validator profile deletion transaction" + ); + + Ok(()) +} diff --git a/src/http/validator/domain.rs b/src/http/validator/domain.rs new file mode 100644 index 00000000..88603684 --- /dev/null +++ b/src/http/validator/domain.rs @@ -0,0 +1,27 @@ +use garde::Validate; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, Validate)] +pub struct DeleteValidatorProfileRequest { + #[garde(custom(validate_starknet_address))] + pub wallet_address: String, +} + +#[derive(Debug, Serialize)] +pub struct DeleteValidatorProfileResponse { + pub message: String, + pub validator_id: Uuid, + pub deleted_at: chrono::DateTime, +} + +pub fn validate_starknet_address(address: &str, _context: &()) -> garde::Result { + if address.starts_with("0x") + && address.len() == 66 + && address.chars().skip(2).all(|c| c.is_ascii_hexdigit()) + { + Ok(()) + } else { + Err(garde::Error::new("Invalid Starknet address")) + } +} diff --git a/src/http/validator/mod.rs b/src/http/validator/mod.rs new file mode 100644 index 00000000..0239d92a --- /dev/null +++ b/src/http/validator/mod.rs @@ -0,0 +1,14 @@ +mod delete_profile; +mod domain; + +use axum::{Router, routing::delete}; +pub use domain::*; + +use crate::AppState; + +pub(crate) fn router() -> Router { + Router::new().route( + "/validator/profile/delete", + delete(delete_profile::delete_validator_profile), + ) +} diff --git a/tests/api/main.rs b/tests/api/main.rs index b7d69542..86da4f78 100644 --- a/tests/api/main.rs +++ b/tests/api/main.rs @@ -7,3 +7,4 @@ mod newsletter; mod projects; mod support_tickets; mod transaction; +mod validator; diff --git a/tests/api/validator.rs b/tests/api/validator.rs new file mode 100644 index 00000000..7d8f6667 --- /dev/null +++ b/tests/api/validator.rs @@ -0,0 +1,195 @@ +use crate::helpers::{TestApp, generate_address}; +use axum::{body::Body, extract::Request, http::StatusCode}; +use serde_json::json; +use uuid::Uuid; + +#[tokio::test] +async fn test_delete_validator_profile_success() { + let app = TestApp::new().await; + let db = &app.db; + + // Create a test validator profile + let wallet_address = generate_address(); + let validator_id = Uuid::now_v7(); + + // Insert validator profile + sqlx::query( + r#" + INSERT INTO validator_profiles ( + id, wallet_address, government_name, date_of_birth, nationality, + email_address, mobile_number, years_of_experience, resume_path, + country, document, document_front_path, document_back_path + ) VALUES ( + $1, $2, 'John Doe', '1990-01-01', 'American', + 'john.doe@example.com', '+1234567890', 5, '/path/to/resume.pdf', + 'United States', 'passport', '/path/to/front.jpg', '/path/to/back.jpg' + ) + "#, + ) + .bind(validator_id) + .bind(&wallet_address) + .execute(&db.pool) + .await + .expect("Failed to insert test validator profile"); + + // Insert some related data + let language_id = sqlx::query_scalar::<_, i32>( + "INSERT INTO programming_languages (name) VALUES ('Rust') RETURNING id", + ) + .fetch_one(&db.pool) + .await + .expect("Failed to insert programming language"); + + sqlx::query( + "INSERT INTO validator_programming_languages (validator_id, language_id) VALUES ($1, $2)", + ) + .bind(validator_id) + .bind(language_id) + .execute(&db.pool) + .await + .expect("Failed to insert validator programming language"); + + let expertise_id = sqlx::query_scalar::<_, i32>( + "INSERT INTO expertise (name) VALUES ('Smart Contract Auditing') RETURNING id", + ) + .fetch_one(&db.pool) + .await + .expect("Failed to insert expertise"); + + sqlx::query("INSERT INTO validator_expertise (validator_id, expertise_id) VALUES ($1, $2)") + .bind(validator_id) + .bind(expertise_id) + .execute(&db.pool) + .await + .expect("Failed to insert validator expertise"); + + // Add validator to escrow_users + sqlx::query("INSERT INTO escrow_users (wallet_address, balance) VALUES ($1, 0.0)") + .bind(&wallet_address) + .execute(&db.pool) + .await + .expect("Failed to insert escrow user"); + + // Prepare delete request + let payload = json!({ + "wallet_address": wallet_address + }); + + let req = Request::builder() + .method("DELETE") + .uri("/validator/profile/delete") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(); + + let res = app.request(req).await; + let status = res.status(); + + assert_eq!(status, StatusCode::OK); + + // Verify the validator profile has been deleted + let profile_exists = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM validator_profiles WHERE wallet_address = $1", + ) + .bind(&wallet_address) + .fetch_one(&db.pool) + .await + .expect("Failed to check validator profile existence"); + + assert_eq!(profile_exists, 0); + + // Verify related data has been cleaned up + let lang_relations = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM validator_programming_languages WHERE validator_id = $1", + ) + .bind(validator_id) + .fetch_one(&db.pool) + .await + .expect("Failed to check language relations"); + + assert_eq!(lang_relations, 0); + + let expertise_relations = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM validator_expertise WHERE validator_id = $1", + ) + .bind(validator_id) + .fetch_one(&db.pool) + .await + .expect("Failed to check expertise relations"); + + assert_eq!(expertise_relations, 0); + + // Verify escrow user has been removed + let escrow_exists = + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM escrow_users WHERE wallet_address = $1") + .bind(&wallet_address) + .fetch_one(&db.pool) + .await + .expect("Failed to check escrow user existence"); + + assert_eq!(escrow_exists, 0); +} + +#[tokio::test] +async fn test_delete_validator_profile_not_found() { + let app = TestApp::new().await; + let non_existent_address = generate_address(); + + let payload = json!({ + "wallet_address": non_existent_address + }); + + let req = Request::builder() + .method("DELETE") + .uri("/validator/profile/delete") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(); + + let res = app.request(req).await; + let status = res.status(); + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_delete_validator_profile_invalid_address() { + let app = TestApp::new().await; + + let payload = json!({ + "wallet_address": "invalid_address" + }); + + let req = Request::builder() + .method("DELETE") + .uri("/validator/profile/delete") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(); + + let res = app.request(req).await; + let status = res.status(); + + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_delete_validator_profile_missing_wallet_address() { + let app = TestApp::new().await; + + let payload = json!({ + // Missing wallet_address field + }); + + let req = Request::builder() + .method("DELETE") + .uri("/validator/profile/delete") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(); + + let res = app.request(req).await; + let status = res.status(); + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); // 422 for missing required field +}