From b11533e8196232b0c4e290f8df00acb6107beefa Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Fri, 4 Jul 2025 17:19:36 +0800 Subject: [PATCH] added unit tests --- .env | 3 + Cargo.lock | 233 ++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 6 ++ README.md | 10 +- src/handlers.rs | 17 ++- src/{main.rs => lib.rs} | 16 +-- src/tests.rs | 149 +++++++++++++++++++++++++ 7 files changed, 402 insertions(+), 32 deletions(-) rename src/{main.rs => lib.rs} (80%) create mode 100644 src/tests.rs diff --git a/.env b/.env index 5a9fb5d..04fd3f9 100644 --- a/.env +++ b/.env @@ -4,3 +4,6 @@ PGUSER=postgres PGHOST=127.0.0.1 PGPASSWORD=password INTRASYS_LOG=trace + +#Only use for tests +DATABASE_URL=postgres://postgres:password@localhost:5432 diff --git a/Cargo.lock b/Cargo.lock index a8fadf0..cec00fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,6 +47,22 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "atoi" version = "2.0.0" @@ -56,6 +72,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "auto-future" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" + [[package]] name = "autocfg" version = "1.5.0" @@ -116,6 +138,36 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-test" +version = "17.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb1dfb84bd48bad8e4aa1acb82ed24c2bb5e855b659959b4e03b4dca118fcac" +dependencies = [ + "anyhow", + "assert-json-diff", + "auto-future", + "axum", + "bytes", + "bytesize", + "cookie", + "http", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower", + "url", +] + [[package]] name = "backtrace" version = "0.3.75" @@ -193,6 +245,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "bytesize" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3c8f83209414aacf0eeae3cf730b18d6981697fba62f200fcfb92b9f082acba" + [[package]] name = "cc" version = "1.2.27" @@ -235,6 +293,16 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -301,6 +369,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -638,6 +721,7 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", ] [[package]] @@ -647,13 +731,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" dependencies = [ "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "hyper", + "libc", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -802,6 +891,7 @@ name = "intrasys" version = "0.1.0" dependencies = [ "axum", + "axum-test", "bigdecimal", "dotenv", "serde", @@ -983,11 +1073,17 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.5", "smallvec", "zeroize", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-integer" version = "0.1.46" @@ -1131,6 +1227,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1140,6 +1242,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -1171,8 +1283,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -1182,7 +1304,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -1194,6 +1326,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + [[package]] name = "redox_syscall" version = "0.5.13" @@ -1247,6 +1388,15 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "reserve-port" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" +dependencies = [ + "thiserror", +] + [[package]] name = "rsa" version = "0.9.8" @@ -1260,13 +1410,28 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", "zeroize", ] +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http", + "mime", + "rand 0.9.1", + "thiserror", +] + [[package]] name = "rustc-demangle" version = "0.1.25" @@ -1398,7 +1563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1563,7 +1728,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.5", "rsa", "serde", "sha1", @@ -1604,7 +1769,7 @@ dependencies = [ "memchr", "num-bigint", "once_cell", - "rand", + "rand 0.8.5", "serde", "serde_json", "sha2", @@ -1721,6 +1886,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -1878,6 +2074,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.18.0" @@ -1957,6 +2159,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2290,6 +2501,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index a28da6a..40ba3ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,3 +15,9 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } dotenv = "0.15" bigdecimal = { version = "0.4", features = ["serde"] } + +[lib] +crate-type = ["bin"] + +[dev-dependencies] +axum-test = "17.3" diff --git a/README.md b/README.md index c066aaa..4b5d127 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Intrasys is an internal financial transfers application built with Rust and Axum ### 1. Provision PostgreSQL Database -Run a PostgreSQL container with Docker: +The easiest way is to run a PostgreSQL container with Docker: ```bash docker run --name intrasys-pg -d -e POSTGRES_PASSWORD=password -p 127.0.0.1:5432:5432 postgres:alpine @@ -101,14 +101,6 @@ The application will be available at `http://localhost:3000` sqlx migrate run ``` -## Testing - -Run the test suite with: - -```bash -cargo test -``` - ## Assumptions - All accounts use the same currency diff --git a/src/handlers.rs b/src/handlers.rs index 49ebc10..a13aba7 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -1,8 +1,9 @@ use std::str::FromStr; use axum::{ - Json, + Json, Router, extract::{Path, State}, + routing::{get, post}, }; use bigdecimal::BigDecimal; use sqlx::types::BigDecimal as SqlxBigDecimal; @@ -14,7 +15,7 @@ use crate::{ models::{Account, CreateAccount, TransactionRequest}, }; -pub(crate) async fn create_account( +async fn create_account( State(pool): State, Json(payload): Json, ) -> Result<(), AppError> { @@ -48,7 +49,7 @@ pub(crate) async fn create_account( Ok(()) } -pub(crate) async fn get_account( +async fn get_account( State(pool): State, Path(account_id): Path, ) -> Result, AppError> { @@ -74,7 +75,7 @@ pub(crate) async fn get_account( Ok(Json(account)) } -pub(crate) async fn process_transaction( +async fn process_transaction( State(pool): State, Json(payload): Json, ) -> Result<(), AppError> { @@ -165,3 +166,11 @@ pub(crate) async fn process_transaction( Ok(()) } + +pub(crate) fn create_router(pool: DbPool) -> Router { + Router::new() + .route("/accounts", post(create_account)) + .route("/accounts/{account_id}", get(get_account)) + .route("/transactions", post(process_transaction)) + .with_state(pool) +} diff --git a/src/main.rs b/src/lib.rs similarity index 80% rename from src/main.rs rename to src/lib.rs index e0f9759..e67addd 100644 --- a/src/main.rs +++ b/src/lib.rs @@ -1,10 +1,5 @@ -use std::env; - -use axum::{ - Router, - routing::{get, post}, -}; use dotenv::dotenv; +use std::env; use tracing::info; use tracing_subscriber::layer::SubscriberExt; @@ -34,11 +29,7 @@ async fn main() { .expect("Failed to run migrations"); // Build the application's routes - let app = Router::new() - .route("/accounts", post(handlers::create_account)) - .route("/accounts/{account_id}", get(handlers::get_account)) - .route("/transactions", post(handlers::process_transaction)) - .with_state(pool); + let app = handlers::create_router(pool); // Run the server let host = env::var("INTRASYS_HOST").unwrap_or(String::from("127.0.0.1")); @@ -49,3 +40,6 @@ async fn main() { info!("listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); } + +#[cfg(test)] +mod tests; diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..c1746db --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,149 @@ +use std::str::FromStr; + +use crate::handlers::create_router; +use axum::http::StatusCode; +use axum_test::TestServer; +use bigdecimal::BigDecimal; +use serde_json::{Value, json}; +use sqlx::{Pool, Postgres}; + +#[sqlx::test] +async fn test_get_account(pool: Pool) { + let app = create_router(pool.clone()); + let server = TestServer::new(app).unwrap(); + + let response = server.get("/accounts/3").await; + + //Account does not exist yet + assert_eq!(response.status_code(), StatusCode::NOT_FOUND); + // Create test account + sqlx::query("INSERT INTO accounts (account_id, balance) VALUES (3, 150.50)") + .execute(&pool) + .await + .unwrap(); + + let response = server.get("/accounts/3").await; + + assert_eq!(response.status_code(), StatusCode::OK); + + let account: Value = response.json(); + assert_eq!(3, account["account_id"]); + assert_eq!( + BigDecimal::from_str("150.50"), + BigDecimal::from_str(account["balance"].as_str().unwrap()) + ); + let response = server.get("/accounts/4").await; + + //Account does not exist + assert_eq!(response.status_code(), StatusCode::NOT_FOUND); +} + +#[sqlx::test] +async fn test_create_account_success(pool: Pool) { + let app = create_router(pool.clone()); + let server = TestServer::new(app).unwrap(); + + let response = server + .post("/accounts") + .json(&json!({ + "account_id": 1, + "initial_balance": "100.00" + })) + .await; + + assert_eq!(response.status_code(), StatusCode::OK); + + // Verify account was created + let account = sqlx::query_as::<_, (i64, BigDecimal)>( + "SELECT account_id, balance FROM accounts WHERE account_id = 1", + ) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(account.0, 1); + assert_eq!(account.1, BigDecimal::from(100)); +} + +#[sqlx::test] +async fn test_process_transaction_success(pool: Pool) { + let app = create_router(pool.clone()); + let server = TestServer::new(app).unwrap(); + + // Create test accounts + sqlx::query("INSERT INTO accounts (account_id, balance) VALUES (4, 200.00), (5, 50.00)") + .execute(&pool) + .await + .unwrap(); + + let response = server + .post("/transactions") + .json(&json!({ + "source_account_id": 4, + "destination_account_id": 5, + "amount": "75.25" + })) + .await; + + assert_eq!(response.status_code(), StatusCode::OK); + + // Verify balances were updated + let source = sqlx::query_as::<_, (i64, BigDecimal)>( + "SELECT account_id, balance FROM accounts WHERE account_id = 4", + ) + .fetch_one(&pool) + .await + .unwrap(); + + let destination = sqlx::query_as::<_, (i64, BigDecimal)>( + "SELECT account_id, balance FROM accounts WHERE account_id = 5", + ) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(source.1, BigDecimal::from_str("124.75").unwrap()); + assert_eq!(destination.1, BigDecimal::from_str("125.25").unwrap()); +} + +#[sqlx::test] +async fn test_process_transaction_insufficient_funds(pool: Pool) { + let app = create_router(pool.clone()); + let server = TestServer::new(app).unwrap(); + + // Create test accounts + sqlx::query("INSERT INTO accounts (account_id, balance) VALUES (6, 50.00), (7, 100.00)") + .execute(&pool) + .await + .unwrap(); + + let response = server + .post("/transactions") + .json(&json!({ + "source_account_id": 6, + "destination_account_id": 7, + "amount": "75.00" + })) + .await; + + assert_eq!(response.status_code(), StatusCode::BAD_REQUEST); + assert_eq!(response.text(), "Insufficient funds"); + + // Verify balances were not changed + let source = sqlx::query_as::<_, (i64, BigDecimal)>( + "SELECT account_id, balance FROM accounts WHERE account_id = 6", + ) + .fetch_one(&pool) + .await + .unwrap(); + + let destination = sqlx::query_as::<_, (i64, BigDecimal)>( + "SELECT account_id, balance FROM accounts WHERE account_id = 7", + ) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(source.1, BigDecimal::from_str("50.00").unwrap()); + assert_eq!(destination.1, BigDecimal::from_str("100.00").unwrap()); +}