diff --git a/Cargo.lock b/Cargo.lock index 71e6017..8acc40b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -187,6 +187,16 @@ dependencies = [ "term", ] +[[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 = "async-channel" version = "2.3.1" @@ -333,6 +343,12 @@ dependencies = [ "rustc_version", ] +[[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.4.0" @@ -394,6 +410,36 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-test" +version = "17.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317c1f4ecc1e68e0ad5decb78478421055c963ce215e736ed97463fa609cd196" +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.74" @@ -455,7 +501,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -464,6 +519,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.9.0" @@ -595,6 +656,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bytesize" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2c12f985c78475a6b8d629afd0c360260ef34cfef52efccdcfd31972f81c2e" + [[package]] name = "castaway" version = "0.2.3" @@ -759,6 +826,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[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" version = "0.9.4" @@ -915,6 +992,12 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc55fe0d1f6c107595572ec8b107c0999bb1a2e0b75e37429a4fb0d6474a0e7d" +[[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" @@ -1079,6 +1162,17 @@ dependencies = [ "tempfile", ] +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1878,7 +1972,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" dependencies = [ "ascii-canvas", - "bit-set", + "bit-set 0.5.3", "ena", "itertools 0.11.0", "lalrpop-util", @@ -2169,7 +2263,9 @@ dependencies = [ "anyhow", "argon2", "axum", + "axum-test", "chrono", + "fancy-regex", "jsonwebtoken", "lazy_static", "lettre", @@ -2628,6 +2724,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[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-macro-crate" version = "3.3.0" @@ -3037,6 +3143,16 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "reserve-port" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "359fc315ed556eb0e42ce74e76f4b1cd807b50fa6307f3de4e51f92dbe86e2d5" +dependencies = [ + "lazy_static", + "thiserror 2.0.12", +] + [[package]] name = "revision" version = "0.10.0" @@ -3220,6 +3336,22 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc4bb9e7c9abe5fa5f30c2d8f8fefb9e0080a2c1e3c2e567318d2907054b35d3" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http", + "mime", + "mime_guess", + "rand 0.9.0", + "thiserror 2.0.12", +] + [[package]] name = "rust-stemmers" version = "1.2.0" @@ -5072,6 +5204,12 @@ dependencies = [ "tap", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.7.5" diff --git a/Cargo.toml b/Cargo.toml index 9b143b1..36929cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,8 @@ tower-http = { version = "0.6.2", features = ["cors"] } validator = { version = "0.12", features = ["derive"] } lazy_static = "1.4.0" regex = "1.11.1" +axum-test = "17.2.0" +fancy-regex = "0.14.0" [profile.release] lto = "fat" diff --git a/src/apps/v1/auth/auth_controller_test.rs b/src/apps/v1/auth/auth_controller_test.rs new file mode 100644 index 0000000..85e15ac --- /dev/null +++ b/src/apps/v1/auth/auth_controller_test.rs @@ -0,0 +1,601 @@ +use crate::{ + apps::v1::auth::auth_router, create_mock_app_state, create_test_user, + encode_refresh_token, AuthOtpSchema, OtpManager, ResourceEnum, +}; +use axum::{http::StatusCode, Extension}; +use axum_test::TestServer; +use serde_json::json; + +#[tokio::test] +async fn test_login_should_fail_with_invalid_user() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let payload = json!({ + "email": "nonexistent@example.com", + "password": "wrongpassword" + }); + let res = server.post("/v1/auth/login").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::UNAUTHORIZED); + let body = res.text(); + println!("🧪 Response Body: {}", body); +} + +#[tokio::test] +async fn test_login_should_fail_with_wrong_password() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state.clone())); + let server = TestServer::new(app).unwrap(); + let repo = crate::apps::v1::users::UsersRepository::new(&state); + let mut user = create_test_user("user@example.com", "User Satu", true); + user.password = crate::hash_password("correctpassword").unwrap(); + repo.query_create_user(user).await.unwrap(); + let payload = json!({ + "email": "user@example.com", + "password": "wrongpassword" + }); + let res = server.post("/v1/auth/login").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); + let body = res.text(); + println!("🧪 Wrong password: {}", body); +} + +#[tokio::test] +async fn test_login_should_fail_if_user_not_active() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state.clone())); + let server = TestServer::new(app).unwrap(); + let repo = crate::apps::v1::users::UsersRepository::new(&state); + let mut user = create_test_user("inactive@example.com", "Inactive User", false); + user.password = crate::hash_password("secret").unwrap(); + repo.query_create_user(user).await.unwrap(); + let payload = json!({ + "email": "inactive@example.com", + "password": "secret" + }); + let res = server.post("/v1/auth/login").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); + let body = res.text(); + println!("🧪 Not active user: {}", body); +} + +#[tokio::test] +async fn test_login_should_succeed() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state.clone())); + let server = TestServer::new(app).unwrap(); + + let repo = crate::apps::v1::users::UsersRepository::new(&state); + let mut user = create_test_user("active@example.com", "Active User", true); + user.password = crate::hash_password("secret").unwrap(); + repo.query_create_user(user).await.unwrap(); + let payload = json!({ + "email": "active@example.com", + "password": "secret" + }); + let res = server.post("/v1/auth/login").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::OK); + let json = res.json::(); + assert!(json["data"]["token"]["access_token"].is_string()); + assert!(json["data"]["user"]["email"] == "active@example.com"); +} + +#[tokio::test] +async fn test_login_should_fail_if_payload_empty() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let payload = json!({}); + let res = server.post("/v1/auth/login").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn test_login_should_fail_if_password_empty() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let payload = json!({ + "email": "test@test.com", + "password": "" + }); + let res = server.post("/v1/auth/login").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_login_should_fail_if_email_not_valid() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let payload = json!({ + "email": "test", + "password": "test123" + }); + let res = server.post("/v1/auth/login").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_register_should_succeed() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state.clone())); + let server = TestServer::new(app).unwrap(); + let payload = json!({ + "email": "validuser@example.com", + "password": "Validpass1!", + "fullname": "Valid User", + "student_type": "regular", + "phone_number": "0812345678", + "reffered_by": "Facebook", + "refferal_code": "KFNB" + }); + let res = server.post("/v1/auth/register").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::CREATED); +} + +#[tokio::test] +async fn test_register_should_fail_if_email_already_taken() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state.clone())); + let server = TestServer::new(app).unwrap(); + let repo = crate::apps::v1::users::UsersRepository::new(&state); + let mut user = create_test_user("duplicate@example.com", "User Exists", false); + user.password = crate::hash_password("secret").unwrap(); + repo.query_create_user(user).await.unwrap(); + let payload = json!({ + "fullname": "User Exists", + "email": "duplicate@example.com", + "password": "secret", + "student_type": "general", + "phone_number": "081234567890", + "referral_code": null, + "referred_by": null + }); + let res = server.post("/v1/auth/register").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_register_should_fail_if_email_invalid() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let payload = json!({ + "fullname": "Invalid Email", + "email": "invalid-email", + "password": "secret", + "student_type": "general", + "phone_number": "081234567890", + "referral_code": null, + "referred_by": null + }); + let res = server.post("/v1/auth/register").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_register_should_fail_if_password_empty() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + + let payload = json!({ + "fullname": "No Password", + "email": "nopass@example.com", + "password": "", + "student_type": "general", + "phone_number": "081234567890", + "referral_code": null, + "referred_by": null + }); + let res = server.post("/v1/auth/register").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_register_should_fail_if_payload_empty() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let payload = json!({}); + let res = server.post("/v1/auth/register").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn test_send_otp_should_succeed() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let payload = json!({ + "email": "user@example.com" + }); + let res = server.post("/v1/auth/send-otp").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::OK); + let json = res.json::(); + assert_eq!(json["message"], "OTP resent successfully"); +} + +#[tokio::test] +async fn test_send_otp_should_fail_if_email_empty() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let payload = json!({ "email": "" }); + let res = server.post("/v1/auth/send-otp").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); + let json = res.json::(); + assert_eq!(json["message"], "Email cannot be empty, Email not valid"); +} + +#[tokio::test] +async fn test_send_otp_should_fail_with_invalid_email_format() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + + let payload = json!({ "email": "not-an-email" }); + let res = server.post("/v1/auth/send-otp").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); + let json = res.json::(); + assert_eq!(json["message"], "Email not valid"); +} + +#[tokio::test] +async fn test_send_otp_should_fail_if_payload_empty() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let payload = json!({}); + let res = server.post("/v1/auth/send-otp").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn test_send_otp_should_fail_if_otp_store_fails() { + let mut state = create_mock_app_state().await; + state.surrealdb_mem = surrealdb::Surreal::new::(()) + .await + .expect("failed to create mock surrealdb_mem"); + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let payload = serde_json::json!({ "email": "fail-store@example.com" }); + let res = server.post("/v1/auth/send-otp").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); + let body = res.text(); + assert!( + body.contains("Specify a namespace to use") || body.contains("Failed store otp"), + "Expected namespace setup error or store failure, got: {body}" + ); +} + +#[tokio::test] +async fn test_forgot_password_should_succeed() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state.clone())); + let server = TestServer::new(app).unwrap(); + let mut user = create_test_user("forgot@example.com", "Forgot User", true); + user.password = crate::hash_password("secret").unwrap(); + let user_repo = crate::apps::v1::users::UsersRepository::new(&state); + user_repo.query_create_user(user).await.unwrap(); + let payload = json!({ "email": "forgot@example.com" }); + let res = server.post("/v1/auth/forgot").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::OK); + let body = res.text(); + assert!( + body.contains("Reset Password request send"), + "Unexpected response: {body}" + ); +} + +#[tokio::test] +async fn test_forgot_password_should_fail_if_user_not_found() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let payload = json!({ "email": "notfound@example.com" }); + let res = server.post("/v1/auth/forgot").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); + let body = res.text(); + assert!( + body.contains("User not found"), + "Expected 'User not found', got: {body}" + ); +} + +#[tokio::test] +async fn test_forgot_password_should_fail_if_email_invalid() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let payload = json!({ "email": "" }); + let res = server.post("/v1/auth/forgot").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); + let body = res.text(); + assert!( + body.contains("Email not valid") || body.contains("User not found"), + "Expected email error, got: {body}" + ); +} + +#[tokio::test] +async fn test_refresh_should_succeed() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let valid_token = encode_refresh_token("refreshme@example.com".into()).unwrap(); + let payload = json!({ + "refresh_token": valid_token + }); + let res = server.post("/v1/auth/refresh").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::OK); + let body = res.json::(); + assert!(body["data"]["access_token"].is_string()); + assert!(body["data"]["refresh_token"].is_string()); +} + +#[tokio::test] +async fn test_refresh_should_fail_with_invalid_token() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let payload = json!({ + "refresh_token": "invalid.token.value" + }); + let res = server.post("/v1/auth/refresh").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::UNAUTHORIZED); + let body = res.text(); + assert!(body.contains("Invalid refresh token")); +} + +#[tokio::test] +async fn test_refresh_should_fail_with_empty_payload() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let payload = json!({}); + let res = server.post("/v1/auth/refresh").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn test_verify_email_should_succeed() { + let state = create_mock_app_state().await; + let server = TestServer::new( + axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state.clone())), + ) + .unwrap(); + let user = create_test_user("verify@example.com", "Verify User", false); + let otp = OtpManager::generate_otp(); + let user_repo = crate::apps::v1::users::UsersRepository::new(&state); + let auth_repo = crate::apps::v1::auth::AuthRepository::new(&state); + user_repo.query_create_user(user.clone()).await.unwrap(); + auth_repo + .query_store_otp(user.email.clone(), otp) + .await + .unwrap(); + let payload = json!({ + "email": user.email, + "otp": otp + }); + let res = server.post("/v1/auth/verify-email").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::OK); + let body = res.text(); + assert!(body.contains("Email verified successfully")); +} + +#[tokio::test] +async fn test_verify_email_should_fail_with_wrong_otp() { + let state = create_mock_app_state().await; + let server = TestServer::new( + axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state.clone())), + ) + .unwrap(); + let user = create_test_user("wrongotp@example.com", "Wrong OTP", false); + let otp = OtpManager::generate_otp(); + let user_repo = crate::apps::v1::users::UsersRepository::new(&state); + let auth_repo = crate::apps::v1::auth::AuthRepository::new(&state); + user_repo.query_create_user(user.clone()).await.unwrap(); + auth_repo + .query_store_otp(user.email.clone(), otp) + .await + .unwrap(); + let payload = json!({ + "email": user.email, + "otp": 999_999 + }); + let res = server.post("/v1/auth/verify-email").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); + let body = res.text(); + assert!(body.contains("Failed to verify OTP")); +} + +#[tokio::test] +async fn test_verify_email_should_fail_if_otp_expired() { + let state = create_mock_app_state().await; + let server = TestServer::new( + axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state.clone())), + ) + .unwrap(); + let user = create_test_user("expired@example.com", "Expired OTP", false); + let user_repo = crate::apps::v1::users::UsersRepository::new(&state); + user_repo.query_create_user(user.clone()).await.unwrap(); + use chrono::{Duration, Utc}; + let expired_otp = crate::apps::v1::auth::AuthOtpSchema { + otp: 123456, + expires_at: Utc::now() - Duration::minutes(1), + }; + let _: Option = state + .surrealdb_mem + .create((ResourceEnum::OtpCache.to_string(), user.email.clone())) + .content(expired_otp) + .await + .unwrap(); + let payload = json!({ + "email": user.email, + "otp": 123456 + }); + let res = server.post("/v1/auth/verify-email").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); + let body = res.text(); + assert!(body.contains("OTP expired")); +} + +#[tokio::test] +async fn test_verify_email_should_fail_with_empty_payload() { + let state = create_mock_app_state().await; + let server = TestServer::new( + axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)), + ) + .unwrap(); + let res = server.post("/v1/auth/verify-email").json(&json!({})).await; + assert_eq!(res.status_code(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn test_new_password_should_succeed() { + let state = create_mock_app_state().await; + let server = TestServer::new( + axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state.clone())), + ) + .unwrap(); + let email = "resetme@example.com"; + let repo = crate::apps::v1::users::UsersRepository::new(&state); + let mut user = create_test_user(email, "Reset User", true); + user.password = crate::hash_password("oldpass123!").unwrap(); + repo.query_create_user(user).await.unwrap(); + let token = crate::encode_reset_password_token(email.to_string()).unwrap(); + let payload = json!({ + "token": token, + "password": "Newpass123!" + }); + let res = server.post("/v1/auth/new-password").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::OK); + let body = res.text(); + assert!(body.contains("Success update password user")); +} + +#[tokio::test] +async fn test_new_password_should_fail_if_token_is_empty() { + let state = create_mock_app_state().await; + let server = TestServer::new( + axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)), + ) + .unwrap(); + let payload = json!({ + "token": "", + "password": "Newpass123!" + }); + let res = server.post("/v1/auth/new-password").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); + let body = res.text(); + assert!( + body.contains("Invalid or missing token"), + "Gagal deteksi token kosong: {body}" + ); +} + +#[tokio::test] +async fn test_new_password_should_fail_if_password_is_weak() { + let state = create_mock_app_state().await; + let server = TestServer::new( + axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)), + ) + .unwrap(); + let email = "weakpass@example.com"; + let token = crate::encode_reset_password_token(email.to_string()).unwrap(); + let payload = json!({ + "token": token, + "password": "123" + }); + let res = server.post("/v1/auth/new-password").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); + let body = res.text(); + assert!( + body.contains("Password must have at least") + || body.contains("Password must include"), + "Pesan error nggak sesuai: {body}" + ); +} + +#[tokio::test] +async fn test_new_password_should_fail_if_user_not_found() { + let state = create_mock_app_state().await; + let server = TestServer::new( + axum::Router::new() + .nest("/v1/auth", auth_router()) + .layer(Extension(state)), + ) + .unwrap(); + let token = + crate::encode_reset_password_token("ghost@example.com".into()).unwrap(); + let payload = json!({ + "token": token, + "password": "Validpass123!" + }); + let res = server.post("/v1/auth/new-password").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); + let body = res.text(); + assert!(body.contains("User not found")); +} diff --git a/src/apps/v1/auth/auth_dto.rs b/src/apps/v1/auth/auth_dto.rs index c28006f..5712dc9 100644 --- a/src/apps/v1/auth/auth_dto.rs +++ b/src/apps/v1/auth/auth_dto.rs @@ -6,10 +6,7 @@ use utoipa::ToSchema; use validator::Validate; lazy_static! { - static ref PASSWORD_REGEX: Regex = Regex::new( - r"^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$" - ) - .unwrap(); + static ref PASSWORD_REGEX: Regex = Regex::new(r"^[A-Za-z\d@$!%*?&]{8,}$").unwrap(); } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] @@ -106,10 +103,7 @@ pub struct AuthRefreshTokenRequestDto { #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct AuthNewPasswordRequestDto { pub token: String, - #[validate(length( - min = 8, - message = "Password must have at least 8 characters" - ))] + #[validate(length(min = 1, message = "Token cannot be empty"))] #[validate(regex( path = "PASSWORD_REGEX", message = "Password must include uppercase, lowercase, number, and special character" diff --git a/src/apps/v1/auth/auth_repository.rs b/src/apps/v1/auth/auth_repository.rs index 960079a..a2b3c52 100644 --- a/src/apps/v1/auth/auth_repository.rs +++ b/src/apps/v1/auth/auth_repository.rs @@ -4,7 +4,7 @@ use anyhow::{anyhow, bail, Result}; use chrono::{Duration, Utc}; pub struct AuthRepository<'a> { - state: &'a AppState, + pub state: &'a AppState, } impl<'a> AuthRepository<'a> { @@ -13,6 +13,9 @@ impl<'a> AuthRepository<'a> { } pub async fn query_store_user(&self, user: UsersSchema) -> Result { + if user.email.trim().is_empty() { + bail!("Email is required"); + } let table = ResourceEnum::UsersCache.to_string(); let user_id = user.email.clone(); let id = make_thing(&table, &user_id); @@ -43,7 +46,7 @@ impl<'a> AuthRepository<'a> { } pub async fn query_delete_stored_user(&self, email: String) -> Result { - let record: Option = self + let record: Option = self .state .surrealdb_mem .delete((ResourceEnum::UsersCache.to_string(), email)) diff --git a/src/apps/v1/auth/auth_repository_test.rs b/src/apps/v1/auth/auth_repository_test.rs new file mode 100644 index 0000000..b988364 --- /dev/null +++ b/src/apps/v1/auth/auth_repository_test.rs @@ -0,0 +1,166 @@ +#[cfg(test)] +mod auth_repository_test { + use crate::{ + create_mock_app_state, make_thing, AuthOtpSchema, AuthRepository, ResourceEnum, + UsersSchema, + }; + use chrono::{Duration, Utc}; + use surrealdb::Uuid; + + fn create_mock_user(email: &str) -> UsersSchema { + UsersSchema { + id: make_thing("app_users", &Uuid::new_v4().to_string()), + email: email.to_string(), + fullname: "Test User".to_string(), + password: "password".to_string(), + is_deleted: false, + avatar: None, + phone_number: "081234567890".to_string(), + referral_code: None, + referred_by: None, + identity_number: None, + is_active: true, + student_type: "general".to_string(), + religion: None, + gender: None, + birthdate: None, + is_profile_completed: false, + role: make_thing("roles", "user"), + created_at: None, + updated_at: None, + } + } + + #[tokio::test] + async fn test_store_and_get_user() { + let app_state = create_mock_app_state().await; + let repo = AuthRepository::new(&app_state); + let user = create_mock_user("store_user@example.com"); + let store = repo.query_store_user(user.clone()).await; + assert!(store.is_ok()); + let fetched = repo.query_get_stored_user(user.email.clone()).await; + assert!(fetched.is_ok()); + assert_eq!(fetched.unwrap().email, user.email); + } + + #[tokio::test] + async fn test_delete_stored_user() { + let app_state = create_mock_app_state().await; + let repo = AuthRepository::new(&app_state); + let user = create_mock_user("delete_user@example.com"); + assert!(repo.query_store_user(user.clone()).await.is_ok()); + let deleted = repo.query_delete_stored_user(user.email.clone()).await; + assert!(deleted.is_ok()); + let result = repo.query_get_stored_user(user.email.clone()).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_store_and_get_otp() { + let app_state = create_mock_app_state().await; + let repo = AuthRepository::new(&app_state); + let email = "otp_user@example.com".to_string(); + let otp = 123456; + let stored = repo.query_store_otp(email.clone(), otp).await; + assert!(stored.is_ok()); + let fetched = repo.query_get_stored_otp(email.clone()).await; + assert!(fetched.is_ok()); + assert_eq!(fetched.unwrap(), otp); + } + + #[tokio::test] + async fn test_delete_stored_otp() { + let app_state = create_mock_app_state().await; + let repo = AuthRepository::new(&app_state); + let email = "otp_del@example.com".to_string(); + let otp = 654321; + repo.query_store_otp(email.clone(), otp).await.unwrap(); + let deleted = repo.query_delete_stored_otp(email.clone()).await; + assert!(deleted.is_ok()); + let fetched = repo.query_get_stored_otp(email.clone()).await; + assert!(fetched.is_err()); + } + + #[tokio::test] + async fn test_expired_otp() { + let app_state = create_mock_app_state().await; + let repo = AuthRepository::new(&app_state); + let email = "expired_otp@example.com".to_string(); + let otp = 789012; + let table = ResourceEnum::OtpCache.to_string(); + let expires_at = Utc::now() - Duration::seconds(1); + let _: Option = repo + .state + .surrealdb_mem + .create((table.clone(), email.as_str())) + .content(AuthOtpSchema { otp, expires_at }) + .await + .unwrap(); + let result = repo.query_get_stored_otp(email.clone()).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_store_user_with_empty_email_should_fail() { + let app_state = create_mock_app_state().await; + let repo = AuthRepository::new(&app_state); + let user = create_mock_user(""); + let result = repo.query_store_user(user).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_get_non_existent_stored_user_should_fail() { + let app_state = create_mock_app_state().await; + let repo = AuthRepository::new(&app_state); + let result = repo + .query_get_stored_user("not_found@example.com".into()) + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_delete_non_existent_user_should_fail() { + let app_state = create_mock_app_state().await; + let repo = AuthRepository::new(&app_state); + let result = repo + .query_delete_stored_user("ghost@example.com".into()) + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_get_expired_otp_should_fail() { + use chrono::Duration; + let app_state = create_mock_app_state().await; + let repo = AuthRepository::new(&app_state); + let email = "expired_otp@example.com"; + let expired_time = chrono::Utc::now() - Duration::seconds(10); + let otp = 123456; + let _: Option = repo + .state + .surrealdb_mem + .create((ResourceEnum::OtpCache.to_string(), email)) + .content(AuthOtpSchema { + otp, + expires_at: expired_time, + }) + .await + .unwrap(); + + let result = repo.query_get_stored_otp(email.into()).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_store_and_get_valid_otp() { + let app_state = create_mock_app_state().await; + let repo = AuthRepository::new(&app_state); + let email = "valid_otp@example.com"; + let otp = 654321; + let store_result = repo.query_store_otp(email.into(), otp).await; + assert!(store_result.is_ok()); + let get_result = repo.query_get_stored_otp(email.into()).await; + assert_eq!(get_result.unwrap(), otp); + } +} diff --git a/src/apps/v1/auth/auth_service.rs b/src/apps/v1/auth/auth_service.rs index cdb47d4..2315bc7 100644 --- a/src/apps/v1/auth/auth_service.rs +++ b/src/apps/v1/auth/auth_service.rs @@ -72,9 +72,20 @@ impl AuthService { let response = ResponseSuccessDto { data: AuthLoginResponsetDto { user: UsersItemDto { + id: user.id.id.to_raw(), fullname: user.fullname.clone(), email: user.email.clone(), - is_active: user.is_active, + is_active: user.is_active.clone(), + avatar: user.avatar.clone(), + phone_number: user.phone_number.clone(), + referral_code: user.referral_code.clone(), + referred_by: user.referred_by.clone(), + identity_number: user.identity_number.clone(), + student_type: user.student_type.clone(), + religion: user.religion.clone(), + gender: user.gender.clone(), + birthdate: user.birthdate.clone(), + is_profile_completed: user.is_profile_completed.clone(), }, token: TokenDto { access_token, @@ -97,6 +108,10 @@ impl AuthService { payload: AuthRegisterRequestDto, state: &AppState, ) -> Response { + if let Err((status, message)) = validate_request(&payload) { + return common_response(status, &message); + } + let user_repo = UsersRepository::new(state); let auth_repo = AuthRepository::new(state); @@ -188,6 +203,9 @@ impl AuthService { payload: AuthResendOtpRequestDto, state: &AppState, ) -> Response { + if let Err((status, message)) = validate_request(&payload) { + return common_response(status, &message); + } let repository = AuthRepository::new(state); let otp = generate_otp::OtpManager::generate_otp(); let message = format!("Your OTP code is {}", otp); @@ -203,6 +221,9 @@ impl AuthService { pub async fn mutation_refresh_token( payload: AuthRefreshTokenRequestDto, ) -> Response { + if let Err((status, message)) = validate_request(&payload) { + return common_response(status, &message); + } let email = match decode_refresh_token(&payload.refresh_token) { Ok(token) => token.claims.sub, Err(_) => { @@ -244,6 +265,9 @@ impl AuthService { payload: AuthResendOtpRequestDto, state: &AppState, ) -> Response { + if let Err((status, message)) = validate_request(&payload) { + return common_response(status, &message); + } let user_repo = UsersRepository::new(state); if user_repo .query_user_by_email(payload.email.clone()) @@ -278,6 +302,9 @@ impl AuthService { payload: AuthVerifyEmailRequestDto, state: &AppState, ) -> Response { + if let Err((status, message)) = validate_request(&payload) { + return common_response(status, &message); + } let user_repo = UsersRepository::new(state); let auth_repo = AuthRepository::new(state); let email = payload.email.clone(); @@ -314,9 +341,25 @@ impl AuthService { payload: AuthNewPasswordRequestDto, state: &AppState, ) -> Response { + if let Err((status, message)) = validate_request(&payload) { + return common_response(status, &message); + } let user_repo = UsersRepository::new(state); - let email = extract_email_token(payload.token).unwrap(); - let password = hash_password(&payload.password).unwrap(); + let email = match extract_email_token(payload.token.clone()) { + Some(email) => email, + None => { + return common_response(StatusCode::BAD_REQUEST, "Invalid or missing token"); + } + }; + let password = match hash_password(&payload.password) { + Ok(p) => p, + Err(_) => { + return common_response( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to hash password", + ); + } + }; match user_repo .query_update_password_user(email, UsersSetNewPasswordSchema { password }) .await diff --git a/src/apps/v1/auth/mod.rs b/src/apps/v1/auth/mod.rs index 8443a53..d1e9b6c 100644 --- a/src/apps/v1/auth/mod.rs +++ b/src/apps/v1/auth/mod.rs @@ -7,6 +7,11 @@ pub mod auth_repository; pub mod auth_schema; pub mod auth_service; +#[cfg(test)] +pub mod auth_controller_test; +#[cfg(test)] +pub mod auth_repository_test; + pub use auth_dto::*; pub use auth_repository::*; pub use auth_schema::*; diff --git a/src/apps/v1/users/mod.rs b/src/apps/v1/users/mod.rs index f26adcf..6434227 100644 --- a/src/apps/v1/users/mod.rs +++ b/src/apps/v1/users/mod.rs @@ -2,6 +2,9 @@ pub mod users_dto; pub mod users_repository; pub mod users_schema; +#[cfg(test)] +pub mod users_repository_test; + pub use users_dto::*; pub use users_repository::*; pub use users_schema::*; diff --git a/src/apps/v1/users/users_dto.rs b/src/apps/v1/users/users_dto.rs index a85a25a..c00751e 100644 --- a/src/apps/v1/users/users_dto.rs +++ b/src/apps/v1/users/users_dto.rs @@ -10,7 +10,18 @@ pub struct UsersCreateRequestDto { #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct UsersItemDto { - pub email: String, + pub id: String, pub fullname: String, + pub email: String, + pub avatar: Option, + pub phone_number: String, + pub referral_code: Option, + pub referred_by: Option, + pub identity_number: Option, pub is_active: bool, + pub student_type: String, + pub religion: Option, + pub gender: Option, + pub birthdate: Option, + pub is_profile_completed: bool, } diff --git a/src/apps/v1/users/users_repository.rs b/src/apps/v1/users/users_repository.rs index 35ecd5b..42c857a 100644 --- a/src/apps/v1/users/users_repository.rs +++ b/src/apps/v1/users/users_repository.rs @@ -1,5 +1,8 @@ use super::{UsersActiveInactiveSchema, UsersSchema, UsersSetNewPasswordSchema}; -use crate::{get_id, AppState, ResourceEnum}; +use crate::{ + get_id, AppState, CountResult, MetaRequestDto, MetaResponseDto, ResourceEnum, + ResponseListSuccessDto, +}; use anyhow::{bail, Result}; pub struct UsersRepository<'a> { @@ -11,10 +14,99 @@ impl<'a> UsersRepository<'a> { Self { state } } + pub async fn query_user_list( + &self, + meta: MetaRequestDto, + ) -> Result>> { + let db = &self.state.surrealdb_ws; + let table = ResourceEnum::Users.to_string(); + + let page = meta.page.unwrap_or(1); + let per_page = meta.per_page.unwrap_or(10); + let start = (page - 1) * per_page; + + let mut sql = format!("SELECT * FROM {}", table); + let mut conditions = vec!["is_deleted = false".to_string()]; + + if meta.search.is_some() { + conditions.push("string::contains(fullname, $search)".to_string()); + } + + if meta.filter_by.is_some() && meta.filter.is_some() { + let filter_by = meta.filter_by.as_ref().unwrap(); + conditions.push(format!("{} = $filter", filter_by)); + } + + if !conditions.is_empty() { + sql.push_str(" WHERE "); + sql.push_str(&conditions.join(" AND ")); + } + + if let Some(sort_by) = &meta.sort_by { + let order = meta.order.clone().unwrap_or_else(|| "ASC".to_string()); + sql.push_str(&format!(" ORDER BY {} {}", sort_by, order)); + } + + sql.push_str(" LIMIT $per_page START $start"); + + let mut query = db.query(sql); + + if let Some(search) = meta.search.clone() { + query = query.bind(("search", search)); + } + + if let Some(filter_val) = meta.filter.clone() { + if let Ok(b) = filter_val.parse::() { + query = query.bind(("filter", b)); + } else if let Ok(i) = filter_val.parse::() { + query = query.bind(("filter", i)); + } else { + query = query.bind(("filter", filter_val)); + } + } + + query = query.bind(("per_page", per_page)).bind(("start", start)); + + let users: Vec = query.await?.take(0)?; + + let mut count_sql = + format!("SELECT count() FROM {} WHERE is_deleted = false", table); + if meta.filter_by.is_some() && meta.filter.is_some() { + let filter_by = meta.filter_by.as_ref().unwrap(); + count_sql.push_str(&format!(" AND {} = $filter", filter_by)); + } + + let mut count_query = db.query(count_sql); + + if let Some(filter_val) = meta.filter.clone() { + if let Ok(b) = filter_val.parse::() { + count_query = count_query.bind(("filter", b)); + } else if let Ok(i) = filter_val.parse::() { + count_query = count_query.bind(("filter", i)); + } else { + count_query = count_query.bind(("filter", filter_val)); + } + } + + let count_result: Vec = count_query.await?.take(0)?; + let total = count_result.first().map(|c| c.count); + + let meta_res = MetaResponseDto { + page: Some(page), + per_page: Some(per_page), + total, + }; + + Ok(ResponseListSuccessDto { + data: users, + meta: Some(meta_res), + }) + } + pub async fn query_user_by_email(&self, email: String) -> Result { let db = &self.state.surrealdb_ws; let sql = format!( - "SELECT * FROM {} WHERE email = $email", + "SELECT * FROM {} WHERE email = $email AND is_deleted = false", ResourceEnum::Users.to_string() ); let mut response: Vec = db @@ -22,22 +114,20 @@ impl<'a> UsersRepository<'a> { .bind(("email", email.clone())) .await? .take(0)?; - - if let Some(user) = response.pop() { - Ok(user) - } else { - bail!("User not found") + match response.pop() { + Some(user) => Ok(user), + None => bail!("User not found"), } } pub async fn query_user_by_id(&self, id: String) -> Result { let db = &self.state.surrealdb_ws; - let result = db + let result: Option = db .select((ResourceEnum::Users.to_string(), id.clone())) .await?; match result { - Some(response) => Ok(response), - None => bail!("User not found"), + Some(response) if !response.is_deleted => Ok(response), + _ => bail!("User not found"), } } @@ -102,4 +192,18 @@ impl<'a> UsersRepository<'a> { None => bail!("Failed to update password user"), } } + + pub async fn query_delete_user(&self, email: String) -> Result { + let db = &self.state.surrealdb_ws; + let user = self.query_user_by_email(email.clone()).await?; + let record_key = get_id(&user.id)?; + let record: Option = db + .update(record_key) + .merge(serde_json::json!({ "is_deleted": true })) + .await?; + match record { + Some(_) => Ok("Success delete user".into()), + None => bail!("Failed to delete user"), + } + } } diff --git a/src/apps/v1/users/users_repository_test.rs b/src/apps/v1/users/users_repository_test.rs new file mode 100644 index 0000000..aaab1f3 --- /dev/null +++ b/src/apps/v1/users/users_repository_test.rs @@ -0,0 +1,196 @@ +use crate::create_mock_app_state; +use crate::{ + make_thing, MetaRequestDto, UsersActiveInactiveSchema, UsersRepository, + UsersSchema, UsersSetNewPasswordSchema, +}; +use surrealdb::Uuid; + +fn create_test_user(email: &str, fullname: &str, is_active: bool) -> UsersSchema { + UsersSchema { + id: make_thing("app_users", &Uuid::new_v4().to_string()), + email: email.to_string(), + fullname: fullname.to_string(), + password: "password".to_string(), + is_deleted: false, + avatar: None, + phone_number: "081234567890".to_string(), + referral_code: None, + referred_by: None, + identity_number: None, + is_active, + student_type: "general".to_string(), + religion: None, + gender: None, + birthdate: None, + is_profile_completed: false, + role: make_thing("roles", "user"), + created_at: None, + updated_at: None, + } +} + +#[tokio::test] +async fn test_create_and_get_user() { + let app_state = create_mock_app_state().await; + let repo = UsersRepository::new(&app_state); + let user = create_test_user("testuser@example.com", "Test User", true); + let create_result = repo.query_create_user(user.clone()).await; + assert!(create_result.is_ok()); + let fetched = repo + .query_user_by_email("testuser@example.com".into()) + .await; + assert!(fetched.is_ok()); + assert_eq!(fetched.unwrap().email, "testuser@example.com"); +} + +#[tokio::test] +async fn test_update_password_user() { + let app_state = create_mock_app_state().await; + let repo = UsersRepository::new(&app_state); + let email = "changepass@example.com"; + let user = create_test_user(email, "Change Password", true); + repo.query_create_user(user).await.unwrap(); + let result = repo + .query_update_password_user( + email.into(), + UsersSetNewPasswordSchema { + password: "newpass".into(), + }, + ) + .await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_query_user_list_with_pagination_and_filter() { + let app_state = create_mock_app_state().await; + let repo = UsersRepository::new(&app_state); + for i in 0..10 { + let email = format!("user{}@example.com", i); + let fullname = format!("User {}", i); + let is_active = i % 2 == 0; + let user = create_test_user(&email, &fullname, is_active); + repo.query_create_user(user).await.unwrap(); + } + let meta = MetaRequestDto { + page: Some(1), + per_page: Some(5), + search: None, + sort_by: Some("email".into()), + order: Some("ASC".into()), + filter: Some("true".into()), + filter_by: Some("is_active".into()), + }; + let result = repo.query_user_list(meta).await.unwrap(); + assert!(result.data.len() <= 5); + assert!(result.data.iter().all(|u| u.is_active)); + assert!(result.meta.as_ref().unwrap().total.is_some()); +} + +#[tokio::test] +async fn test_query_user_list_basic() { + let app_state = create_mock_app_state().await; + let repo = UsersRepository::new(&app_state); + for i in 0..10 { + let email = format!("basic{}@example.com", i); + let user = create_test_user(&email, &format!("Basic User {}", i), true); + repo.query_create_user(user).await.unwrap(); + } + let meta = MetaRequestDto { + page: Some(1), + per_page: Some(5), + search: None, + sort_by: None, + order: None, + filter: None, + filter_by: None, + }; + let result = repo.query_user_list(meta).await.unwrap(); + assert!(result.meta.as_ref().unwrap().total.unwrap() >= 1); + assert_eq!(result.meta.as_ref().unwrap().page.unwrap(), 1); + assert_eq!(result.meta.as_ref().unwrap().per_page.unwrap(), 5); +} + +#[tokio::test] +async fn test_query_active_inactive_user() { + let app_state = create_mock_app_state().await; + let repo = UsersRepository::new(&app_state); + let email = "inactive@example.com"; + let user = create_test_user(email, "Inactive User", false); + repo.query_create_user(user).await.unwrap(); + let result = repo + .query_active_inactive_user( + email.into(), + UsersActiveInactiveSchema { is_active: true }, + ) + .await; + assert!(result.is_ok()); + let updated = repo + .query_user_by_email("inactive@example.com".into()) + .await + .unwrap(); + assert_eq!(updated.is_active, true); +} + +#[tokio::test] +async fn test_query_user_by_invalid_email_should_fail() { + let app_state = create_mock_app_state().await; + let repo = UsersRepository::new(&app_state); + let result = repo + .query_user_by_email("nonexistent@example.com".into()) + .await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_query_update_password_for_nonexistent_user_should_fail() { + let app_state = create_mock_app_state().await; + let repo = UsersRepository::new(&app_state); + let result = repo + .query_update_password_user( + "ghost@example.com".into(), + UsersSetNewPasswordSchema { + password: "secret".into(), + }, + ) + .await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_query_delete_user() { + let app_state = create_mock_app_state().await; + let repo = UsersRepository::new(&app_state); + let email = "deleteuser@example.com"; + let user = create_test_user(email, "Delete User", true); + repo.query_create_user(user.clone()).await.unwrap(); + let delete_result = repo.query_delete_user(email.to_string()).await; + assert!(delete_result.is_ok()); + let fetch_result = repo.query_user_by_email(email.to_string()).await; + assert!(fetch_result.is_err()); +} + +#[tokio::test] +async fn test_delete_non_existent_user_should_fail() { + let app_state = create_mock_app_state().await; + let repo = UsersRepository::new(&app_state); + let result = repo + .query_delete_user("nonexistent@example.com".to_string()) + .await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), "User not found"); +} + +#[tokio::test] +async fn test_delete_user_twice_should_fail_on_second_attempt() { + let app_state = create_mock_app_state().await; + let repo = UsersRepository::new(&app_state); + let email = "twice@example.com"; + let user = create_test_user(email, "Delete Twice", true); + repo.query_create_user(user.clone()).await.unwrap(); + let first = repo.query_delete_user(email.to_string()).await; + assert!(first.is_ok()); + let second = repo.query_delete_user(email.to_string()).await; + assert!(second.is_err()); + assert_eq!(second.unwrap_err().to_string(), "User not found"); +} diff --git a/src/apps/v1/users/users_schema.rs b/src/apps/v1/users/users_schema.rs index 84299ed..1565d28 100644 --- a/src/apps/v1/users/users_schema.rs +++ b/src/apps/v1/users/users_schema.rs @@ -17,6 +17,7 @@ pub struct UsersSchema { pub referred_by: Option, pub identity_number: Option, pub is_active: bool, + pub is_deleted: bool, pub student_type: String, pub religion: Option, pub gender: Option, @@ -43,6 +44,7 @@ impl Default for UsersSchema { referred_by: None, identity_number: None, is_active: false, + is_deleted: false, student_type: String::new(), religion: None, gender: None, diff --git a/src/entities/common_dto.rs b/src/entities/common_dto.rs index 4fbc098..d730330 100644 --- a/src/entities/common_dto.rs +++ b/src/entities/common_dto.rs @@ -48,3 +48,8 @@ pub struct AppState { pub surrealdb_ws: SurrealWsClient, pub surrealdb_mem: SurrealMemClient, } + +#[derive(Debug, serde::Deserialize)] +pub struct CountResult { + pub count: u64, +} diff --git a/src/utils/bind_filter.rs b/src/utils/bind_filter.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/utils/bind_filter.rs @@ -0,0 +1 @@ + diff --git a/src/utils/mock_test.rs b/src/utils/mock_test.rs new file mode 100644 index 0000000..8339dfe --- /dev/null +++ b/src/utils/mock_test.rs @@ -0,0 +1,81 @@ +use crate::{hash_password, AppState, UsersSchema}; +use surrealdb::{ + engine::{local::Mem, remote::ws::Ws}, + opt::auth::Root, + Surreal, Uuid, +}; + +use super::make_thing; + +pub async fn create_mock_app_state() -> AppState { + let db_mem = Surreal::new::(()).await.unwrap(); + let db_ws = Surreal::new::("localhost:8000").await.unwrap(); + db_mem.use_ns("test").use_db("test").await.unwrap(); + db_ws + .signin(Root { + username: "root", + password: "root", + }) + .await + .unwrap(); + db_ws.use_ns("test").use_db("test").await.unwrap(); + + AppState { + surrealdb_mem: db_mem, + surrealdb_ws: db_ws, + } +} + +pub async fn cleanup_db() { + let app_state = create_mock_app_state().await; + let _ = app_state + .surrealdb_mem + .query( + r#" + REMOVE TABLE app_users; + REMOVE TABLE app_roles; + REMOVE TABLE app_users_cache; + REMOVE TABLE app_otp_cache; +"#, + ) + .await; + let _ = app_state + .surrealdb_ws + .query( + r#" + REMOVE TABLE app_users; + REMOVE TABLE app_roles; + REMOVE TABLE app_users_cache; + REMOVE TABLE app_otp_cache; +"#, + ) + .await; +} + +pub fn create_test_user( + email: &str, + fullname: &str, + is_active: bool, +) -> UsersSchema { + UsersSchema { + id: make_thing("app_users", &Uuid::new_v4().to_string()), + email: email.to_string(), + fullname: fullname.to_string(), + password: hash_password("secret").unwrap(), + is_deleted: false, + avatar: None, + phone_number: "081234567890".to_string(), + referral_code: None, + referred_by: None, + identity_number: None, + is_active, + student_type: "general".to_string(), + religion: None, + gender: None, + birthdate: None, + is_profile_completed: false, + role: make_thing("roles", "user"), + created_at: None, + updated_at: None, + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 862815c..d006483 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,8 +1,10 @@ +pub mod bind_filter; pub mod extract_email; pub mod generate_date; pub mod generate_otp; pub mod get_id; pub mod make_thing; +pub mod mock_test; pub mod response_format; pub mod validator; @@ -11,5 +13,6 @@ pub use generate_date::*; pub use generate_otp::*; pub use get_id::*; pub use make_thing::*; +pub use mock_test::*; pub use response_format::*; pub use validator::*;