diff --git a/src/handlers/address.rs b/src/handlers/address.rs new file mode 100644 index 0000000..3c1d715 --- /dev/null +++ b/src/handlers/address.rs @@ -0,0 +1,143 @@ +use axum::{ + extract::{self, State}, + Json, +}; + +use crate::{ + http_server::AppState, + models::{ + address::{Address, AddressInput, NewAddressPayload}, + }, + utils::generate_referral_code::generate_referral_code, + AppError, +}; + +use super::SuccessResponse; + +pub async fn handle_add_address( + State(state): State, + extract::Json(payload): Json, +) -> Result>, AppError> { + tracing::info!("Creating address struct..."); + + let referral_code = generate_referral_code(payload.quan_address.clone()).await?; + let input = AddressInput { + quan_address: payload.quan_address, + eth_address: None, + referral_code, + }; + + let address_data = Address::new(input)?; + + let created_id = state.db.addresses.create(&address_data).await?; + + Ok(SuccessResponse::new(created_id)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{config::Config, db_persistence::DbPersistence, models::ModelError}; + use std::sync::Arc; + + // Helper to set up a test AppState with a connection to a clean test DB. + async fn setup_test_app_state() -> AppState { + let config = Config::load().expect("Failed to load test configuration"); + let db = DbPersistence::new(config.get_database_url()).await.unwrap(); + + // Clean tables for test isolation + sqlx::query("TRUNCATE addresses, referrals, tasks RESTART IDENTITY CASCADE") + .execute(&db.pool) + .await + .unwrap(); + + AppState { db: Arc::new(db) } + } + + #[tokio::test] + async fn test_add_address_success() { + // Arrange + let state = setup_test_app_state().await; + let payload = NewAddressPayload { + quan_address: "qz_a_valid_and_long_address_string".to_string(), + }; + + // Act: Call the handler function directly. + let result = handle_add_address(State(state.clone()), Json(payload.clone())).await; + + // Assert: Check the handler's response. + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!( + response.data, payload.quan_address, + "Expected the created address ID to be returned" + ); + + // Assert: Verify the address was correctly saved to the database. + let created_address = state + .db + .addresses + .find_by_id(&payload.quan_address) + .await + .unwrap(); + + assert!(created_address.is_some(), "Address was not found in the database"); + let address_data = created_address.unwrap(); + assert!(!address_data.referral_code.is_empty(), "A referral code should have been generated"); + } + + #[tokio::test] + async fn test_add_address_invalid_input() { + // Arrange + let state = setup_test_app_state().await; + // This address is too short and will fail validation inside `Address::new`. + let payload = NewAddressPayload { + quan_address: "qzshort".to_string(), + }; + + // Act + let result = handle_add_address(State(state.clone()), Json(payload)).await; + + // Assert + assert!(result.is_err()); + let error = result.unwrap_err(); + // Check that it's the expected validation error. + assert!(matches!(error, AppError::Model(ModelError::InvalidInput))); + + // Verify that no records were created in the database. + let addresses = state.db.addresses.find_all().await.unwrap(); + assert!(addresses.is_empty(), "No address should be created on validation failure"); + } + + #[tokio::test] + async fn test_add_address_handles_conflict() { + // Arrange + let state = setup_test_app_state().await; + let address_string = "qz_an_existing_address_for_conflict".to_string(); + + // Manually create an address first. + let initial_address = Address::new(AddressInput { + quan_address: address_string.clone(), + eth_address: None, + referral_code: "INITIAL_CODE".to_string(), + }).unwrap(); + state.db.addresses.create(&initial_address).await.unwrap(); + + // Create a payload with the same address. + let payload = NewAddressPayload { + quan_address: address_string.clone(), + }; + + // Act: Call the handler with the duplicate address. + let result = handle_add_address(State(state.clone()), Json(payload)).await; + + // Assert: The operation should still be successful due to ON CONFLICT. + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.data, address_string); + + // Verify there is still only one address in the database. + let addresses = state.db.addresses.find_all().await.unwrap(); + assert_eq!(addresses.len(), 1, "No duplicate address should be created"); + } +} \ No newline at end of file diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index a8b0f90..41441d7 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -2,6 +2,7 @@ use axum::Json; use serde::Serialize; pub mod referral; +pub mod address; #[derive(Debug, Serialize)] pub struct SuccessResponse { diff --git a/src/handlers/referral.rs b/src/handlers/referral.rs index 025ce75..62028cb 100644 --- a/src/handlers/referral.rs +++ b/src/handlers/referral.rs @@ -36,18 +36,17 @@ pub async fn handle_add_referral( let referral = Referral::new(referral_data)?; - if let Ok(referral_code) = generate_referral_code(referral.referee_address.0.clone()).await - { - tracing::info!("Creating referee address struct..."); - let referee = Address::new(AddressInput { - quan_address: referral.referee_address.0.clone(), - eth_address: None, - referral_code, - })?; - - tracing::info!("Saving referee address to DB..."); - state.db.addresses.create(&referee).await?; - } + let referral_code = generate_referral_code(referral.referee_address.0.clone()).await?; + + tracing::info!("Creating referee address struct..."); + let referee = Address::new(AddressInput { + quan_address: referral.referee_address.0.clone(), + eth_address: None, + referral_code, + })?; + + tracing::info!("Saving referee address to DB..."); + state.db.addresses.create(&referee).await?; state.db.referrals.create(&referral).await?; diff --git a/src/models/address.rs b/src/models/address.rs index fb8cb48..de564c6 100644 --- a/src/models/address.rs +++ b/src/models/address.rs @@ -99,3 +99,8 @@ pub struct AddressInput { pub eth_address: Option, pub referral_code: String, } + +#[derive(Debug,Clone, Deserialize)] +pub struct NewAddressPayload { + pub quan_address: String, +} diff --git a/src/routes/address.rs b/src/routes/address.rs new file mode 100644 index 0000000..0c06f40 --- /dev/null +++ b/src/routes/address.rs @@ -0,0 +1,7 @@ +use axum::{routing::post, Router}; + +use crate::{handlers::address::handle_add_address, http_server::AppState}; + +pub fn address_routes() -> Router { + Router::new().route("/addresses", post(handle_add_address)) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index fe821eb..23d9d12 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,10 +1,13 @@ use axum::Router; use referral::referral_routes; -use crate::http_server::AppState; +use crate::{http_server::AppState, routes::address::address_routes}; +pub mod address; pub mod referral; pub fn api_routes() -> Router { - Router::new().merge(referral_routes()) + Router::new() + .merge(referral_routes()) + .merge(address_routes()) } diff --git a/src/utils/generate_referral_code.rs b/src/utils/generate_referral_code.rs index 7681a72..c16cd03 100644 --- a/src/utils/generate_referral_code.rs +++ b/src/utils/generate_referral_code.rs @@ -2,10 +2,12 @@ use qp_human_checkphrase::{address_to_checksum, load_bip39_list}; use std::sync::OnceLock; use tokio::task; +use crate::models::ModelError; + // Create a static OnceLock instance for caching bip39 load. static WORD_LIST: OnceLock> = OnceLock::new(); -pub async fn generate_referral_code(address: String) -> Result { +pub async fn generate_referral_code(address: String) -> Result { let result = task::spawn_blocking(move || { // The closure `|| { ... }` is only executed on the very first call. // `expect` is used here because if the word list can't load, @@ -23,7 +25,7 @@ pub async fn generate_referral_code(address: String) -> Result { Err(join_error) => { eprintln!("Blocking task failed to execute: {}", join_error); - Err(format!("Task execution failed: {}", join_error)) + Err(ModelError::FailedGenerateCheckphrase) } } }