chore: complete unit test of user and authentication system

This commit is contained in:
Maulana Sodiqin
2025-03-23 12:32:00 +07:00
parent 79721b7af6
commit 0ae1e26ba8
10 changed files with 996 additions and 25 deletions
+12 -4
View File
@@ -1,10 +1,11 @@
use crate::{
v1::{
auth, AuthLoginRequestDto, AuthLoginResponsetDto, AuthResendOtpRequestDto,
AuthVerifyEmailRequestDto,
auth, users, AuthLoginRequestDto, AuthLoginResponsetDto,
AuthResendOtpRequestDto, AuthVerifyEmailRequestDto,
},
AuthNewPasswordRequestDto, AuthRefreshTokenRequestDto, MessageResponseDto,
MetaRequestDto, MetaResponseDto, ResponseSuccessDto, TokenDto,
MetaRequestDto, MetaResponseDto, ResponseListSuccessDto, ResponseSuccessDto,
TokenDto, UsersItemDto,
};
use utoipa::{
@@ -21,7 +22,13 @@ use utoipa::{
auth::auth_controller::post_resend_otp,
auth::auth_controller::post_refresh_token,
auth::auth_controller::post_forgot_password,
auth::auth_controller::post_new_password
auth::auth_controller::post_new_password,
users::users_controller::post_create_user,
users::users_controller::put_update_user,
users::users_controller::patch_user_active_status,
users::users_controller::delete_user,
users::users_controller::get_user_by_id,
users::users_controller::get_user_list
),
components(
schemas(
@@ -36,6 +43,7 @@ use utoipa::{
AuthRefreshTokenRequestDto,
ResponseSuccessDto<TokenDto>,
ResponseSuccessDto<AuthLoginResponsetDto>,
ResponseListSuccessDto<Vec<UsersItemDto>>
)
),
info(
+3 -2
View File
@@ -10,8 +10,9 @@ pub use users::*;
pub async fn routes() -> Router {
let public_routes = Router::new().nest("/auth", auth_router());
let protected_routes =
Router::new().layer(from_fn(auth::auth_middleware::auth_middleware));
let protected_routes = Router::new()
.nest("/users", users_router())
.layer(from_fn(auth::auth_middleware::auth_middleware));
Router::new().merge(public_routes).merge(protected_routes)
}
View File
+21
View File
@@ -1,10 +1,31 @@
use axum::{
routing::{delete, get, post, put},
Router,
};
pub mod users_controller;
pub mod users_dto;
pub mod users_repository;
pub mod users_schema;
pub mod users_service;
#[cfg(test)]
pub mod users_controller_test;
#[cfg(test)]
pub mod users_repository_test;
pub use users_controller::*;
pub use users_dto::*;
pub use users_repository::*;
pub use users_schema::*;
pub use users_service::*;
pub fn users_router() -> Router {
Router::new()
.route("/", get(get_user_list))
.route("/activate/{id}", put(patch_user_active_status))
.route("/create", post(post_create_user))
.route("/delete/{id}", delete(delete_user))
.route("/detail/{id}", get(get_user_by_id))
.route("/update/{id}", put(put_update_user))
}
+118
View File
@@ -0,0 +1,118 @@
use axum::extract::{Path, Query};
use axum::response::IntoResponse;
use axum::{Extension, Json};
use crate::{v1::users_service::UsersService, AppState, MetaRequestDto};
use crate::{
MessageResponseDto, ResponseListSuccessDto, ResponseSuccessDto,
UsersActiveInactiveRequestDto, UsersCreateRequestDto, UsersItemDto,
};
use super::UsersUpdateRequestDto;
#[utoipa::path(
get,
path = "/v1/users",
params(
("page" = Option<i64>, Query, description = "Page number"),
("per_page" = Option<i64>, Query, description = "Items per page"),
("search" = Option<String>, Query, description = "Search keyword"),
("sort_by" = Option<String>, Query, description = "Sort by field"),
("order" = Option<String>, Query, description = "Order ASC or DESC"),
("filter" = Option<String>, Query, description = "Filter value"),
("filter_by" = Option<String>, Query, description = "Field to filter by"),
),
responses(
(status = 200, description = "Get user list", body = ResponseListSuccessDto<Vec<UsersItemDto>>)
),
tag = "Users"
)]
pub async fn get_user_list(
Extension(state): Extension<AppState>,
Query(meta): Query<MetaRequestDto>,
) -> impl IntoResponse {
UsersService::get_user_list(&state, meta).await
}
#[utoipa::path(
get,
path = "/v1/users/detail/{id}",
params(
("id" = String, Path, description = "User ID")
),
responses(
(status = 200, description = "Get user by ID", body = ResponseSuccessDto<UsersItemDto>)
),
tag = "Users"
)]
pub async fn get_user_by_id(
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
UsersService::get_user_by_id(&state, id).await
}
#[utoipa::path(
post,
path = "/v1/users/create",
request_body = UsersCreateRequestDto,
responses(
(status = 201, description = "Create new user", body = MessageResponseDto)
),
tag = "Users"
)]
pub async fn post_create_user(
Extension(state): Extension<AppState>,
Json(payload): Json<UsersCreateRequestDto>,
) -> impl IntoResponse {
UsersService::create_user(&state, payload).await
}
#[utoipa::path(
put,
path = "/v1/users/update/{id}",
request_body = UsersCreateRequestDto,
responses(
(status = 200, description = "Update user", body = MessageResponseDto)
),
tag = "Users"
)]
pub async fn put_update_user(
Extension(state): Extension<AppState>,
Path(id): Path<String>,
Json(payload): Json<UsersUpdateRequestDto>,
) -> impl IntoResponse {
UsersService::update_user(&state, id, payload).await
}
#[utoipa::path(
put,
path = "/v1/users/activate/{id}",
request_body = UsersActiveInactiveRequestDto,
responses(
(status = 200, description = "Set user active/inactive", body = MessageResponseDto)
),
tag = "Users"
)]
pub async fn patch_user_active_status(
Extension(state): Extension<AppState>,
axum::extract::Path(id): axum::extract::Path<String>,
Json(payload): Json<UsersActiveInactiveRequestDto>,
) -> impl IntoResponse {
UsersService::set_user_active_status(&state, id, payload).await
}
#[utoipa::path(
delete,
path = "/v1/users/delete/{id}",
responses(
(status = 200, description = "Soft delete user", body = MessageResponseDto)
),
tag = "Users"
)]
pub async fn delete_user(
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
UsersService::delete_user(&state, id).await
}
+507
View File
@@ -0,0 +1,507 @@
use crate::{
apps::v1::users_router, create_mock_app_state, UsersActiveInactiveRequestDto,
UsersCreateRequestDto, UsersRepository, UsersUpdateRequestDto,
};
use axum::{http::StatusCode, Extension};
use axum_test::TestServer;
#[tokio::test]
async fn test_get_user_list_should_return_200() {
let state = create_mock_app_state().await;
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state));
let server = TestServer::new(app).unwrap();
let res = server.get("/v1/users?page=1&per_page=10").await;
let status = res.status_code();
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn test_create_user_should_return_201() {
let state = create_mock_app_state().await;
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state));
let server = TestServer::new(app).unwrap();
let payload = UsersCreateRequestDto {
fullname: "Create Data #1".into(),
email: "test@create.com".into(),
password: "Password1!".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
is_active: true,
referral_code: None,
referred_by: None,
};
let res = server.post("/v1/users/create").json(&payload).await;
assert_eq!(res.status_code(), StatusCode::CREATED);
}
#[tokio::test]
async fn test_delete_user_should_return_200() {
let state = create_mock_app_state().await;
let repo = UsersRepository::new(&state);
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state.clone()));
let server = TestServer::new(app).unwrap();
let payload = UsersCreateRequestDto {
fullname: "Create Data #1".into(),
email: "test@create.com".into(),
password: "Password1!".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
is_active: true,
referral_code: None,
referred_by: None,
};
let create_res = server.post("/v1/users/create").json(&payload).await;
assert_eq!(create_res.status_code(), StatusCode::CREATED);
let detail_res = repo.query_user_by_email(payload.email);
let user_id = detail_res.await.unwrap().id.id.to_raw();
let delete_res = server
.delete(&format!("/v1/users/delete/{}", user_id))
.await;
assert_eq!(delete_res.status_code(), StatusCode::OK);
}
#[tokio::test]
async fn test_get_user_detail_should_return_200() {
let state = create_mock_app_state().await;
let repo = UsersRepository::new(&state);
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state.clone()));
let server = TestServer::new(app).unwrap();
let payload = UsersCreateRequestDto {
fullname: "Detail User".into(),
email: "detail@test.com".into(),
password: "Password1!".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
is_active: true,
referral_code: None,
referred_by: None,
};
server.post("/v1/users/create").json(&payload).await;
let user = repo.query_user_by_email(payload.email).await.unwrap();
let user_id = user.id.id.to_raw();
let res = server.get(&format!("/v1/users/detail/{}", user_id)).await;
assert_eq!(res.status_code(), StatusCode::OK);
}
#[tokio::test]
async fn test_activate_user_should_return_200() {
let state = create_mock_app_state().await;
let repo = UsersRepository::new(&state);
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state.clone()));
let server = TestServer::new(app).unwrap();
let payload = UsersCreateRequestDto {
fullname: "Inactive User".into(),
email: "inactive@test.com".into(),
password: "Password1!".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
is_active: false,
referral_code: None,
referred_by: None,
};
server.post("/v1/users/create").json(&payload).await;
let user = repo.query_user_by_email(payload.email).await.unwrap();
let user_id = user.id.id.to_raw();
let res = server
.put(&format!("/v1/users/activate/{}", user_id))
.json(&UsersActiveInactiveRequestDto { is_active: true })
.await;
assert_eq!(res.status_code(), StatusCode::OK);
}
#[tokio::test]
async fn test_update_user_should_return_200() {
let state = create_mock_app_state().await;
let repo = UsersRepository::new(&state);
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state.clone()));
let server = TestServer::new(app).unwrap();
let payload = UsersCreateRequestDto {
fullname: "Old Name".into(),
email: "update@test.com".into(),
password: "Password1!".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
is_active: true,
referral_code: None,
referred_by: None,
};
server.post("/v1/users/create").json(&payload).await;
let user = repo.query_user_by_email(payload.email).await.unwrap();
let user_id = user.id.id.to_raw();
let update_payload = UsersUpdateRequestDto {
fullname: "Updated Name".into(),
email: "update@test.com".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
is_active: true,
referral_code: None,
referred_by: None,
identity_number: Some("1234567890123456".into()),
religion: Some("Islam".into()),
gender: Some("Laki-laki".into()),
birthdate: Some("2000-01-01".into()),
avatar: None,
};
let res = server
.put(&format!("/v1/users/update/{}", user_id))
.json(&update_payload)
.await;
let status = res.status_code();
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn test_create_user_should_fail_if_email_taken() {
let state = create_mock_app_state().await;
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state.clone()));
let server = TestServer::new(app).unwrap();
let payload = UsersCreateRequestDto {
fullname: "User Satu".into(),
email: "taken@example.com".into(),
password: "Password1!".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
is_active: true,
referral_code: None,
referred_by: None,
};
let res1 = server.post("/v1/users/create").json(&payload).await;
assert_eq!(res1.status_code(), StatusCode::CREATED);
let res2 = server.post("/v1/users/create").json(&payload).await;
let body2 = res2.text();
assert_eq!(res2.status_code(), StatusCode::BAD_REQUEST);
assert!(body2.contains("User already exists"));
}
#[tokio::test]
async fn test_get_user_detail_should_fail_if_not_found() {
let state = create_mock_app_state().await;
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state));
let server = TestServer::new(app).unwrap();
let res = server.get("/v1/users/detail/non-existent-id").await;
assert_eq!(res.status_code(), StatusCode::NOT_FOUND);
let body = res.text();
assert!(body.contains("User not found"));
}
#[tokio::test]
async fn test_delete_user_should_fail_if_already_deleted() {
let state = create_mock_app_state().await;
let repo = UsersRepository::new(&state);
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state.clone()));
let server = TestServer::new(app).unwrap();
let payload = UsersCreateRequestDto {
fullname: "To Be Deleted".into(),
email: "delete-me@example.com".into(),
password: "Password1!".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
is_active: true,
referral_code: None,
referred_by: None,
};
server.post("/v1/users/create").json(&payload).await;
let user = repo.query_user_by_email(payload.email).await.unwrap();
let id = user.id.id.to_raw();
let res1 = server.delete(&format!("/v1/users/delete/{}", id)).await;
assert_eq!(res1.status_code(), StatusCode::OK);
let res2 = server.delete(&format!("/v1/users/delete/{}", id)).await;
assert_eq!(res2.status_code(), StatusCode::BAD_REQUEST);
let body = res2.text();
assert!(body.contains("User not found"));
}
#[tokio::test]
async fn test_update_user_should_fail_if_user_not_found() {
let state = create_mock_app_state().await;
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state));
let server = TestServer::new(app).unwrap();
let update_payload = UsersUpdateRequestDto {
fullname: "Does Not Exist".into(),
email: "nonexistent@test.com".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
is_active: true,
referral_code: None,
referred_by: None,
identity_number: Some("1234567890123456".into()),
religion: Some("Islam".into()),
gender: Some("Laki-laki".into()),
birthdate: Some("2000-01-01".into()),
avatar: None,
};
let res = server
.put("/v1/users/update/non-existent-id")
.json(&update_payload)
.await;
assert_eq!(res.status_code(), StatusCode::BAD_REQUEST);
let body = res.text();
assert!(body.contains("User not found"));
}
#[tokio::test]
async fn test_activate_user_should_fail_if_user_not_found() {
let state = create_mock_app_state().await;
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state));
let server = TestServer::new(app).unwrap();
let payload = UsersActiveInactiveRequestDto { is_active: true };
let res = server
.put("/v1/users/activate/non-existent-id")
.json(&payload)
.await;
assert_eq!(res.status_code(), StatusCode::BAD_REQUEST);
let body = res.text();
assert!(body.contains("User not found"));
}
#[tokio::test]
async fn test_create_user_should_fail_if_payload_invalid() {
let state = create_mock_app_state().await;
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state));
let server = TestServer::new(app).unwrap();
let invalid_payload = serde_json::json!({});
let res = server.post("/v1/users/create").json(&invalid_payload).await;
assert_eq!(res.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn test_list_users_should_fail_with_invalid_pagination() {
let state = create_mock_app_state().await;
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state));
let server = TestServer::new(app).unwrap();
let res = server.get("/v1/users?page=0&per_page=0").await;
assert_eq!(res.status_code(), StatusCode::BAD_REQUEST);
let body = res.text();
assert!(
body.contains("Invalid pagination") || body.contains("per_page"),
"Expected pagination error, got: {body}"
);
}
#[tokio::test]
async fn test_list_users_should_ignore_invalid_sort_field() {
let state = create_mock_app_state().await;
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state));
let server = TestServer::new(app).unwrap();
let res = server
.get("/v1/users?page=1&per_page=10&sort_by=nonexistent_field")
.await;
assert_eq!(res.status_code(), StatusCode::OK);
}
#[tokio::test]
async fn test_list_users_should_fallback_on_invalid_order() {
let state = create_mock_app_state().await;
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state));
let server = TestServer::new(app).unwrap();
let res = server
.get("/v1/users?page=1&per_page=10&sort_by=email&order=invalid")
.await;
assert_eq!(res.status_code(), StatusCode::OK);
}
#[tokio::test]
async fn test_list_users_with_search_should_return_empty_if_no_match() {
let state = create_mock_app_state().await;
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state));
let server = TestServer::new(app).unwrap();
let res = server
.get("/v1/users?page=1&per_page=10&search=no-user-should-match-this")
.await;
assert_eq!(res.status_code(), StatusCode::OK);
let body: serde_json::Value = res.json();
assert_eq!(body["data"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn test_list_users_with_invalid_filter_by_should_return_empty() {
let state = create_mock_app_state().await;
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state));
let server = TestServer::new(app).unwrap();
let res = server
.get("/v1/users?page=1&per_page=10&filter_by=unknown_field&filter=value")
.await;
assert_eq!(res.status_code(), StatusCode::OK);
let body: serde_json::Value = res.json();
assert_eq!(body["data"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn test_user_detail_should_fail_with_invalid_id_format() {
let state = create_mock_app_state().await;
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state));
let server = TestServer::new(app).unwrap();
let res = server.get("/v1/users/detail/!@#invalid-id").await;
assert_eq!(res.status_code(), StatusCode::NOT_FOUND);
let body = res.text();
assert!(body.contains("User not found") || body.contains("not found"));
}
#[tokio::test]
async fn test_user_detail_should_fail_if_user_not_found() {
let state = create_mock_app_state().await;
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state));
let server = TestServer::new(app).unwrap();
let res = server.get("/v1/users/detail/random-id-123456").await;
assert_eq!(res.status_code(), StatusCode::NOT_FOUND);
let body = res.text();
assert!(body.contains("User not found"));
}
#[tokio::test]
async fn test_user_detail_should_fail_if_user_is_soft_deleted() {
let state = create_mock_app_state().await;
let repo = UsersRepository::new(&state);
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state.clone()));
let server = TestServer::new(app).unwrap();
let payload = UsersCreateRequestDto {
fullname: "Soft Deleted".into(),
email: "softdeleted@test.com".into(),
password: "Password1!".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
is_active: true,
referral_code: None,
referred_by: None,
};
server.post("/v1/users/create").json(&payload).await;
let user = repo.query_user_by_email(payload.email).await.unwrap();
let user_id = user.id.id.to_raw();
let _ = repo.query_delete_user(user_id.clone()).await.unwrap();
let res = server.get(&format!("/v1/users/detail/{}", user_id)).await;
assert_eq!(res.status_code(), StatusCode::NOT_FOUND);
let body = res.text();
assert!(body.contains("User not found"));
}
#[tokio::test]
async fn test_update_user_should_fail_if_user_is_deleted() {
let state = create_mock_app_state().await;
let repo = UsersRepository::new(&state);
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state.clone()));
let server = TestServer::new(app).unwrap();
let payload = UsersCreateRequestDto {
fullname: "To Be Deleted".into(),
email: "deleteduser@test.com".into(),
password: "Password1!".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
is_active: true,
referral_code: None,
referred_by: None,
};
server.post("/v1/users/create").json(&payload).await;
let user = repo.query_user_by_email(payload.email).await.unwrap();
let user_id = user.id.id.to_raw();
let _ = repo.query_delete_user(user_id.clone()).await.unwrap();
let update_payload = UsersUpdateRequestDto {
fullname: "Should Fail".into(),
email: "deleteduser@test.com".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
is_active: true,
referral_code: None,
referred_by: None,
identity_number: Some("1234567890123456".into()),
religion: Some("Islam".into()),
gender: Some("Laki-laki".into()),
birthdate: Some("2000-01-01".into()),
avatar: None,
};
let res = server
.put(&format!("/v1/users/update/{}", user_id))
.json(&update_payload)
.await;
assert_eq!(res.status_code(), StatusCode::BAD_REQUEST);
let body = res.text();
assert!(body.contains("User not found"));
}
#[tokio::test]
async fn test_update_user_should_fail_if_payload_invalid() {
let state = create_mock_app_state().await;
let repo = UsersRepository::new(&state);
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state.clone()));
let server = TestServer::new(app).unwrap();
let payload = UsersCreateRequestDto {
fullname: "Invalid Payload".into(),
email: "invalid@test.com".into(),
password: "Password1!".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
is_active: true,
referral_code: None,
referred_by: None,
};
server.post("/v1/users/create").json(&payload).await;
let user = repo.query_user_by_email(payload.email).await.unwrap();
let user_id = user.id.id.to_raw();
let update_payload = UsersUpdateRequestDto {
fullname: "".into(),
email: "invalid@test.com".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
is_active: true,
referral_code: None,
referred_by: None,
identity_number: Some("1234567890123456".into()),
religion: Some("Islam".into()),
gender: Some("Laki-laki".into()),
birthdate: Some("2000-01-01".into()),
avatar: None,
};
let res = server
.put(&format!("/v1/users/update/{}", user_id))
.json(&update_payload)
.await;
assert_eq!(res.status_code(), StatusCode::BAD_REQUEST);
let body = res.text();
assert!(
body.contains("Fullname") || body.contains("at least have 2 character"),
"Gagal validasi fullname: {body}"
);
}
+80 -6
View File
@@ -1,11 +1,80 @@
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use validator::Validate;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
lazy_static! {
static ref PASSWORD_REGEX: Regex = Regex::new(r"^[A-Za-z\d@$!%*?&]{8,}$").unwrap();
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct UsersCreateRequestDto {
#[validate(
length(min = 1, message = "Email cannot be empty"),
email(message = "Email not valid")
)]
pub email: String,
pub fullname: String,
#[validate(length(
min = 8,
message = "Password must have at least 8 characters"
))]
#[validate(regex(
path = "PASSWORD_REGEX",
message = "Password must include uppercase, lowercase, number, and special character"
))]
pub password: String,
#[validate(length(min = 2, message = "Fullname at least have 2 character"))]
pub fullname: String,
#[validate(length(min = 1, message = "Student type is required"))]
pub student_type: String,
#[validate(length(
min = 10,
message = "Phone number at least have 10 character"
))]
pub phone_number: String,
#[validate(length(
max = 4,
message = "Referal code cannot be more than 4 character"
))]
pub referral_code: Option<String>,
pub referred_by: Option<String>,
pub is_active: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct UsersUpdateRequestDto {
#[validate(
length(min = 1, message = "Email cannot be empty"),
email(message = "Email not valid")
)]
pub email: String,
#[validate(length(
min = 8,
message = "Password must have at least 8 characters"
))]
#[validate(length(min = 2, message = "Fullname at least have 2 character"))]
pub fullname: String,
#[validate(length(min = 1, message = "Student type is required"))]
pub student_type: String,
#[validate(length(
min = 10,
message = "Phone number at least have 10 character"
))]
pub phone_number: String,
#[validate(length(
max = 4,
message = "Referal code cannot be more than 4 character"
))]
pub referral_code: Option<String>,
pub referred_by: Option<String>,
pub is_active: bool,
#[validate(length(min = 16, message = "NIK at must have 16 character"))]
pub identity_number: Option<String>,
pub religion: Option<String>,
pub gender: Option<String>,
pub birthdate: Option<String>,
pub avatar: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
@@ -15,13 +84,18 @@ pub struct UsersItemDto {
pub email: String,
pub avatar: Option<String>,
pub phone_number: String,
pub referral_code: Option<String>,
pub referred_by: Option<String>,
pub identity_number: Option<String>,
pub is_active: bool,
pub referral_code: Option<String>,
pub student_type: String,
pub is_active: bool,
pub is_profile_completed: bool,
pub identity_number: Option<String>,
pub religion: Option<String>,
pub gender: Option<String>,
pub birthdate: Option<String>,
pub is_profile_completed: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct UsersActiveInactiveRequestDto {
pub is_active: bool,
}
+55 -6
View File
@@ -1,7 +1,7 @@
use super::{UsersActiveInactiveSchema, UsersSchema, UsersSetNewPasswordSchema};
use crate::{
get_id, AppState, CountResult, MetaRequestDto, MetaResponseDto, ResourceEnum,
ResponseListSuccessDto,
get_id, make_thing, AppState, CountResult, MetaRequestDto, MetaResponseDto,
ResourceEnum, ResponseListSuccessDto,
};
use anyhow::{bail, Result};
@@ -23,6 +23,11 @@ impl<'a> UsersRepository<'a> {
let page = meta.page.unwrap_or(1);
let per_page = meta.per_page.unwrap_or(10);
if page < 1 || per_page < 1 {
bail!("Invalid pagination: page and per_page must be greater than 0");
}
let start = (page - 1) * per_page;
let mut sql = format!("SELECT * FROM {}", table);
@@ -43,7 +48,17 @@ impl<'a> UsersRepository<'a> {
}
if let Some(sort_by) = &meta.sort_by {
let order = meta.order.clone().unwrap_or_else(|| "ASC".to_string());
let order = match meta
.order
.clone()
.unwrap_or_else(|| "ASC".to_string())
.to_uppercase()
.as_str()
{
"ASC" => "ASC",
"DESC" => "DESC",
_ => "ASC", // fallback kalau order invalid
};
sql.push_str(&format!(" ORDER BY {} {}", sort_by, order));
}
@@ -146,7 +161,16 @@ impl<'a> UsersRepository<'a> {
pub async fn query_update_user(&self, data: UsersSchema) -> Result<String> {
let db = &self.state.surrealdb_ws;
let record_key = get_id(&data.id)?;
let record: Option<UsersSchema> = db.update(record_key).merge(data).await?;
let existing = self.query_user_by_id(data.id.id.to_raw()).await?;
if existing.is_deleted {
bail!("User already deleted");
}
let merged = UsersSchema {
password: existing.password,
created_at: existing.created_at,
..data.clone()
};
let record: Option<UsersSchema> = db.update(record_key).merge(merged).await?;
match record {
Some(_) => Ok("Success update user".into()),
None => bail!("Failed to update user"),
@@ -160,6 +184,9 @@ impl<'a> UsersRepository<'a> {
) -> Result<String> {
let db = &self.state.surrealdb_ws;
let user = self.query_user_by_email(email.clone()).await?;
if user.is_deleted {
bail!("User not found");
}
let record_key = get_id(&user.id)?;
let record: Option<UsersSchema> = db
.update(record_key)
@@ -173,6 +200,24 @@ impl<'a> UsersRepository<'a> {
}
}
pub async fn query_active_inactive_user_by_id(
&self,
id: String,
data: UsersActiveInactiveSchema,
) -> Result<String> {
let db = &self.state.surrealdb_ws;
let record: Option<UsersSchema> = db
.update((ResourceEnum::Users.to_string(), id))
.merge(UsersActiveInactiveSchema {
is_active: data.is_active,
})
.await?;
match record {
Some(_) => Ok("Success update user".into()),
None => bail!("Failed to update user"),
}
}
pub async fn query_update_password_user(
&self,
email: String,
@@ -193,9 +238,13 @@ impl<'a> UsersRepository<'a> {
}
}
pub async fn query_delete_user(&self, email: String) -> Result<String> {
pub async fn query_delete_user(&self, id: String) -> Result<String> {
let db = &self.state.surrealdb_ws;
let user = self.query_user_by_email(email.clone()).await?;
let user_id = make_thing(&ResourceEnum::Users.to_string(), &id);
let user = self.query_user_by_id(user_id.id.to_raw()).await?;
if user.is_deleted {
bail!("User already deleted");
}
let record_key = get_id(&user.id)?;
let record: Option<UsersSchema> = db
.update(record_key)
+28 -7
View File
@@ -164,9 +164,17 @@ async fn test_query_delete_user() {
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;
let user_detail = repo
.query_user_by_email(email.to_string().clone())
.await
.unwrap();
let delete_result = repo
.query_delete_user(user_detail.id.id.to_raw().clone())
.await;
assert!(delete_result.is_ok());
let fetch_result = repo.query_user_by_email(email.to_string()).await;
let fetch_result = repo
.query_user_by_email(user_detail.id.id.to_raw().clone())
.await;
assert!(fetch_result.is_err());
}
@@ -174,9 +182,7 @@ async fn test_query_delete_user() {
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;
let result = repo.query_delete_user("lklklklk".to_string()).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().to_string(), "User not found");
}
@@ -188,9 +194,24 @@ async fn test_delete_user_twice_should_fail_on_second_attempt() {
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;
let first = repo.query_delete_user(user.id.id.to_raw()).await;
assert!(first.is_ok());
let second = repo.query_delete_user(email.to_string()).await;
let second = repo.query_delete_user(user.id.id.to_raw()).await;
assert!(second.is_err());
assert_eq!(second.unwrap_err().to_string(), "User not found");
}
#[tokio::test]
async fn test_query_update_user_should_succeed() {
let state = create_mock_app_state().await;
let repo = UsersRepository::new(&state);
let mut user = create_test_user("update@example.com", "Old Name", true);
repo.query_create_user(user.clone()).await.unwrap();
user.fullname = "Updated Name".into();
user.phone_number = "089876543210".into();
let result = repo.query_update_user(user.clone()).await;
assert!(result.is_ok(), "Update failed: {:?}", result.err());
let updated = repo.query_user_by_id(user.id.id.to_raw()).await.unwrap();
assert_eq!(updated.fullname, "Updated Name");
assert_eq!(updated.phone_number, "089876543210");
}
+172
View File
@@ -0,0 +1,172 @@
use crate::{
common_response, get_iso_date, hash_password, make_thing, success_list_response,
success_response, validate_request, ResourceEnum, ResponseSuccessDto,
};
use crate::{
AppState, MetaRequestDto, ResponseListSuccessDto, UsersActiveInactiveSchema,
UsersRepository, UsersSchema, UsersSetNewPasswordSchema,
};
use axum::{http::StatusCode, response::Response};
use super::{
UsersActiveInactiveRequestDto, UsersCreateRequestDto, UsersUpdateRequestDto,
};
pub struct UsersService;
impl UsersService {
pub async fn get_user_list(state: &AppState, meta: MetaRequestDto) -> Response {
let repo = UsersRepository::new(state);
match repo.query_user_list(meta).await {
Ok(data) => {
let response = ResponseListSuccessDto {
data: data.data,
meta: data.meta,
};
success_list_response(response)
}
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
pub async fn get_user_by_email(state: &AppState, email: String) -> Response {
let repo = UsersRepository::new(state);
match repo.query_user_by_email(email).await {
Ok(user) => success_response(ResponseSuccessDto { data: user }),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
}
}
pub async fn get_user_by_id(state: &AppState, id: String) -> Response {
let repo = UsersRepository::new(state);
match repo.query_user_by_id(id).await {
Ok(user) => success_response(ResponseSuccessDto { data: user }),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
}
}
pub async fn create_user(
state: &AppState,
new_user: UsersCreateRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&new_user) {
return common_response(status, &message);
}
let repo = UsersRepository::new(state);
if repo
.query_user_by_email(new_user.email.clone())
.await
.is_ok()
{
return common_response(StatusCode::BAD_REQUEST, "User already exists");
}
match repo
.query_create_user(UsersSchema {
email: new_user.email.clone(),
fullname: new_user.fullname.clone(),
password: hash_password(&new_user.password).unwrap(),
phone_number: new_user.phone_number.clone(),
referral_code: new_user.referral_code.clone(),
referred_by: new_user.referred_by.clone(),
student_type: new_user.student_type.clone(),
is_active: new_user.is_active.clone(),
is_profile_completed: false,
..Default::default()
})
.await
{
Ok(msg) => common_response(StatusCode::CREATED, &msg),
Err(err) => {
common_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string())
}
}
}
pub async fn update_user(
state: &AppState,
id: String,
user: UsersUpdateRequestDto,
) -> Response {
let repo = UsersRepository::new(state);
if let Err((status, message)) = validate_request(&user) {
return common_response(status, &message);
}
let user_id = make_thing(&ResourceEnum::Users.to_string(), &id);
let role_id = make_thing(&ResourceEnum::Roles.to_string(), "");
let updated_user = UsersSchema {
id: user_id,
fullname: user.fullname,
email: user.email,
phone_number: user.phone_number,
referral_code: user.referral_code,
referred_by: user.referred_by,
identity_number: user.identity_number,
is_active: user.is_active,
student_type: user.student_type,
religion: user.religion,
gender: user.gender,
birthdate: user.birthdate,
avatar: user.avatar,
is_profile_completed: false,
role: role_id,
updated_at: Some(get_iso_date()),
..Default::default()
};
match repo.query_update_user(updated_user).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
pub async fn set_user_active_status(
state: &AppState,
id: String,
status: UsersActiveInactiveRequestDto,
) -> Response {
let repo = UsersRepository::new(state);
if repo.query_user_by_id(id.clone()).await.is_err() {
return common_response(StatusCode::BAD_REQUEST, "User not found");
}
match repo
.query_active_inactive_user_by_id(
id,
UsersActiveInactiveSchema {
is_active: status.is_active,
},
)
.await
{
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
pub async fn update_user_password(
state: &AppState,
email: String,
new_password: UsersSetNewPasswordSchema,
) -> Response {
let repo = UsersRepository::new(state);
match repo.query_update_password_user(email, new_password).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
pub async fn delete_user(state: &AppState, id: String) -> Response {
let repo = UsersRepository::new(state);
if repo.query_user_by_id(id.clone()).await.is_err() {
return common_response(StatusCode::BAD_REQUEST, "User not found");
}
match repo.query_delete_user(id).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
}