From 34eaa418d5908b95b53126f7576b188ffd49f619 Mon Sep 17 00:00:00 2001 From: Maulana Sodiqin Date: Tue, 25 Mar 2025 10:48:34 +0700 Subject: [PATCH] feat: roles router --- src/apps/v1/docs/docs_controller.rs | 16 +++- src/apps/v1/mod.rs | 1 + src/apps/v1/roles/mod.rs | 20 +++++ src/apps/v1/roles/roles_controller.rs | 99 ++++++++++++++++++++++ src/apps/v1/roles/roles_controller_test.rs | 94 ++++++++++++++++++++ src/apps/v1/roles/roles_service.rs | 73 ++++++++++++++++ src/apps/v1/users/users_controller_test.rs | 93 +++++++++++++++++--- src/apps/v1/users/users_repository.rs | 6 +- src/utils/query_list.rs | 17 ++++ 9 files changed, 401 insertions(+), 18 deletions(-) create mode 100644 src/apps/v1/roles/roles_controller.rs create mode 100644 src/apps/v1/roles/roles_controller_test.rs create mode 100644 src/apps/v1/roles/roles_service.rs diff --git a/src/apps/v1/docs/docs_controller.rs b/src/apps/v1/docs/docs_controller.rs index 9b436f3..f4f2899 100644 --- a/src/apps/v1/docs/docs_controller.rs +++ b/src/apps/v1/docs/docs_controller.rs @@ -1,11 +1,12 @@ use crate::{ v1::{ - auth, users, AuthLoginRequestDto, AuthLoginResponsetDto, + auth, users, roles, AuthLoginRequestDto, AuthLoginResponsetDto, AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, }, AuthNewPasswordRequestDto, AuthRefreshTokenRequestDto, MessageResponseDto, MetaRequestDto, MetaResponseDto, ResponseListSuccessDto, ResponseSuccessDto, - TokenDto, UsersItemDto, + RolesItemDto, RolesRequestCreateDto, RolesRequestUpdateDto, TokenDto, + UsersItemDto, }; use utoipa::{ @@ -28,7 +29,12 @@ use utoipa::{ 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 + users::users_controller::get_user_list, + roles::roles_controller::get_role_list, + roles::roles_controller::get_role_by_id, + roles::roles_controller::post_create_role, + roles::roles_controller::put_update_role, + roles::roles_controller::delete_role ), components( schemas( @@ -42,7 +48,11 @@ use utoipa::{ AuthNewPasswordRequestDto, AuthRefreshTokenRequestDto, ResponseSuccessDto, + RolesItemDto, + RolesRequestCreateDto, + RolesRequestUpdateDto, ResponseSuccessDto, + ResponseListSuccessDto>, ResponseListSuccessDto> ) ), diff --git a/src/apps/v1/mod.rs b/src/apps/v1/mod.rs index ebd020d..887a74c 100644 --- a/src/apps/v1/mod.rs +++ b/src/apps/v1/mod.rs @@ -15,6 +15,7 @@ pub async fn routes() -> Router { let public_routes = Router::new().nest("/auth", auth_router()); let protected_routes = Router::new() .nest("/users", users_router()) + .nest("/roles", roles_router()) .layer(from_fn(auth::auth_middleware::auth_middleware)); Router::new().merge(public_routes).merge(protected_routes) } diff --git a/src/apps/v1/roles/mod.rs b/src/apps/v1/roles/mod.rs index 339a5db..035475d 100644 --- a/src/apps/v1/roles/mod.rs +++ b/src/apps/v1/roles/mod.rs @@ -1,12 +1,32 @@ +use axum::{ + routing::{delete, get, post, put}, + Router, +}; + +pub mod roles_controller; pub mod roles_dto; pub mod roles_enum; pub mod roles_repository; pub mod roles_schema; +pub mod roles_service; +#[cfg(test)] +pub mod roles_controller_test; #[cfg(test)] pub mod roles_repository_test; +pub use roles_controller::*; pub use roles_dto::*; pub use roles_enum::*; pub use roles_repository::*; pub use roles_schema::*; +pub use roles_service::*; + +pub fn roles_router() -> Router { + Router::new() + .route("/", get(get_role_list)) + .route("/detail/{id}", get(get_role_by_id)) + .route("/create", post(post_create_role)) + .route("/update/{id}", put(put_update_role)) + .route("/delete/{id}", delete(delete_role)) +} diff --git a/src/apps/v1/roles/roles_controller.rs b/src/apps/v1/roles/roles_controller.rs new file mode 100644 index 0000000..dc1c503 --- /dev/null +++ b/src/apps/v1/roles/roles_controller.rs @@ -0,0 +1,99 @@ +use axum::{ + extract::{Path, Query}, + response::IntoResponse, + Extension, Json, +}; + +use super::{RolesItemDto, RolesRequestCreateDto, RolesRequestUpdateDto}; +use crate::{ + v1::roles_service::RolesService, AppState, MessageResponseDto, MetaRequestDto, + ResponseListSuccessDto, ResponseSuccessDto, +}; + +#[utoipa::path( + get, + path = "/v1/roles", + params( + ("page" = Option, Query, description = "Page number"), + ("per_page" = Option, Query, description = "Items per page"), + ("search" = Option, Query, description = "Search keyword"), + ("sort_by" = Option, Query, description = "Sort by field"), + ("order" = Option, Query, description = "Order ASC or DESC"), + ("filter" = Option, Query, description = "Filter value"), + ("filter_by" = Option, Query, description = "Field to filter by"), + ), + responses( + (status = 200, description = "Get role list", body = ResponseListSuccessDto>) + ), + tag = "Roles" +)] +pub async fn get_role_list( + Extension(state): Extension, + Query(meta): Query, +) -> impl IntoResponse { + RolesService::get_role_list(&state, meta).await +} + +#[utoipa::path( + get, + path = "/v1/roles/detail/{id}", + params(("id" = String, Path, description = "Role ID")), + responses( + (status = 200, description = "Get role by ID", body = ResponseSuccessDto) + ), + tag = "Roles" +)] +pub async fn get_role_by_id( + Extension(state): Extension, + Path(id): Path, +) -> impl IntoResponse { + RolesService::get_role_by_id(&state, id).await +} + +#[utoipa::path( + post, + path = "/v1/roles/create", + request_body = RolesRequestCreateDto, + responses( + (status = 201, description = "Create new role", body = MessageResponseDto) + ), + tag = "Roles" +)] +pub async fn post_create_role( + Extension(state): Extension, + Json(payload): Json, +) -> impl IntoResponse { + RolesService::create_role(&state, payload).await +} + +#[utoipa::path( + put, + path = "/v1/roles/update/{id}", + request_body = RolesRequestUpdateDto, + responses( + (status = 200, description = "Update role", body = MessageResponseDto) + ), + tag = "Roles" +)] +pub async fn put_update_role( + Extension(state): Extension, + Path(id): Path, + Json(payload): Json, +) -> impl IntoResponse { + RolesService::update_role(&state, id, payload).await +} + +#[utoipa::path( + delete, + path = "/v1/roles/delete/{id}", + responses( + (status = 200, description = "Delete role", body = MessageResponseDto) + ), + tag = "Roles" +)] +pub async fn delete_role( + Extension(state): Extension, + Path(id): Path, +) -> impl IntoResponse { + RolesService::delete_role(&state, id).await +} diff --git a/src/apps/v1/roles/roles_controller_test.rs b/src/apps/v1/roles/roles_controller_test.rs new file mode 100644 index 0000000..4bcd675 --- /dev/null +++ b/src/apps/v1/roles/roles_controller_test.rs @@ -0,0 +1,94 @@ +use crate::AppState; +use crate::{ + create_mock_app_state, roles_router, v1::roles_repository::RolesRepository, + RolesRequestCreateDto, RolesRequestUpdateDto, +}; +use axum::http::StatusCode; +use axum::Extension; +use axum::Router; +use axum_test::TestServer; + +pub fn create_test_app(state: AppState) -> TestServer { + let app = Router::new() + .nest("/v1/roles", roles_router()) + .layer(Extension(state)); + + TestServer::new(app).unwrap() +} + +#[tokio::test] +async fn test_get_role_list_should_return_200() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/roles", roles_router()) + .layer(axum::Extension(state.clone())); + let server = TestServer::new(app).unwrap(); + let res = server.get("/v1/roles?page=1&per_page=10").await; + assert_eq!(res.status_code(), StatusCode::OK); +} + +#[tokio::test] +async fn test_post_create_role_should_return_201() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/roles", roles_router()) + .layer(axum::Extension(state.clone())); + let server = TestServer::new(app).unwrap(); + let payload = RolesRequestCreateDto { + name: "Test Role".into(), + permissions: vec![], + }; + let res = server.post("/v1/roles/create").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::CREATED); +} + +#[tokio::test] +async fn test_put_update_role_should_return_200() { + let state = create_mock_app_state().await; + let repo = RolesRepository::new(&state); + let _ = repo + .query_create_role(RolesRequestCreateDto { + name: "Role To Update".into(), + permissions: vec![], + }) + .await + .unwrap(); + let existing = repo + .query_role_by_name("Role To Update".into()) + .await + .unwrap(); + let id = existing.id.clone(); + let server = create_test_app(state); + let payload = RolesRequestUpdateDto { + name: Some("Updated Role".into()), + permissions: Some(vec![]), + }; + let res = server + .put(&format!("/v1/roles/update/{}", id)) + .json(&payload) + .await; + dbg!(res.text()); + assert_eq!(res.status_code(), StatusCode::OK); +} + +#[tokio::test] +async fn test_delete_role_should_return_200() { + let state = create_mock_app_state().await; + let repo = RolesRepository::new(&state); + let _ = repo + .query_create_role(RolesRequestCreateDto { + name: "Role To Delete".into(), + permissions: vec![], + }) + .await + .unwrap(); + let existing = repo + .query_role_by_name("Role To Delete".into()) + .await + .unwrap(); + let id = existing.id; + let server = create_test_app(state); + let res = server.delete(&format!("/v1/roles/delete/{}", id)).await; + dbg!(res.text()); + assert_eq!(res.status_code(), StatusCode::OK); +} diff --git a/src/apps/v1/roles/roles_service.rs b/src/apps/v1/roles/roles_service.rs new file mode 100644 index 0000000..12f5f81 --- /dev/null +++ b/src/apps/v1/roles/roles_service.rs @@ -0,0 +1,73 @@ +use crate::{ + common_response, success_list_response, success_response, AppState, + MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto, +}; +use axum::http::StatusCode; + +use super::{RolesRepository, RolesRequestCreateDto, RolesRequestUpdateDto}; + +pub struct RolesService; + +impl RolesService { + pub async fn get_role_list( + state: &AppState, + meta: MetaRequestDto, + ) -> axum::response::Response { + let repo = RolesRepository::new(state); + match repo.query_role_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_role_by_id( + state: &AppState, + id: String, + ) -> axum::response::Response { + let repo = RolesRepository::new(state); + match repo.query_role_by_id(id).await { + Ok(role) => success_response(ResponseSuccessDto { data: role }), + Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), + } + } + + pub async fn create_role( + state: &AppState, + payload: RolesRequestCreateDto, + ) -> axum::response::Response { + let repo = RolesRepository::new(state); + match repo.query_create_role(payload).await { + Ok(msg) => common_response(StatusCode::CREATED, &msg), + Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } + } + + pub async fn update_role( + state: &AppState, + id: String, + payload: RolesRequestUpdateDto, + ) -> axum::response::Response { + let repo = RolesRepository::new(state); + match repo.query_update_role(id, payload).await { + Ok(msg) => common_response(StatusCode::OK, &msg), + Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), + } + } + + pub async fn delete_role( + state: &AppState, + id: String, + ) -> axum::response::Response { + let repo = RolesRepository::new(state); + match repo.query_delete_role(id).await { + Ok(msg) => common_response(StatusCode::OK, &msg), + Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), + } + } +} diff --git a/src/apps/v1/users/users_controller_test.rs b/src/apps/v1/users/users_controller_test.rs index df9632b..975fd85 100644 --- a/src/apps/v1/users/users_controller_test.rs +++ b/src/apps/v1/users/users_controller_test.rs @@ -18,6 +18,86 @@ async fn test_get_user_list_should_return_200() { assert_eq!(status, StatusCode::OK); } +#[tokio::test] +async fn test_list_users_should_fail_with_invalid_per_page() { + 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=0").await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_list_users_should_fail_with_invalid_page() { + 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=10").await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); +} + +#[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=invalid_field") + .await; + assert_eq!(res.status_code(), StatusCode::OK); +} + +#[tokio::test] +async fn test_list_users_with_search_no_match_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&search=nonexistinguserxyz") + .await; + let body: serde_json::Value = res.json(); + assert_eq!(res.status_code(), StatusCode::OK); + assert_eq!(body["data"].as_array().unwrap().len(), 0); +} + +#[tokio::test] +async fn test_list_users_should_return_empty_on_invalid_filter() { + 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=is_active&filter=maybe") + .await; + let body: serde_json::Value = res.json(); + assert_eq!(res.status_code(), StatusCode::OK); + assert_eq!(body["data"].as_array().unwrap().len(), 0); +} + +#[tokio::test] +async fn test_get_user_list_with_search_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&search=maulana") + .await; + let status = res.status_code(); + dbg!(res.text()); + assert_eq!(status, StatusCode::OK); +} + #[tokio::test] async fn test_create_user_should_return_201() { let state = create_mock_app_state().await; @@ -363,19 +443,6 @@ async fn test_list_users_should_fail_with_invalid_pagination() { ); } -#[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; diff --git a/src/apps/v1/users/users_repository.rs b/src/apps/v1/users/users_repository.rs index 7633d63..c54a6e2 100644 --- a/src/apps/v1/users/users_repository.rs +++ b/src/apps/v1/users/users_repository.rs @@ -21,8 +21,10 @@ impl<'a> UsersRepository<'a> { let db = &self.state.surrealdb_ws; let table = ResourceEnum::Users.to_string(); let mut conditions = vec!["is_deleted = false".to_string()]; - if meta.search.is_some() { - conditions.push("string::contains(name, $search)".into()); + if let Some(search) = &meta.search { + if !search.is_empty() { + 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(); diff --git a/src/utils/query_list.rs b/src/utils/query_list.rs index 56cb139..c3d1c2a 100644 --- a/src/utils/query_list.rs +++ b/src/utils/query_list.rs @@ -19,12 +19,15 @@ where 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); if !conditions.is_empty() { sql.push_str(" WHERE "); sql.push_str(&conditions.join(" AND ")); } + if let Some(sort_by) = &meta.sort_by { let order = match meta .order @@ -39,7 +42,9 @@ where }; 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 { if !search.is_empty() { @@ -50,23 +55,35 @@ where query = bind_filter_value(query, filter_val); } query = query.bind(("per_page", per_page)).bind(("start", start)); + let items: Vec = query.await?.take(0)?; + + // COUNT QUERY let mut count_sql = format!("SELECT count() FROM {}", table); if !conditions.is_empty() { count_sql.push_str(" WHERE "); count_sql.push_str(&conditions.join(" AND ")); } + let mut count_query = db.query(count_sql); + if let Some(search) = &meta.search { + if !search.is_empty() { + count_query = count_query.bind(("search", search.clone())); + } + } if let Some(filter_val) = meta.filter.clone() { count_query = bind_filter_value(count_query, filter_val); } + let count_result: Vec = count_query.await?.take(0)?; let total = count_result.first().map(|c| c.count); + let meta = MetaResponseDto { page: Some(page), per_page: Some(per_page), total, }; + Ok(ResponseListSuccessDto { data: items, meta: Some(meta),