Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions src/handlers/address.rs
Original file line number Diff line number Diff line change
@@ -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<AppState>,
extract::Json(payload): Json<NewAddressPayload>,
) -> Result<Json<SuccessResponse<String>>, 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");
}
}
1 change: 1 addition & 0 deletions src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use axum::Json;
use serde::Serialize;

pub mod referral;
pub mod address;

#[derive(Debug, Serialize)]
pub struct SuccessResponse<T> {
Expand Down
23 changes: 11 additions & 12 deletions src/handlers/referral.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;

Expand Down
5 changes: 5 additions & 0 deletions src/models/address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,8 @@ pub struct AddressInput {
pub eth_address: Option<String>,
pub referral_code: String,
}

#[derive(Debug,Clone, Deserialize)]
pub struct NewAddressPayload {
pub quan_address: String,
}
7 changes: 7 additions & 0 deletions src/routes/address.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
use axum::{routing::post, Router};

use crate::{handlers::address::handle_add_address, http_server::AppState};

pub fn address_routes() -> Router<AppState> {
Router::new().route("/addresses", post(handle_add_address))
}
7 changes: 5 additions & 2 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
@@ -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<AppState> {
Router::new().merge(referral_routes())
Router::new()
.merge(referral_routes())
.merge(address_routes())
}
6 changes: 4 additions & 2 deletions src/utils/generate_referral_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>> = OnceLock::new();

pub async fn generate_referral_code(address: String) -> Result<String, String> {
pub async fn generate_referral_code(address: String) -> Result<String, ModelError> {
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,
Expand All @@ -23,7 +25,7 @@ pub async fn generate_referral_code(address: String) -> Result<String, String> {

Err(join_error) => {
eprintln!("Blocking task failed to execute: {}", join_error);
Err(format!("Task execution failed: {}", join_error))
Err(ModelError::FailedGenerateCheckphrase)
}
}
}