chore: re-map project structure to using multi workspace paradigm

This commit is contained in:
Maulana Sodiqin
2025-04-06 13:25:44 +07:00
parent de9c5a6dc1
commit b4bf78f0d6
87 changed files with 325 additions and 192 deletions
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "imphnen-backend-service"
version = "0.1.0"
edition = "2021"
[dependencies]
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" }
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" }
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" }
axum.workspace = true
serde.workspace = true
serde_json.workspace = true
utoipa.workspace = true
lazy_static.workspace = true
regex.workspace = true
validator.workspace = true
axum-test.workspace = true
surrealdb.workspace = true
rand.workspace = true
tokio.workspace = true
chrono.workspace = true
anyhow.workspace = true
tower-http.workspace = true
utoipa-swagger-ui.workspace = true
+55
View File
@@ -0,0 +1,55 @@
use crate::{AppState, Env, SurrealMemClient, SurrealWsClient};
use axum::{
http::{header, HeaderValue, Method},
response::Redirect,
routing::get,
Extension, Router,
};
use tower_http::cors::CorsLayer;
use utoipa_swagger_ui::SwaggerUi;
pub mod v1;
pub mod v2;
pub use v1::*;
pub async fn apps(
surrealdb_ws: SurrealWsClient,
surrealdb_mem: SurrealMemClient,
) -> Router {
let state = AppState {
surrealdb_ws,
surrealdb_mem,
};
let env = Env::new();
let cors_origins = match env.rust_env.as_str() {
"development" => vec!["http://localhost:3000"],
"production" => {
vec!["https://gacha.imphnen.dev", "https://imphnen.dev"]
}
_ => vec![
"http://localhost:3000",
"https://gacha.imphnen.dev",
"https://imphnen.dev",
],
};
let allowed_origins: Vec<HeaderValue> = cors_origins
.into_iter()
.filter_map(|origin| origin.parse::<HeaderValue>().ok())
.collect();
let cors_middleware = CorsLayer::new()
.allow_origin(allowed_origins)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
.allow_credentials(true);
Router::new()
.route("/", get(Redirect::to("/docs")))
.nest("/v1", v1::routes().await)
.nest("/v2", v2::routes().await)
.merge(SwaggerUi::new("/docs").url("/openapi.json", v1::docs_router()))
.layer(cors_middleware)
.layer(Extension(state))
}
@@ -0,0 +1,125 @@
use super::{
AuthLoginRequestDto, AuthRefreshTokenRequestDto, AuthRegisterRequestDto,
AuthResendOtpRequestDto, AuthService, AuthVerifyEmailRequestDto,
};
use crate::{v1::AuthLoginResponsetDto, AppState};
use crate::{AuthNewPasswordRequestDto, MessageResponseDto, ResponseSuccessDto};
use axum::{response::IntoResponse, Extension, Json};
#[utoipa::path(
post,
path = "/v1/auth/login",
request_body = AuthLoginRequestDto,
responses(
(status = 200, description = "Login successful", body = ResponseSuccessDto<AuthLoginResponsetDto>),
(status = 401, description = "Login failed", body = MessageResponseDto)
),
tag = "Authentication"
)]
pub async fn post_login(
Extension(state): Extension<AppState>,
Json(payload): Json<AuthLoginRequestDto>,
) -> impl IntoResponse {
AuthService::mutation_login(payload, &state).await
}
#[utoipa::path(
post,
path = "/v1/auth/register",
request_body = AuthRegisterRequestDto,
responses(
(status = 200, description = "Register successful", body = MessageResponseDto),
(status = 401, description = "Register failed", body = MessageResponseDto)
),
tag = "Authentication"
)]
pub async fn post_register(
Extension(state): Extension<AppState>,
Json(payload): Json<AuthRegisterRequestDto>,
) -> impl IntoResponse {
AuthService::mutation_register(payload, &state).await
}
#[utoipa::path(
post,
path = "/v1/auth/verify-email",
request_body = AuthVerifyEmailRequestDto,
responses(
(status = 200, description = "Verify email successful", body = MessageResponseDto),
(status = 401, description = "Verify email failed", body = MessageResponseDto)
),
tag = "Authentication"
)]
pub async fn post_verify_email(
Extension(state): Extension<AppState>,
Json(payload): Json<AuthVerifyEmailRequestDto>,
) -> impl IntoResponse {
AuthService::mutation_verify_email(payload, &state).await
}
#[utoipa::path(
post,
path = "/v1/auth/send-otp",
request_body = AuthResendOtpRequestDto,
responses(
(status = 200, description = "Resend otp successful", body = MessageResponseDto),
(status = 401, description = "Resend otp failed", body = MessageResponseDto)
),
tag = "Authentication"
)]
pub async fn post_resend_otp(
Extension(state): Extension<AppState>,
Json(payload): Json<AuthResendOtpRequestDto>,
) -> impl IntoResponse {
AuthService::mutation_resend_otp(payload, &state).await
}
#[utoipa::path(
post,
path = "/v1/auth/forgot",
request_body = AuthResendOtpRequestDto,
responses(
(status = 200, description = "Forgot password request successful", body = MessageResponseDto),
(status = 401, description = "Forgot password request failed", body = MessageResponseDto)
),
tag = "Authentication"
)]
pub async fn post_forgot_password(
Extension(state): Extension<AppState>,
Json(payload): Json<AuthResendOtpRequestDto>,
) -> impl IntoResponse {
AuthService::mutation_forgot_password(payload, &state).await
}
#[utoipa::path(
post,
path = "/v1/auth/new-password",
request_body = AuthNewPasswordRequestDto,
responses(
(status = 200, description = "New password request successful", body = MessageResponseDto),
(status = 401, description = "New password request failed", body = MessageResponseDto)
),
tag = "Authentication"
)]
pub async fn post_new_password(
Extension(state): Extension<AppState>,
Json(payload): Json<AuthNewPasswordRequestDto>,
) -> impl IntoResponse {
AuthService::mutation_new_password(payload, &state).await
}
#[utoipa::path(
post,
path = "/v1/auth/refresh",
request_body = AuthRefreshTokenRequestDto,
responses(
(status = 200, description = "Refresh token request successful", body = MessageResponseDto),
(status = 401, description = "Refresh token request failed", body = MessageResponseDto)
),
tag = "Authentication"
)]
pub async fn post_refresh_token(
Json(payload): Json<AuthRefreshTokenRequestDto>,
) -> impl IntoResponse {
AuthService::mutation_refresh_token(payload).await
}
@@ -0,0 +1,666 @@
use crate::{
apps::v1::auth::auth_router, create_mock_app_state, create_test_user,
encode_refresh_token, AuthOtpSchema, OtpManager, ResourceEnum, RolesRepository,
};
use axum::{http::StatusCode, Extension};
use axum_test::TestServer;
use serde_json::json;
use surrealdb::Uuid;
#[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 role_repo = RolesRepository::new(&state);
let role_id = role_repo
.query_role_by_name("Student".to_string())
.await
.unwrap()
.id;
let mut user = create_test_user("user@example.com", "User Satu", true, &role_id);
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 role_repo = RolesRepository::new(&state);
let role_id = role_repo
.query_role_by_name("Student".to_string())
.await
.unwrap()
.id;
let mut user = create_test_user(
"inactive-again@example.com",
"Inactive User",
false,
&role_id,
);
user.password = crate::hash_password("secret").unwrap();
repo.query_create_user(user).await.unwrap();
let payload = json!({
"email": "inactive-again@example.com",
"password": "secret"
});
let res = server.post("/v1/auth/login").json(&payload).await;
dbg!(res.text());
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 role_repo = RolesRepository::new(&state);
let role_id = role_repo
.query_role_by_name("Student".to_string())
.await
.unwrap()
.id;
let mut user =
create_test_user("active@example.com", "Active User", true, &role_id);
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::<serde_json::Value>();
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 random_email = format!("validuser+{}@example.com", Uuid::new_v4());
let payload = json!({
"email": random_email,
"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 role_repo = RolesRepository::new(&state);
let role_id = role_repo
.query_role_by_name("Student".to_string())
.await
.unwrap()
.id;
let mut user =
create_test_user("duplicate@example.com", "User Exists", false, &role_id);
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::<serde_json::Value>();
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::<serde_json::Value>();
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::<serde_json::Value>();
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::<surrealdb::engine::local::Mem>(())
.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 role_repo = RolesRepository::new(&state);
let role_id = role_repo
.query_role_by_name("Student".to_string())
.await
.unwrap()
.id;
let mut user =
create_test_user("forgot@example.com", "Forgot User", true, &role_id);
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;
dbg!(res.text());
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::<serde_json::Value>();
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 role_repo = RolesRepository::new(&state);
let role_id = role_repo
.query_role_by_name("Student".to_string())
.await
.unwrap()
.id;
let user = create_test_user("verify@example.com", "Verify User", false, &role_id);
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 role_repo = RolesRepository::new(&state);
let role_id = role_repo
.query_role_by_name("Student".to_string())
.await
.unwrap()
.id;
let user = create_test_user("wrongotp@example.com", "Wrong OTP", false, &role_id);
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 role_repo = RolesRepository::new(&state);
let role_id = role_repo
.query_role_by_name("Student".to_string())
.await
.unwrap()
.id;
let user = create_test_user("expired@example.com", "Expired OTP", false, &role_id);
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<AuthOtpSchema> = 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 role_repo = RolesRepository::new(&state);
let role_id = role_repo
.query_role_by_name("Student".to_string())
.await
.unwrap()
.id;
let mut user = create_test_user(email, "Reset User", true, &role_id);
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"));
}
+143
View File
@@ -0,0 +1,143 @@
use crate::RolesItemDto;
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use validator::Validate;
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 AuthLoginRequestDto {
#[validate(
length(min = 1, message = "Email cannot be empty"),
email(message = "Email not valid")
)]
pub email: String,
#[validate(length(min = 1, message = "Password cannot be empty"))]
pub password: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct AuthUserItemDto {
pub id: String,
pub role: RolesItemDto,
pub fullname: String,
pub email: String,
pub avatar: Option<String>,
pub phone_number: String,
pub is_active: bool,
pub gender: Option<String>,
pub birthdate: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct AuthLoginResponsetDto {
pub token: TokenDto,
pub user: AuthUserItemDto,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct TokenDto {
pub access_token: String,
pub refresh_token: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct AuthRegisterRequestDto {
#[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(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>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct AuthActiveInactiveRequestDto {
pub is_active: bool,
#[validate(
length(min = 1, message = "Email cannot be empty"),
email(message = "Email not valid")
)]
pub email: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct AuthVerifyEmailRequestDto {
#[validate(
length(min = 1, message = "Email cannot be empty"),
email(message = "Email not valid")
)]
pub email: String,
pub otp: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct AuthResendOtpRequestDto {
#[validate(
length(min = 1, message = "Email cannot be empty"),
email(message = "Email not valid")
)]
pub email: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct AuthRefreshTokenRequestDto {
#[validate(length(min = 1, message = "Refresh token cannot be empty"))]
pub refresh_token: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct AuthNewPasswordRequestDto {
pub token: String,
#[validate(length(min = 1, message = "Token cannot be empty"))]
#[validate(regex(
path = "PASSWORD_REGEX",
message = "Password must include uppercase, lowercase, number, and special character"
))]
pub password: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct AuthSetNewPasswordRequestDto {
#[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(regex(
path = "PASSWORD_REGEX",
message = "Password must include uppercase, lowercase, number, and special character"
))]
pub password: String,
}
@@ -0,0 +1,50 @@
use crate::{
common_response, extract_email, AppState, UsersItemDtoRaw, UsersRepository,
};
use axum::{
extract::Request, http::StatusCode, middleware::Next, response::Response,
Extension,
};
use std::convert::Infallible;
pub async fn auth_middleware(
Extension(state): Extension<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, Infallible> {
let headers = req.headers();
let email = match extract_email(headers) {
Some(email) => email,
None => {
return Ok(common_response(
StatusCode::UNAUTHORIZED,
"Invalid or expired token",
));
}
};
let repository = UsersRepository::new(&state);
let user: Option<UsersItemDtoRaw> =
match repository.query_user_by_email(email).await {
Ok(user) => Some(user),
Err(err) => {
return Ok(common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&err.to_string(),
))
}
};
if user.is_none() {
return Ok(common_response(
StatusCode::UNAUTHORIZED,
"Unauthorized user",
));
}
req.extensions_mut().insert(user.unwrap());
Ok(next.run(req).await)
}
@@ -0,0 +1,115 @@
use super::AuthOtpSchema;
use crate::{make_thing, AppState, ResourceEnum, UsersItemDtoRaw};
use anyhow::{anyhow, bail, Result};
use chrono::{Duration, Utc};
pub struct AuthRepository<'a> {
pub state: &'a AppState,
}
impl<'a> AuthRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
pub async fn query_store_user(&self, user: UsersItemDtoRaw) -> Result<String> {
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);
let _ = self
.state
.surrealdb_mem
.delete::<Option<UsersItemDtoRaw>>((table.clone(), user_id.clone()))
.await?;
let mut user_to_store = user.clone();
user_to_store.id = id.clone();
let record: Option<UsersItemDtoRaw> = self
.state
.surrealdb_mem
.create((table, user_id))
.content(user_to_store)
.await?;
match record {
Some(_) => Ok("Success store user data".to_string()),
None => bail!("Failed store user data"),
}
}
pub async fn query_get_stored_user(
&self,
email: String,
) -> Result<UsersItemDtoRaw> {
let user: Option<UsersItemDtoRaw> = self
.state
.surrealdb_mem
.select((ResourceEnum::UsersCache.to_string(), email))
.await?;
match user {
Some(u) => Ok(u),
None => bail!("No stored user data found"),
}
}
pub async fn query_delete_stored_user(&self, email: String) -> Result<String> {
let record: Option<UsersItemDtoRaw> = self
.state
.surrealdb_mem
.delete((ResourceEnum::UsersCache.to_string(), email))
.await?;
dbg!(record.clone());
match record {
Some(_) => Ok("Success delete stored user".to_string()),
None => bail!("Failed delete stored user"),
}
}
pub async fn query_get_stored_otp(&self, email: String) -> Result<u32> {
let table = ResourceEnum::OtpCache.to_string();
let key = (table.as_str(), email.as_str());
let result: Option<AuthOtpSchema> = self.state.surrealdb_mem.select(key).await?;
match result {
Some(data) => match Utc::now() > data.expires_at {
true => {
let _ = self
.state
.surrealdb_mem
.delete::<Option<AuthOtpSchema>>(key)
.await?;
Err(anyhow!("OTP expired"))
}
false => Ok(data.otp),
},
None => bail!("No stored OTP found"),
}
}
pub async fn query_store_otp(&self, email: String, otp: u32) -> Result<String> {
let expires_at = Utc::now() + Duration::seconds(300);
let table = ResourceEnum::OtpCache.to_string();
let record: Option<AuthOtpSchema> = self
.state
.surrealdb_mem
.create((table.as_str(), email.as_str()))
.content(AuthOtpSchema { otp, expires_at })
.await?;
match record {
Some(_) => Ok("Success store otp".to_string()),
None => bail!("Failed store otp"),
}
}
pub async fn query_delete_stored_otp(&self, email: String) -> Result<String> {
let record: Option<AuthOtpSchema> = self
.state
.surrealdb_mem
.delete((ResourceEnum::OtpCache.to_string(), email))
.await?;
match record {
Some(_) => Ok("Success delete stored otp".to_string()),
None => bail!("Failed delete stored otp"),
}
}
}
@@ -0,0 +1,186 @@
#[cfg(test)]
mod auth_repository_test {
use crate::{
create_mock_app_state, get_iso_date, make_thing, AuthOtpSchema, AuthRepository,
ResourceEnum, RolesItemDtoRaw, UsersItemDtoRaw, UsersRepository, 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(),
is_active: true,
gender: None,
birthdate: None,
role: make_thing("roles", "user"),
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
#[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("forgot@example.com");
let user_repo = UsersRepository::new(&app_state);
let user_data = user_repo
.query_user_by_email("forgot@example.com".to_string())
.await
.unwrap();
let store = repo.query_store_user(user_data.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 state = create_mock_app_state().await;
let auth_repo = AuthRepository::new(&state);
let email = "delete_me@example.com".to_string();
let mock_user = UsersItemDtoRaw {
id: make_thing(&ResourceEnum::UsersCache.to_string(), &email),
fullname: "Test User".into(),
email: email.clone(),
avatar: None,
phone_number: "08123456789".into(),
is_active: true,
gender: None,
birthdate: None,
role: RolesItemDtoRaw {
id: make_thing("app_roles", &Uuid::new_v4().to_string()),
name: "Dummy Role".into(),
permissions: vec![],
is_deleted: false,
created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()),
},
is_deleted: false,
password: "".into(),
created_at: get_iso_date(),
updated_at: get_iso_date(),
};
let _: Option<UsersItemDtoRaw> = state
.surrealdb_mem
.create((ResourceEnum::UsersCache.to_string(), email.clone()))
.content(mock_user)
.await
.unwrap();
let result = auth_repo.query_delete_stored_user(email.clone()).await;
assert!(
result.is_ok(),
"Delete operation failed: {:?}",
result.err()
);
assert_eq!(result.unwrap(), "Success delete stored user");
}
#[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<AuthOtpSchema> = 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_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<AuthOtpSchema> = 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);
}
}
@@ -0,0 +1,8 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuthOtpSchema {
pub otp: u32,
pub expires_at: DateTime<Utc>,
}
@@ -0,0 +1,365 @@
use super::{
AuthLoginRequestDto, AuthLoginResponsetDto, AuthNewPasswordRequestDto,
AuthRefreshTokenRequestDto, AuthRegisterRequestDto, AuthRepository,
AuthResendOtpRequestDto, AuthUserItemDto, AuthVerifyEmailRequestDto, TokenDto,
};
use crate::{
common_response, decode_refresh_token, encode_access_token, encode_refresh_token,
encode_reset_password_token, extract_email_token, generate_otp, get_iso_date,
hash_password, make_thing, send_email, success_response, validate_request,
verify_password, AppState, Env, ResourceEnum, ResponseSuccessDto, RolesEnum,
RolesItemDto, RolesRepository, UsersActiveInactiveSchema, UsersRepository,
UsersSchema, UsersSetNewPasswordSchema,
};
use axum::{http::StatusCode, response::Response};
use surrealdb::Uuid;
pub struct AuthService;
impl AuthService {
pub async fn mutation_login(
payload: AuthLoginRequestDto,
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 role_repo = RolesRepository::new(state);
match user_repo.query_user_by_email(payload.email.clone()).await {
Ok(user) => {
let is_password_correct =
verify_password(&payload.password, &user.password).unwrap_or(false);
if !is_password_correct {
return common_response(
StatusCode::BAD_REQUEST,
"Email or password not correct",
);
}
if !user.is_active {
return common_response(
StatusCode::BAD_REQUEST,
"Account not active, please verify your email",
);
}
let access_token = match encode_access_token(payload.email.clone()) {
Ok(token) => token,
Err(_) => {
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate access token",
)
}
};
let refresh_token = match encode_refresh_token(payload.email.clone()) {
Ok(token) => token,
Err(_) => {
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate refresh token",
)
}
};
let role_response = role_repo
.query_role_by_id(user.role.id.id.to_raw())
.await
.unwrap();
let response = ResponseSuccessDto {
data: AuthLoginResponsetDto {
user: AuthUserItemDto {
id: user.id.id.to_raw(),
fullname: user.fullname.clone(),
email: user.email.clone(),
is_active: user.is_active.clone(),
avatar: user.avatar.clone(),
phone_number: user.phone_number.clone(),
gender: user.gender.clone(),
birthdate: user.birthdate.clone(),
role: RolesItemDto {
id: role_response.id,
name: role_response.name,
permissions: role_response.permissions,
created_at: role_response.created_at,
updated_at: role_response.updated_at,
},
},
token: TokenDto {
access_token,
refresh_token,
},
},
};
if let Err(_err) = auth_repo.query_store_user(user).await {
return common_response(StatusCode::BAD_REQUEST, "User already login");
}
success_response(response)
}
Err(err) => common_response(StatusCode::UNAUTHORIZED, &err.to_string()),
}
}
pub async fn mutation_register(
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);
let role_repo = RolesRepository::new(state);
let role = match role_repo
.query_role_by_name(RolesEnum::User.to_string())
.await
{
Ok(role) => role,
Err(_) => return common_response(StatusCode::BAD_REQUEST, "Role Not Found"),
};
if user_repo
.query_user_by_email(payload.email.clone())
.await
.is_ok()
{
return common_response(StatusCode::BAD_REQUEST, "User already exists");
}
let hashed_password = match hash_password(&payload.password) {
Ok(hash) => hash,
Err(_) => {
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to hash password",
);
}
};
let new_user = AuthRegisterRequestDto {
email: payload.email,
password: hashed_password,
fullname: payload.fullname,
student_type: payload.student_type,
phone_number: payload.phone_number,
referral_code: payload.referral_code,
referred_by: payload.referred_by,
};
let otp = generate_otp::OtpManager::generate_otp();
match auth_repo
.query_store_otp(new_user.email.clone(), otp.clone())
.await
{
Ok(_) => {
let message = format!("your otp code is {}", otp);
if let Err(err) = send_email(&new_user.email, "OTP Verification", &message) {
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&err.to_string(),
);
}
}
Err(err) => {
return common_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string());
}
}
let role_thing = make_thing(&ResourceEnum::Roles.to_string(), &role.id);
let user_thing = make_thing(
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
);
match user_repo
.query_create_user(UsersSchema {
id: user_thing,
email: new_user.email.clone(),
fullname: new_user.fullname.clone(),
password: new_user.password.clone(),
phone_number: new_user.phone_number.clone(),
created_at: get_iso_date(),
updated_at: get_iso_date(),
role: role_thing,
..Default::default()
})
.await
{
Ok(msg) => common_response(StatusCode::CREATED, &msg),
Err(err) => {
common_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string())
}
}
}
pub async fn mutation_resend_otp(
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);
match repository.query_store_otp(payload.email.clone(), otp).await {
Ok(_) => match send_email(&payload.email, "OTP Verification", &message) {
Ok(_) => common_response(StatusCode::OK, "OTP resent successfully"),
Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()),
},
Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()),
}
}
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(_) => {
return common_response(StatusCode::UNAUTHORIZED, "Invalid refresh token");
}
};
let access_token = match encode_access_token(email.clone()) {
Ok(token) => token,
Err(_) => {
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate access token",
);
}
};
let refresh_token = match encode_refresh_token(email.clone()) {
Ok(token) => token,
Err(_) => {
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate refresh token",
);
}
};
let response = ResponseSuccessDto {
data: TokenDto {
access_token,
refresh_token,
},
};
success_response(response)
}
pub async fn mutation_forgot_password(
payload: AuthResendOtpRequestDto,
state: &AppState,
) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let user_repo = UsersRepository::new(state);
let user_result = user_repo.query_user_by_email(payload.email.clone()).await;
let user = match user_result {
Ok(user) => user,
Err(err) if err.to_string().contains("User not found") => {
return common_response(StatusCode::BAD_REQUEST, "User not found")
}
Err(err) => {
return common_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string())
}
};
let token = match encode_reset_password_token(user.email) {
Ok(token) => token,
Err(_) => {
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate access token",
)
}
};
let env = Env::new();
let fe_url = env.fe_url;
let message = format!(
"You have requested a password reset. Please click the link below to continue: {}/auth/reset-password?token={}",
fe_url, token
);
match send_email(&payload.email, "Reset Password Request", &message) {
Ok(_) => common_response(StatusCode::OK, "Reset Password request send"),
Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()),
}
}
pub async fn mutation_verify_email(
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();
match auth_repo.query_get_stored_otp(email.clone()).await {
Ok(stored_otp) => match stored_otp == payload.otp {
true => match user_repo
.query_active_inactive_user(
email.clone(),
UsersActiveInactiveSchema { is_active: true },
)
.await
{
Ok(_) => match auth_repo.query_delete_stored_otp(email).await {
Ok(_) => common_response(StatusCode::OK, "Email verified successfully"),
Err(e) => {
common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
}
},
Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()),
},
false => match auth_repo.query_delete_stored_otp(email).await {
Ok(_) => common_response(StatusCode::BAD_REQUEST, "Failed to verify OTP"),
Err(e) => common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to delete OTP: {}", e),
),
},
},
Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()),
}
}
pub async fn mutation_new_password(
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 = 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
{
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()),
}
}
}
+29
View File
@@ -0,0 +1,29 @@
use axum::{routing::post, Router};
pub mod auth_controller;
pub mod auth_dto;
pub mod auth_middleware;
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::*;
pub use auth_service::*;
pub fn auth_router() -> Router {
Router::new()
.route("/forgot", post(auth_controller::post_forgot_password))
.route("/login", post(auth_controller::post_login))
.route("/new-password", post(auth_controller::post_new_password))
.route("/refresh", post(auth_controller::post_refresh_token))
.route("/register", post(auth_controller::post_register))
.route("/send-otp", post(auth_controller::post_resend_otp))
.route("/verify-email", post(auth_controller::post_verify_email))
}
@@ -0,0 +1,103 @@
use crate::{
v1::{
auth, permissions, roles, users, AuthLoginRequestDto, AuthLoginResponsetDto, AuthResendOtpRequestDto, AuthVerifyEmailRequestDto
}, AuthNewPasswordRequestDto, AuthRefreshTokenRequestDto, MessageResponseDto, MetaRequestDto, MetaResponseDto, PermissionsItemDto, PermissionsRequestDto, ResponseListSuccessDto, ResponseSuccessDto, RolesItemDto, RolesRequestCreateDto, RolesRequestUpdateDto, TokenDto, UsersCreateRequestDto, UsersDetailItemDto, UsersItemDto, UsersListItemDto, UsersUpdateRequestDto
};
use utoipa::{
openapi::security::{Http, HttpAuthScheme, SecurityScheme},
Modify, OpenApi,
};
#[derive(OpenApi)]
#[openapi(
paths(
auth::auth_controller::post_login,
auth::auth_controller::post_register,
auth::auth_controller::post_verify_email,
auth::auth_controller::post_resend_otp,
auth::auth_controller::post_refresh_token,
auth::auth_controller::post_forgot_password,
auth::auth_controller::post_new_password,
users::users_controller::post_create_user,
users::users_controller::put_update_user,
users::users_controller::put_update_user_me,
users::users_controller::patch_user_active_status,
users::users_controller::delete_user,
users::users_controller::get_user_by_id,
users::users_controller::get_user_me,
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,
permissions::permissions_controller::get_permission_list,
permissions::permissions_controller::get_permission_by_id,
permissions::permissions_controller::post_create_permission,
permissions::permissions_controller::put_update_permission,
permissions::permissions_controller::delete_permission
),
components(
schemas(
MetaRequestDto,
MetaResponseDto,
MessageResponseDto,
AuthLoginRequestDto,
AuthLoginResponsetDto,
AuthVerifyEmailRequestDto,
AuthResendOtpRequestDto,
AuthNewPasswordRequestDto,
AuthRefreshTokenRequestDto,
ResponseSuccessDto<TokenDto>,
RolesItemDto,
RolesRequestCreateDto,
RolesRequestUpdateDto,
PermissionsRequestDto,
PermissionsItemDto,
UsersItemDto,
UsersListItemDto,
UsersUpdateRequestDto,
UsersCreateRequestDto,
ResponseSuccessDto<AuthLoginResponsetDto>,
ResponseListSuccessDto<Vec<RolesItemDto>>,
ResponseSuccessDto<RolesItemDto>,
ResponseListSuccessDto<Vec<UsersListItemDto>>,
ResponseSuccessDto<UsersDetailItemDto>,
ResponseListSuccessDto<Vec<PermissionsItemDto>>,
ResponseSuccessDto<PermissionsItemDto>
)
),
info(
title = "IMPHNEN Backend Service",
description = "IMPHNEN Backend Service for Provide Gacha, Dimentorin and Backoffice Web App",
version = "0.1.0",
contact(
name = "Maulana Sodiqin",
url = ""
),
license(
name = "MIT",
url = "https://opensource.org/licenses/MIT"
)
),
modifiers(&SecurityAddon),
tags(
(name = "Authentication", description = "List of Authentication Endpoints"),
)
)]
pub struct ApiDoc;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
components.add_security_scheme(
"Bearer",
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
);
}
}
}
+8
View File
@@ -0,0 +1,8 @@
use utoipa::OpenApi;
pub mod docs_controller;
pub use docs_controller::*;
pub fn docs_router() -> utoipa::openapi::OpenApi {
ApiDoc::openapi()
}
@@ -0,0 +1,93 @@
use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing;
use utoipa::ToSchema;
use validator::Validate;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct GachaItemRequestDto {
#[validate(length(min = 1, message = "Item name must not be empty"))]
pub name: String,
#[validate(length(min = 1, message = "Image URL must not be empty"))]
pub image_url: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct GachaClaimRequestDto {
#[validate(length(min = 1, message = "User ID must not be empty"))]
pub user_id: String,
#[validate(length(min = 1, message = "Item ID must not be empty"))]
pub item_id: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct GachaRollRequestDto {
#[validate(length(min = 1, message = "Item ID must not be empty"))]
pub item_id: String,
#[validate(range(min = 1, message = "Weight must be greater than zero"))]
pub weight: f32,
#[validate(range(min = 1, message = "Quantity must be at least 1"))]
pub quantity: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct GachaItemDto {
pub id: String,
pub name: String,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GachaItemDtoRaw {
pub id: Thing,
pub name: String,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct GachaClaimDto {
pub id: String,
pub user: String,
pub item: String,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GachaClaimDtoRaw {
pub id: Thing,
pub user: Thing,
pub item: Thing,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct GachaRollDto {
pub id: String,
pub item: String,
pub weight: String,
pub quantity: i32,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GachaRollDtoRaw {
pub id: Thing,
pub item: Thing,
pub weight: String,
pub quantity: i32,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
@@ -0,0 +1,155 @@
use super::{GachaClaimSchema, GachaItemSchema, GachaRollSchema};
use crate::{
get_id, make_thing, query_list_with_meta, AppState, MetaRequestDto, ResourceEnum,
ResponseListSuccessDto,
};
use anyhow::{bail, Result};
pub struct GachaRepository<'a> {
state: &'a AppState,
}
impl<'a> GachaRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
pub async fn query_gacha_item_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<GachaItemSchema>>> {
let mut conditions = vec!["is_deleted = false".into()];
if meta.search.is_some() {
conditions.push("string::contains(name, $search)".into());
}
query_list_with_meta(
&self.state.surrealdb_ws,
&ResourceEnum::GachaItems.to_string(),
&meta,
conditions,
None,
)
.await
}
pub async fn query_gacha_item_by_id(&self, id: String) -> Result<GachaItemSchema> {
let db = &self.state.surrealdb_ws;
let result: Option<GachaItemSchema> = db
.select((ResourceEnum::GachaItems.to_string(), id.clone()))
.await?;
match result {
Some(item) if !item.is_deleted => Ok(item),
_ => bail!("Gacha Item not found"),
}
}
pub async fn query_create_gacha_item(
&self,
data: GachaItemSchema,
) -> Result<String> {
let db = &self.state.surrealdb_ws;
let record: Option<GachaItemSchema> = db
.create(ResourceEnum::GachaItems.to_string())
.content(data)
.await?;
match record {
Some(_) => Ok("Success create Gacha Item".into()),
None => bail!("Failed to create Gacha Item"),
}
}
pub async fn query_update_gacha_item(
&self,
data: GachaItemSchema,
) -> Result<String> {
let db = &self.state.surrealdb_ws;
let record_key = get_id(&data.id)?;
let existing = self.query_gacha_item_by_id(data.id.id.to_raw()).await?;
if existing.is_deleted {
bail!("Gacha Item already deleted");
}
let merged = GachaItemSchema {
created_at: existing.created_at,
..data.clone()
};
let record: Option<GachaItemSchema> =
db.update(record_key).merge(merged).await?;
match record {
Some(_) => Ok("Success update Gacha Item".into()),
None => bail!("Failed to update Gacha Item"),
}
}
pub async fn query_delete_gacha_item(&self, id: String) -> Result<String> {
let db = &self.state.surrealdb_ws;
let item_id = make_thing(&ResourceEnum::GachaItems.to_string(), &id);
let item = self.query_gacha_item_by_id(item_id.id.to_raw()).await?;
if item.is_deleted {
bail!("Gacha Item already deleted");
}
let record_key = get_id(&item.id)?;
let record: Option<GachaItemSchema> = db
.update(record_key)
.merge(serde_json::json!({ "is_deleted": true }))
.await?;
match record {
Some(_) => Ok("Success delete Gacha Item".into()),
None => bail!("Failed to delete Gacha Item"),
}
}
pub async fn query_gacha_claim_by_id(
&self,
id: String,
) -> Result<GachaClaimSchema> {
let db = &self.state.surrealdb_ws;
let result: Option<GachaClaimSchema> = db
.select((ResourceEnum::GachaClaims.to_string(), id.clone()))
.await?;
match result {
Some(claim) if !claim.is_deleted => Ok(claim),
_ => bail!("Gacha Claim not found"),
}
}
pub async fn query_create_gacha_claim(
&self,
data: GachaClaimSchema,
) -> Result<String> {
let db = &self.state.surrealdb_ws;
let record: Option<GachaClaimSchema> = db
.create(ResourceEnum::GachaClaims.to_string())
.content(data)
.await?;
match record {
Some(_) => Ok("Success create Gacha Claim".into()),
None => bail!("Failed to create Gacha Claim"),
}
}
pub async fn query_gacha_roll_by_id(&self, id: String) -> Result<GachaRollSchema> {
let db = &self.state.surrealdb_ws;
let result: Option<GachaRollSchema> = db
.select((ResourceEnum::GachaRolls.to_string(), id.clone()))
.await?;
match result {
Some(roll) if !roll.is_deleted => Ok(roll),
_ => bail!("Gacha Roll not found"),
}
}
pub async fn query_create_gacha_roll(
&self,
data: GachaRollSchema,
) -> Result<String> {
let db = &self.state.surrealdb_ws;
let record: Option<GachaRollSchema> = db
.create(ResourceEnum::GachaRolls.to_string())
.content(data)
.await?;
match record {
Some(_) => Ok("Success create Gacha Roll".into()),
None => bail!("Failed to create Gacha Roll"),
}
}
}
@@ -0,0 +1,92 @@
use crate::{make_thing, ResourceEnum};
use serde::{Deserialize, Serialize};
use surrealdb::{sql::Thing, Uuid};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GachaItemSchema {
pub id: Thing,
pub name: String,
pub image_url: String,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
impl Default for GachaItemSchema {
fn default() -> Self {
GachaItemSchema {
id: make_thing(
&ResourceEnum::GachaItems.to_string(),
&Uuid::new_v4().to_string(),
),
name: String::new(),
image_url: String::new(),
is_deleted: false,
created_at: None,
updated_at: None,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GachaClaimSchema {
pub id: Thing,
pub user: Thing,
pub item: Thing,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
impl Default for GachaClaimSchema {
fn default() -> Self {
GachaClaimSchema {
id: make_thing(
&ResourceEnum::GachaClaims.to_string(),
&Uuid::new_v4().to_string(),
),
user: make_thing(
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
),
item: make_thing(
&ResourceEnum::GachaItems.to_string(),
&Uuid::new_v4().to_string(),
),
is_deleted: false,
created_at: None,
updated_at: None,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GachaRollSchema {
pub id: Thing,
pub item: Thing,
pub weight: f32,
pub quantity: i32,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
impl Default for GachaRollSchema {
fn default() -> Self {
GachaRollSchema {
id: make_thing(
&ResourceEnum::GachaRolls.to_string(),
&Uuid::new_v4().to_string(),
),
item: make_thing(
&ResourceEnum::GachaItems.to_string(),
&Uuid::new_v4().to_string(),
),
weight: 0.2,
quantity: 2,
is_deleted: false,
created_at: None,
updated_at: None,
}
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod gacha_dto;
pub mod gacha_repository;
pub mod gacha_schema;
pub use gacha_repository::*;
pub use gacha_schema::*;
+23
View File
@@ -0,0 +1,23 @@
use axum::{middleware::from_fn, Router};
pub mod auth;
pub mod docs;
pub mod gacha;
pub mod permissions;
pub mod roles;
pub mod users;
pub use auth::*;
pub use docs::*;
pub use permissions::*;
pub use roles::*;
pub use users::*;
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())
.nest("/permissions", permissions_router())
.layer(from_fn(auth::auth_middleware::auth_middleware));
Router::new().merge(public_routes).merge(protected_routes)
}
@@ -0,0 +1,32 @@
use axum::{
routing::{delete, get, post, put},
Router,
};
pub mod permissions_controller;
pub mod permissions_dto;
pub mod permissions_enum;
pub mod permissions_guard;
pub mod permissions_repository;
pub mod permissions_schema;
pub mod permissions_service;
#[cfg(test)]
pub mod permissions_controller_test;
#[cfg(test)]
pub mod permissions_repository_test;
pub use permissions_controller::*;
pub use permissions_dto::*;
pub use permissions_enum::*;
pub use permissions_guard::*;
pub use permissions_repository::*;
pub use permissions_schema::*;
pub fn permissions_router() -> Router {
Router::new()
.route("/", get(get_permission_list))
.route("/create", post(post_create_permission))
.route("/detail/{id}", get(get_permission_by_id))
.route("/update/{id}", put(put_update_permission))
.route("/delete/{id}", delete(delete_permission))
}
@@ -0,0 +1,166 @@
use axum::{
extract::{Path, Query},
response::IntoResponse,
Extension, Json,
};
use crate::{
v1::{
permissions_dto::{PermissionsItemDto, PermissionsRequestDto},
permissions_service::PermissionsService,
},
AppState, MessageResponseDto, MetaRequestDto, ResponseListSuccessDto,
ResponseSuccessDto,
};
use super::{permissions_guard, PermissionsEnum};
#[utoipa::path(
get,
path = "/v1/permissions",
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 permission list", body = ResponseListSuccessDto<Vec<PermissionsItemDto>>)
),
tag = "Permissions"
)]
pub async fn get_permission_list(
headers: axum::http::HeaderMap,
Extension(state): Extension<AppState>,
Query(meta): Query<MetaRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::ReadListPermissions],
)
.await
{
Ok(_) => PermissionsService::get_permission_list(&state, meta).await,
Err(response) => response,
}
}
#[utoipa::path(
get,
path = "/v1/permissions/detail/{id}",
security(
("Bearer" = [])
),
params(("id" = String, Path, description = "Permission ID")),
responses(
(status = 200, description = "Get permission by ID", body = ResponseSuccessDto<PermissionsItemDto>)
),
tag = "Permissions"
)]
pub async fn get_permission_by_id(
headers: axum::http::HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::ReadDetailPermissions],
)
.await
{
Ok(_) => PermissionsService::get_permission_by_id(&state, id).await,
Err(response) => response,
}
}
#[utoipa::path(
post,
security(
("Bearer" = [])
),
path = "/v1/permissions/create",
request_body = PermissionsRequestDto,
responses(
(status = 201, description = "Create new permission", body = MessageResponseDto)
),
tag = "Permissions"
)]
pub async fn post_create_permission(
headers: axum::http::HeaderMap,
Extension(state): Extension<AppState>,
Json(payload): Json<PermissionsRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::CreatePermissions],
)
.await
{
Ok(_) => PermissionsService::create_role(&state, payload).await,
Err(response) => response,
}
}
#[utoipa::path(
put,
security(
("Bearer" = [])
),
path = "/v1/permissions/update/{id}",
request_body = PermissionsRequestDto,
responses(
(status = 200, description = "Update permission", body = MessageResponseDto)
),
tag = "Permissions"
)]
pub async fn put_update_permission(
headers: axum::http::HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
Json(payload): Json<PermissionsRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::UpdatePermissions],
)
.await
{
Ok(_) => PermissionsService::update_permission(&state, payload, id).await,
Err(response) => response,
}
}
#[utoipa::path(
delete,
security(
("Bearer" = [])
),
path = "/v1/permissions/delete/{id}",
responses(
(status = 200, description = "Delete permission", body = MessageResponseDto)
),
tag = "Permissions"
)]
pub async fn delete_permission(
headers: axum::http::HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::DeletePermissions],
)
.await
{
Ok(_) => PermissionsService::delete_permission(&state, id).await,
Err(response) => response,
}
}
@@ -0,0 +1,171 @@
use crate::{
create_mock_app_state, permissions_router,
v1::{
permissions_dto::PermissionsRequestDto,
permissions_repository::PermissionsRepository,
},
AppState,
};
use axum::{http::StatusCode, Extension, Router};
use axum_test::TestServer;
pub fn create_test_app(state: AppState) -> TestServer {
let app = Router::new()
.nest("/v1/permissions", permissions_router())
.layer(Extension(state));
TestServer::new(app).unwrap()
}
async fn create_dummy_permission(
repo: &PermissionsRepository<'_>,
name: &str,
) -> String {
let dto = crate::v1::permissions_schema::PermissionsSchema {
name: name.into(),
..Default::default()
};
let _ = repo.query_create_permission(dto.clone()).await.unwrap();
let found = repo.query_permission_by_name(name.into()).await.unwrap();
found.id.id.to_raw()
}
async fn delete_dummy_permission(repo: &PermissionsRepository<'_>, id: String) {
let _ = repo.query_delete_permission(id).await;
}
#[tokio::test]
async fn test_create_permission_should_return_201() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let server = create_test_app(state.clone());
let payload = PermissionsRequestDto {
name: "Create Permission".into(),
};
let res = server.post("/v1/permissions/create").json(&payload).await;
dbg!(res.text());
assert_eq!(res.status_code(), StatusCode::CREATED);
let id = repo
.query_permission_by_name(payload.name.clone())
.await
.unwrap()
.id
.id
.to_raw();
delete_dummy_permission(&repo, id).await;
}
#[tokio::test]
async fn test_get_permission_list_should_return_200() {
let state = create_mock_app_state().await;
let server = create_test_app(state);
let res = server.get("/v1/permissions?page=1&per_page=10").await;
dbg!(res.text());
assert_eq!(res.status_code(), StatusCode::OK);
}
#[tokio::test]
async fn test_get_permission_by_id_should_return_200() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let id = create_dummy_permission(&repo, "Get Permission").await;
let server = create_test_app(state.clone());
let res = server.get(&format!("/v1/permissions/detail/{}", id)).await;
dbg!(res.text());
assert_eq!(res.status_code(), StatusCode::OK);
delete_dummy_permission(&repo, id).await;
}
#[tokio::test]
async fn test_update_permission_should_return_200() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let id = create_dummy_permission(&repo, "To Update").await;
let server = create_test_app(state.clone());
let payload = PermissionsRequestDto {
name: "Updated Permission".into(),
};
let res = server
.put(&format!("/v1/permissions/update/{}", id.clone()))
.json(&payload)
.await;
dbg!(res.text());
assert_eq!(res.status_code(), StatusCode::OK);
delete_dummy_permission(&repo, id).await;
}
#[tokio::test]
async fn test_delete_permission_should_return_200() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let id = create_dummy_permission(&repo, "To Delete").await;
let server = create_test_app(state);
let res = server
.delete(&format!("/v1/permissions/delete/{}", id))
.await;
dbg!(res.text());
assert_eq!(res.status_code(), StatusCode::OK);
}
#[tokio::test]
async fn test_create_duplicate_permission_should_return_409() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let name = "Duplicate Permission";
let id = create_dummy_permission(&repo, name).await;
let server = create_test_app(state.clone());
let payload = PermissionsRequestDto { name: name.into() };
let res = server.post("/v1/permissions/create").json(&payload).await;
assert_eq!(res.status_code(), StatusCode::CONFLICT);
delete_dummy_permission(&repo, id).await;
}
#[tokio::test]
async fn test_delete_nonexistent_permission_should_return_404() {
let state = create_mock_app_state().await;
let server = create_test_app(state);
let res = server.delete("/v1/permissions/delete/nonexistent-id").await;
assert_eq!(res.status_code(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_create_permission_with_empty_name_should_return_400() {
let state = create_mock_app_state().await;
let server = create_test_app(state);
let payload = PermissionsRequestDto { name: "".into() };
let res = server.post("/v1/permissions/create").json(&payload).await;
dbg!(res.text());
assert_eq!(res.status_code(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_update_nonexistent_permission_should_return_404() {
let state = create_mock_app_state().await;
let server = create_test_app(state);
let payload = PermissionsRequestDto {
name: "Nonexistent".into(),
};
let res = server
.put("/v1/permissions/update/non-exist")
.json(&payload)
.await;
dbg!(res.text());
assert_eq!(res.status_code(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_get_permission_by_invalid_id_should_return_404() {
let state = create_mock_app_state().await;
let server = create_test_app(state);
let res = server.get("/v1/permissions/detail/invalid-id").await;
dbg!(res.text());
assert_eq!(res.status_code(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_get_permission_list_with_invalid_pagination_should_return_400() {
let state = create_mock_app_state().await;
let server = create_test_app(state);
let res = server.get("/v1/permissions?page=abc&per_page=xyz").await;
dbg!(res.text());
assert_eq!(res.status_code(), StatusCode::BAD_REQUEST);
}
@@ -0,0 +1,26 @@
use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing;
use utoipa::ToSchema;
use validator::Validate;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct PermissionsRequestDto {
#[validate(length(min = 1, message = "Permission name must not be empty"))]
pub name: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct PermissionsItemDto {
pub id: String,
pub name: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PermissionsItemDtoRaw {
pub id: Thing,
pub name: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
@@ -0,0 +1,43 @@
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PermissionsEnum {
ReadListUsers,
ReadDetailUsers,
CreateUsers,
DeleteUsers,
UpdateUsers,
ReadListRoles,
ReadDetailRoles,
CreateRoles,
DeleteRoles,
UpdateRoles,
ReadListPermissions,
ReadDetailPermissions,
CreatePermissions,
DeletePermissions,
UpdatePermissions,
}
impl fmt::Display for PermissionsEnum {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let permission_str = match self {
PermissionsEnum::ReadListUsers => "Read List Users",
PermissionsEnum::ReadDetailUsers => "Read Detail Users",
PermissionsEnum::CreateUsers => "Create Users",
PermissionsEnum::DeleteUsers => "Delete Users",
PermissionsEnum::UpdateUsers => "Update Users",
PermissionsEnum::ReadListRoles => "Read List Roles",
PermissionsEnum::ReadDetailRoles => "Read Detail Roles",
PermissionsEnum::CreateRoles => "Create Roles",
PermissionsEnum::DeleteRoles => "Delete Roles",
PermissionsEnum::UpdateRoles => "Update Roles",
PermissionsEnum::ReadListPermissions => "Read List Permissions",
PermissionsEnum::ReadDetailPermissions => "Read Detail Permissions",
PermissionsEnum::CreatePermissions => "Create Permissions",
PermissionsEnum::DeletePermissions => "Delete Permissions",
PermissionsEnum::UpdatePermissions => "Update Permissions",
};
write!(f, "{}", permission_str)
}
}
@@ -0,0 +1,42 @@
use super::PermissionsEnum;
use crate::{common_response, extract_email, AppState, AuthRepository};
use axum::{
http::{HeaderMap, StatusCode},
response::Response,
};
pub async fn permissions_guard(
headers: &HeaderMap,
state: AppState,
required_permissions: Vec<PermissionsEnum>,
) -> Result<(), Response> {
let auth_repo = AuthRepository::new(&state);
let email = extract_email(headers).ok_or_else(|| {
common_response(
StatusCode::UNAUTHORIZED,
"Invalid or missing authorization token",
)
})?;
let raw_user = auth_repo
.query_get_stored_user(email.clone())
.await
.map_err(|_| {
common_response(
StatusCode::UNAUTHORIZED,
"User session expired or not found",
)
})?;
let role = raw_user.role;
let role_permissions: Vec<String> =
role.permissions.into_iter().map(|perm| perm.name).collect();
let has_all_permissions = required_permissions
.iter()
.all(|required| role_permissions.contains(&required.to_string()));
if !has_all_permissions {
return Err(common_response(
StatusCode::FORBIDDEN,
"You don't have the required permissions",
));
}
Ok(())
}
@@ -0,0 +1,127 @@
use super::PermissionsSchema;
use crate::{
get_id, make_thing, query_list_with_meta, AppState, MetaRequestDto, ResourceEnum,
ResponseListSuccessDto,
};
use anyhow::{bail, Result};
pub struct PermissionsRepository<'a> {
state: &'a AppState,
}
impl<'a> PermissionsRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
pub async fn query_permission_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<PermissionsSchema>>> {
let mut conditions = vec!["is_deleted = false".into()];
if meta.search.is_some() {
conditions.push("string::contains(name, $search)".into());
}
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));
}
query_list_with_meta(
&self.state.surrealdb_ws,
&ResourceEnum::Permissions.to_string(),
&meta,
conditions,
None,
)
.await
}
pub async fn query_permission_by_id(
&self,
id: String,
) -> Result<PermissionsSchema> {
let db = &self.state.surrealdb_ws;
let result: Option<PermissionsSchema> = db
.select((ResourceEnum::Permissions.to_string(), id.clone()))
.await?;
match result {
Some(permission) if !permission.is_deleted => Ok(permission),
_ => bail!("Permission not found"),
}
}
pub async fn query_permission_by_name(
&self,
name: String,
) -> Result<PermissionsSchema> {
let db = &self.state.surrealdb_ws;
let sql = format!(
"SELECT * FROM {} WHERE name = $name AND is_deleted = false",
ResourceEnum::Permissions.to_string()
);
let result: Vec<PermissionsSchema> =
db.query(sql).bind(("name", name.clone())).await?.take(0)?;
if let Some(permission) = result.into_iter().next() {
Ok(permission.into())
} else {
bail!("Permission not found")
}
}
pub async fn query_create_permission(
&self,
data: PermissionsSchema,
) -> Result<String> {
let db = &self.state.surrealdb_ws;
let record: Option<PermissionsSchema> = db
.create(ResourceEnum::Permissions.to_string())
.content(data)
.await?;
match record {
Some(_) => Ok("Success create permission".into()),
None => bail!("Failed to create permission"),
}
}
pub async fn query_update_permission(
&self,
data: PermissionsSchema,
) -> Result<String> {
let db = &self.state.surrealdb_ws;
let record_key = get_id(&data.id)?;
let existing = self.query_permission_by_id(data.id.id.to_raw()).await?;
if existing.is_deleted {
bail!("Permission already deleted");
}
let merged = PermissionsSchema {
created_at: existing.created_at,
..data.clone()
};
let record: Option<PermissionsSchema> =
db.update(record_key).merge(merged).await?;
match record {
Some(_) => Ok("Success update permission".into()),
None => bail!("Failed to update permission"),
}
}
pub async fn query_delete_permission(&self, id: String) -> Result<String> {
let db = &self.state.surrealdb_ws;
let permission_id = make_thing(&ResourceEnum::Permissions.to_string(), &id);
let permission = self
.query_permission_by_id(permission_id.id.to_raw())
.await?;
if permission.is_deleted {
bail!("Permission already deleted");
}
let record_key = get_id(&permission.id)?;
let record: Option<PermissionsSchema> = db
.update(record_key)
.merge(serde_json::json!({ "is_deleted": true }))
.await?;
match record {
Some(_) => Ok("Success delete permission".into()),
None => bail!("Failed to delete permission"),
}
}
}
@@ -0,0 +1,111 @@
use crate::{
apps::v1::permissions::{PermissionsRepository, PermissionsSchema},
create_mock_app_state,
};
use chrono::Utc;
fn create_dummy_permission(name: &str) -> PermissionsSchema {
PermissionsSchema {
name: name.to_string(),
..Default::default()
}
}
#[tokio::test]
async fn test_create_permission_should_succeed() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let permission = create_dummy_permission("Test Permission");
let result = repo.query_create_permission(permission).await;
assert!(result.is_ok(), "Create failed: {:?}", result.err());
}
#[tokio::test]
async fn test_query_permission_list_should_return_data() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let _ = repo
.query_create_permission(create_dummy_permission("View"))
.await;
let meta = crate::MetaRequestDto {
page: Some(1),
per_page: Some(10),
search: None,
sort_by: None,
order: None,
filter: None,
filter_by: None,
};
let result = repo.query_permission_list(meta).await;
assert!(result.is_ok(), "List failed: {:?}", result.err());
assert!(!result.unwrap().data.is_empty(), "Data should not be empty");
}
#[tokio::test]
async fn test_query_permission_by_id_should_succeed() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let permission = create_dummy_permission("Detail");
let _ = repo.query_create_permission(permission.clone()).await;
let id = permission.id.id.to_raw();
let result = repo.query_permission_by_id(id).await;
assert!(result.is_ok(), "Get by id failed: {:?}", result.err());
}
#[tokio::test]
async fn test_update_permission_should_succeed() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let mut permission = create_dummy_permission("Update This");
let _ = repo.query_create_permission(permission.clone()).await;
permission.name = "Updated Name".into();
permission.updated_at = Some(Utc::now().to_rfc3339());
let result = repo.query_update_permission(permission).await;
assert!(result.is_ok(), "Update failed: {:?}", result.err());
}
#[tokio::test]
async fn test_delete_permission_should_succeed() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let permission = create_dummy_permission("To Be Deleted");
let _ = repo.query_create_permission(permission.clone()).await;
let id = permission.id.id.to_raw();
let result = repo.query_delete_permission(id).await;
assert!(result.is_ok(), "Delete failed: {:?}", result.err());
}
#[tokio::test]
async fn test_delete_permission_should_fail_if_already_deleted() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let permission = create_dummy_permission("Delete Twice");
let _ = repo.query_create_permission(permission.clone()).await;
let id = permission.id.id.to_raw();
let _ = repo.query_delete_permission(id.clone()).await;
let second = repo.query_delete_permission(id).await;
assert!(second.is_err(), "Should fail on second delete");
}
#[tokio::test]
async fn test_update_permission_should_fail_if_deleted() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let mut permission = create_dummy_permission("To Be Updated Then Deleted");
let _ = repo.query_create_permission(permission.clone()).await;
let id = permission.id.id.to_raw();
let _ = repo.query_delete_permission(id.clone()).await;
permission.name = "Try Update".into();
let result = repo.query_update_permission(permission).await;
assert!(result.is_err(), "Update on deleted should fail");
}
#[tokio::test]
async fn test_query_permission_by_id_should_fail_if_not_found() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let result = repo.query_permission_by_id("non-existent-id".into()).await;
assert!(result.is_err(), "Expected error for not found id");
}
@@ -0,0 +1,27 @@
use crate::{make_thing, ResourceEnum};
use serde::{Deserialize, Serialize};
use surrealdb::{sql::Thing, Uuid};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PermissionsSchema {
pub id: Thing,
pub name: String,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
impl Default for PermissionsSchema {
fn default() -> Self {
PermissionsSchema {
id: make_thing(
&ResourceEnum::Permissions.to_string(),
&Uuid::new_v4().to_string(),
),
name: String::new(),
is_deleted: false,
created_at: None,
updated_at: None,
}
}
}
@@ -0,0 +1,112 @@
use crate::{
common_response, make_thing, success_list_response, success_response,
validate_request, AppState, MetaRequestDto, PermissionsRepository,
PermissionsSchema, ResourceEnum, ResponseListSuccessDto, ResponseSuccessDto,
};
use axum::http::StatusCode;
use axum::response::Response;
use super::PermissionsRequestDto;
pub struct PermissionsService;
impl PermissionsService {
pub async fn get_permission_list(
state: &AppState,
meta: MetaRequestDto,
) -> Response {
let repo = PermissionsRepository::new(state);
match repo.query_permission_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_permission_by_id(state: &AppState, id: String) -> Response {
let repo = PermissionsRepository::new(state);
match repo.query_permission_by_id(id).await {
Ok(permission) => success_response(ResponseSuccessDto { data: permission }),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
}
}
pub async fn create_role(
state: &AppState,
payload: PermissionsRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let repo = PermissionsRepository::new(state);
match repo.query_permission_by_name(payload.name.clone()).await {
Ok(_role) => {
return common_response(
StatusCode::CONFLICT,
"Permission name already exists",
);
}
Err(err) if err.to_string().contains("not found") => {}
Err(e) => {
return common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string());
}
}
match repo
.query_create_permission(PermissionsSchema {
name: payload.name,
..Default::default()
})
.await
{
Ok(msg) => common_response(StatusCode::CREATED, &msg),
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn update_permission(
state: &AppState,
payload: PermissionsRequestDto,
id: String,
) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let repo = PermissionsRepository::new(state);
match repo
.query_update_permission(PermissionsSchema {
id: make_thing(&ResourceEnum::Permissions.to_string(), &id),
name: payload.name,
..Default::default()
})
.await
{
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => {
if e.to_string().contains("not found") {
common_response(StatusCode::NOT_FOUND, "Permission not found")
} else {
common_response(StatusCode::BAD_REQUEST, &e.to_string())
}
}
}
}
pub async fn delete_permission(state: &AppState, id: String) -> Response {
let repo = PermissionsRepository::new(state);
match repo.query_delete_permission(id).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => {
if e.to_string().contains("not found") {
common_response(StatusCode::NOT_FOUND, "Permission not found")
} else {
common_response(StatusCode::BAD_REQUEST, &e.to_string())
}
}
}
}
}
+32
View File
@@ -0,0 +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))
}
@@ -0,0 +1,164 @@
use axum::{
extract::{Path, Query},
response::IntoResponse,
Extension, Json,
};
use super::{RolesItemDto, RolesRequestCreateDto, RolesRequestUpdateDto};
use crate::{
permissions_guard, v1::roles_service::RolesService, AppState, MessageResponseDto,
MetaRequestDto, PermissionsEnum, ResponseListSuccessDto, ResponseSuccessDto,
};
#[utoipa::path(
get,
security(
("Bearer" = [])
),
path = "/v1/roles",
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 role list", body = ResponseListSuccessDto<Vec<RolesItemDto>>)
),
tag = "Roles"
)]
pub async fn get_role_list(
headers: axum::http::HeaderMap,
Extension(state): Extension<AppState>,
Query(meta): Query<MetaRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::ReadListRoles],
)
.await
{
Ok(_) => RolesService::get_role_list(&state, meta).await,
Err(response) => response,
}
}
#[utoipa::path(
get,
security(
("Bearer" = [])
),
path = "/v1/roles/detail/{id}",
params(("id" = String, Path, description = "Role ID")),
responses(
(status = 200, description = "Get role by ID", body = ResponseSuccessDto<RolesItemDto>)
),
tag = "Roles"
)]
pub async fn get_role_by_id(
headers: axum::http::HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::ReadDetailRoles],
)
.await
{
Ok(_) => RolesService::get_role_by_id(&state, id).await,
Err(response) => response,
}
}
#[utoipa::path(
post,
security(
("Bearer" = [])
),
path = "/v1/roles/create",
request_body = RolesRequestCreateDto,
responses(
(status = 201, description = "Create new role", body = MessageResponseDto)
),
tag = "Roles"
)]
pub async fn post_create_role(
headers: axum::http::HeaderMap,
Extension(state): Extension<AppState>,
Json(payload): Json<RolesRequestCreateDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::CreateRoles],
)
.await
{
Ok(_) => RolesService::create_role(&state, payload).await,
Err(response) => response,
}
}
#[utoipa::path(
put,
security(
("Bearer" = [])
),
path = "/v1/roles/update/{id}",
request_body = RolesRequestUpdateDto,
responses(
(status = 200, description = "Update role", body = MessageResponseDto)
),
tag = "Roles"
)]
pub async fn put_update_role(
headers: axum::http::HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
Json(payload): Json<RolesRequestUpdateDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::UpdateRoles],
)
.await
{
Ok(_) => RolesService::update_role(&state, id, payload).await,
Err(response) => response,
}
}
#[utoipa::path(
delete,
security(
("Bearer" = [])
),
path = "/v1/roles/delete/{id}",
responses(
(status = 200, description = "Delete role", body = MessageResponseDto)
),
tag = "Roles"
)]
pub async fn delete_role(
headers: axum::http::HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::DeleteRoles],
)
.await
{
Ok(_) => RolesService::delete_role(&state, id).await,
Err(response) => response,
}
}
@@ -0,0 +1,172 @@
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()
}
async fn delete_dummy_role(state: AppState, id: String) {
let repo = RolesRepository::new(&state);
let _ = repo.query_delete_role(id).await;
}
#[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 #1".into(),
permissions: vec![],
};
let res = server.post("/v1/roles/create").json(&payload).await;
let repo = RolesRepository::new(&state);
assert_eq!(res.status_code(), StatusCode::CREATED);
let id = repo
.query_role_by_name(payload.name.clone())
.await
.unwrap()
.id;
delete_dummy_role(state, id).await;
}
#[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.clone());
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);
let id = repo
.query_role_by_name(payload.name.clone().unwrap())
.await
.unwrap()
.id;
delete_dummy_role(state.clone(), id).await;
}
#[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);
}
#[tokio::test]
async fn test_post_create_role_with_empty_name_should_return_400() {
let state = create_mock_app_state().await;
let server = create_test_app(state);
let payload = RolesRequestCreateDto {
name: "".into(),
permissions: vec![],
};
let res = server.post("/v1/roles/create").json(&payload).await;
dbg!(res.text());
assert_eq!(res.status_code(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_put_update_nonexistent_role_should_return_404() {
let state = create_mock_app_state().await;
let server = create_test_app(state);
let payload = RolesRequestUpdateDto {
name: Some("Does Not Exist".into()),
permissions: Some(vec![]),
};
let res = server
.put("/v1/roles/update/nonexistent-id")
.json(&payload)
.await;
dbg!(res.text());
assert_eq!(res.status_code(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_delete_nonexistent_role_should_return_404() {
let state = create_mock_app_state().await;
let server = create_test_app(state);
let res = server.delete("/v1/roles/delete/nonexistent-id").await;
dbg!(res.text());
assert_eq!(res.status_code(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_get_role_list_with_invalid_page_params_should_return_400() {
let state = create_mock_app_state().await;
let server = create_test_app(state);
let res = server.get("/v1/roles?page=abc&per_page=xyz").await;
dbg!(res.text());
assert_eq!(res.status_code(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_create_duplicate_role_should_return_409() {
let state = create_mock_app_state().await;
let server = create_test_app(state);
let payload = RolesRequestCreateDto {
name: "Admin".into(),
permissions: vec![],
};
let res = server.post("/v1/roles/create").json(&payload).await;
dbg!(res.text());
assert_eq!(res.status_code(), StatusCode::CONFLICT);
}
@@ -0,0 +1,66 @@
use crate::{PermissionsItemDto, PermissionsItemDtoRaw};
use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing;
use utoipa::ToSchema;
use validator::Validate;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct RolesRequestUpdateDto {
#[validate(length(min = 1, message = "Role name must not be empty"))]
pub name: Option<String>,
pub permissions: Option<Vec<String>>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct RolesRequestCreateDto {
#[validate(length(min = 1, message = "Role name must not be empty"))]
pub name: String,
pub permissions: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct RolesItemListDto {
pub id: String,
pub name: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct RolesItemDto {
pub id: String,
pub name: String,
pub permissions: Vec<PermissionsItemDto>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct RolesItemByIdDto {
pub id: String,
pub name: String,
pub is_deleted: bool,
pub permissions: Vec<PermissionsItemDto>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RolesItemByIdDtoRaw {
pub id: Thing,
pub name: String,
pub permissions: Vec<PermissionsItemDtoRaw>,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RolesItemDtoRaw {
pub id: Thing,
pub name: String,
pub permissions: Vec<PermissionsItemDtoRaw>,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
@@ -0,0 +1,19 @@
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RolesEnum {
Admin,
User,
Staf,
}
impl fmt::Display for RolesEnum {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let roles_str = match self {
RolesEnum::Admin => "Admin",
RolesEnum::User => "User",
RolesEnum::Staf => "Staf",
};
write!(f, "{}", roles_str)
}
}
@@ -0,0 +1,205 @@
use super::{
RolesItemByIdDto, RolesItemByIdDtoRaw, RolesRequestCreateDto,
RolesRequestUpdateDto, RolesSchema,
};
use crate::{
extract_id, get_id, make_thing, query_list_with_meta, AppState, MetaRequestDto,
PermissionsItemDto, ResourceEnum, ResponseListSuccessDto,
};
use anyhow::{bail, Result};
use surrealdb::sql::Thing;
use surrealdb::Uuid;
pub struct RolesRepository<'a> {
state: &'a AppState,
}
impl<'a> RolesRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
pub async fn query_raw_role_by_id(&self, id: &str) -> Result<RolesSchema> {
let db = &self.state.surrealdb_ws;
let role: Option<RolesSchema> =
db.select((ResourceEnum::Roles.to_string(), id)).await?;
match role {
Some(r) if !r.is_deleted => Ok(r),
_ => bail!("Role not found"),
}
}
pub async fn query_role_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<RolesSchema>>> {
let mut conditions = vec!["is_deleted = false".into()];
if meta.search.is_some() {
conditions.push("string::contains(name, $search)".into());
}
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));
}
query_list_with_meta(
&self.state.surrealdb_ws,
&ResourceEnum::Roles.to_string(),
&meta,
conditions,
None,
)
.await
}
pub async fn query_role_by_name(&self, name: String) -> Result<RolesItemByIdDto> {
let db = &self.state.surrealdb_ws;
let sql = format!(
"SELECT *, permissions FROM {} WHERE name = $name AND is_deleted = false LIMIT 1 FETCH permissions",
ResourceEnum::Roles.to_string()
);
let mut result = db.query(sql).bind(("name", name.clone())).await?;
let role: Option<RolesItemByIdDtoRaw> = result.take(0)?;
let role = match role {
Some(r) if !r.is_deleted => r,
_ => bail!("Role not found"),
};
let permissions = role
.permissions
.into_iter()
.map(|perm| PermissionsItemDto {
id: extract_id(&perm.id),
name: perm.name,
created_at: perm.created_at,
updated_at: perm.updated_at,
})
.collect::<Vec<_>>();
Ok(RolesItemByIdDto {
id: extract_id(&role.id),
name: role.name,
is_deleted: role.is_deleted,
permissions,
created_at: role.created_at,
updated_at: role.updated_at,
})
}
pub async fn query_role_by_id(&self, id: String) -> Result<RolesItemByIdDto> {
let db = &self.state.surrealdb_ws;
let query = format!(
"SELECT *, permissions.* AS permissions
FROM app_roles:⟨{}⟩ WHERE is_deleted = false FETCH permissions",
id
);
let mut result = db.query(query).await?;
let role: Option<RolesItemByIdDtoRaw> = result.take(0)?;
let role = match role {
Some(r) if !r.is_deleted => r,
_ => bail!("Role not found"),
};
let permissions = role
.permissions
.into_iter()
.map(|perm| PermissionsItemDto {
id: extract_id(&perm.id),
name: perm.name,
created_at: perm.created_at,
updated_at: perm.updated_at,
})
.collect::<Vec<_>>();
Ok(RolesItemByIdDto {
id: extract_id(&role.id),
name: role.name,
is_deleted: role.is_deleted,
permissions,
created_at: role.created_at,
updated_at: role.updated_at,
})
}
pub async fn query_create_role(
&self,
payload: RolesRequestCreateDto,
) -> Result<String> {
let db = &self.state.surrealdb_ws;
let role_id = Uuid::new_v4().to_string();
let permission_things: Vec<Thing> = payload
.permissions
.iter()
.map(|id| make_thing(&ResourceEnum::Permissions.to_string(), id))
.collect();
let role = RolesSchema {
id: make_thing(&ResourceEnum::Roles.to_string(), &role_id),
name: payload.name,
is_deleted: false,
permissions: permission_things,
created_at: Some(crate::get_iso_date()),
updated_at: Some(crate::get_iso_date()),
};
let _: Option<RolesSchema> = db
.create((&ResourceEnum::Roles.to_string(), role_id))
.content(role)
.await?;
Ok("Role with permissions created successfully".into())
}
pub async fn query_update_role(
&self,
id: String,
data: RolesRequestUpdateDto,
) -> Result<String> {
let db = &self.state.surrealdb_ws;
let thing_id = make_thing(&ResourceEnum::Roles.to_string(), &id);
let existing = self.query_raw_role_by_id(&id).await?;
if existing.is_deleted {
bail!("Role already deleted");
}
let permissions: Vec<Thing> = if let Some(permission_ids) = &data.permissions {
permission_ids
.iter()
.map(|id| make_thing(&ResourceEnum::Permissions.to_string(), id))
.collect()
} else {
existing
.permissions
.iter()
.map(|p| make_thing(&ResourceEnum::Permissions.to_string(), &p.id.to_raw()))
.collect()
};
let merged = RolesSchema {
id: thing_id,
name: data.name.unwrap_or(existing.name),
permissions,
is_deleted: existing.is_deleted,
created_at: existing.created_at,
updated_at: Some(crate::get_iso_date()),
};
let record: Option<RolesSchema> =
db.update(get_id(&merged.id)?).content(merged).await?;
match record {
Some(_) => Ok("Success update role".into()),
None => bail!("Failed to update role"),
}
}
pub async fn query_delete_role(&self, id: String) -> Result<String> {
let db = &self.state.surrealdb_ws;
let role_id = make_thing(&ResourceEnum::Roles.to_string(), &id);
let role = self.query_role_by_id(role_id.id.to_raw()).await?;
if role.is_deleted {
bail!("Role already deleted");
}
let record_key = get_id(&role_id)?;
let record: Option<RolesSchema> = db
.update(record_key)
.merge(serde_json::json!({ "is_deleted": true }))
.await?;
match record {
Some(_) => Ok("Success delete role".into()),
None => bail!("Failed to delete role"),
}
}
}
@@ -0,0 +1,194 @@
use crate::{
apps::v1::{
permissions::{
permissions_repository::PermissionsRepository,
permissions_schema::PermissionsSchema,
},
roles::{
roles_dto::{RolesRequestCreateDto, RolesRequestUpdateDto},
roles_repository::RolesRepository,
},
},
create_mock_app_state, get_iso_date, make_thing, ResourceEnum,
};
use surrealdb::sql::Thing;
use surrealdb::Uuid;
#[tokio::test]
async fn test_query_create_role_should_succeed() {
let state = create_mock_app_state().await;
let perm_repo = PermissionsRepository::new(&state);
let role_repo = RolesRepository::new(&state);
let perm_id = Uuid::new_v4().to_string();
let permission = PermissionsSchema {
id: Thing::from((ResourceEnum::Permissions.to_string(), perm_id.clone())),
name: "Read Quiz".into(),
is_deleted: false,
created_at: Some(get_iso_date()),
updated_at: None,
};
perm_repo.query_create_permission(permission).await.unwrap();
let payload = RolesRequestCreateDto {
name: "Student".into(),
permissions: vec![perm_id.clone()],
};
let result = role_repo.query_create_role(payload).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_query_role_by_name_should_return_data() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let payload = RolesRequestCreateDto {
name: "Viewer".into(),
permissions: vec![],
};
role_repo.query_create_role(payload.clone()).await.unwrap();
let role = role_repo.query_role_by_name("Viewer".into()).await.unwrap();
assert_eq!(role.name, "Viewer");
}
#[tokio::test]
async fn test_query_role_by_id_should_return_data() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let payload = RolesRequestCreateDto {
name: "Tester".into(),
permissions: vec![],
};
role_repo.query_create_role(payload.clone()).await.unwrap();
let role = role_repo.query_role_by_name("Tester".into()).await.unwrap();
let result = role_repo.query_role_by_id(role.id).await.unwrap();
assert_eq!(result.name, "Tester");
}
#[tokio::test]
async fn test_query_update_role_should_update_name_and_permissions() {
let state = create_mock_app_state().await;
let repo = RolesRepository::new(&state);
let perm_repo = PermissionsRepository::new(&state);
let original_perm_id = Uuid::new_v4().to_string();
let original_perm = PermissionsSchema {
id: make_thing(&ResourceEnum::Permissions.to_string(), &original_perm_id),
name: "Original Permission".into(),
is_deleted: false,
created_at: Some(crate::get_iso_date()),
updated_at: None,
};
perm_repo
.query_create_permission(original_perm)
.await
.unwrap();
let create_payload = RolesRequestCreateDto {
name: "Role For Update".into(),
permissions: vec![original_perm_id.clone()],
};
repo.query_create_role(create_payload).await.unwrap();
let existing_role = repo
.query_role_by_name("Role For Update".into())
.await
.unwrap();
let existing_role_id = existing_role.id.clone();
let new_perm_id = Uuid::new_v4().to_string();
let new_perm = PermissionsSchema {
id: make_thing(&ResourceEnum::Permissions.to_string(), &new_perm_id),
name: "New Permission".into(),
is_deleted: false,
created_at: Some(crate::get_iso_date()),
updated_at: None,
};
perm_repo.query_create_permission(new_perm).await.unwrap();
let update_payload = RolesRequestUpdateDto {
name: Some("Updated Role Name".into()),
permissions: Some(vec![new_perm_id.clone()]),
};
let update_result = repo
.query_update_role(existing_role_id.clone(), update_payload)
.await;
assert!(update_result.is_ok());
let updated = repo
.query_role_by_id(existing_role_id.clone())
.await
.unwrap();
assert_eq!(updated.name, "Updated Role Name");
}
#[tokio::test]
async fn test_query_delete_role_should_soft_delete() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let payload = RolesRequestCreateDto {
name: "Temporary".into(),
permissions: vec![],
};
role_repo.query_create_role(payload.clone()).await.unwrap();
let role = role_repo
.query_role_by_name("Temporary".into())
.await
.unwrap();
let result = role_repo.query_delete_role(role.id.clone()).await;
assert!(result.is_ok());
let deleted = role_repo.query_role_by_id(role.id).await;
assert!(deleted.is_err());
}
#[tokio::test]
async fn test_query_update_role_should_fallback_to_existing_permissions_if_none_provided(
) {
let state = create_mock_app_state().await;
let repo = RolesRepository::new(&state);
let perm_repo = PermissionsRepository::new(&state);
let perm_id = Uuid::new_v4().to_string();
let permission = PermissionsSchema {
id: make_thing(&ResourceEnum::Permissions.to_string(), &perm_id),
name: "Permission for Fallback".into(),
is_deleted: false,
created_at: Some(crate::get_iso_date()),
updated_at: None,
};
perm_repo.query_create_permission(permission).await.unwrap();
let create_payload = RolesRequestCreateDto {
name: "Role With Permission".into(),
permissions: vec![perm_id.clone()],
};
repo.query_create_role(create_payload).await.unwrap();
let existing = repo
.query_role_by_name("Role With Permission".into())
.await
.unwrap();
let existing_id = existing.id.clone();
let update_payload = RolesRequestUpdateDto {
name: Some("Updated Role Name".into()),
permissions: None,
};
let update_res = repo
.query_update_role(existing_id.clone(), update_payload)
.await;
assert!(update_res.is_ok());
}
#[tokio::test]
async fn test_query_role_by_name_should_fail_if_not_found() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let result = role_repo.query_role_by_name("ghost-role".into()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_query_delete_role_should_fail_if_already_deleted() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let payload = RolesRequestCreateDto {
name: "SoftDeleteTest".into(),
permissions: vec![],
};
role_repo.query_create_role(payload.clone()).await.unwrap();
let role = role_repo
.query_role_by_name("SoftDeleteTest".into())
.await
.unwrap();
role_repo.query_delete_role(role.id.clone()).await.unwrap();
let result = role_repo.query_delete_role(role.id);
assert!(result.await.is_err());
}
@@ -0,0 +1,33 @@
use serde::{Deserialize, Serialize};
use surrealdb::{sql::Thing, Uuid};
use crate::{make_thing, ResourceEnum};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RolesSchema {
pub id: Thing,
pub name: String,
pub is_deleted: bool,
pub permissions: Vec<Thing>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
impl Default for RolesSchema {
fn default() -> Self {
RolesSchema {
id: make_thing(
&ResourceEnum::Roles.to_string(),
&Uuid::new_v4().to_string(),
),
permissions: vec![make_thing(
&ResourceEnum::Permissions.to_string(),
&Uuid::new_v4().to_string(),
)],
name: String::new(),
is_deleted: false,
created_at: None,
updated_at: None,
}
}
}
@@ -0,0 +1,112 @@
use super::{RolesRepository, RolesRequestCreateDto, RolesRequestUpdateDto};
use crate::{
common_response, success_list_response, success_response, validate_request,
AppState, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto,
};
use axum::{http::StatusCode, response::Response};
pub struct RolesService;
impl RolesService {
pub async fn get_role_list(state: &AppState, meta: MetaRequestDto) -> 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) -> 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,
) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let repo = RolesRepository::new(state);
match repo.query_role_by_name(payload.name.clone()).await {
Ok(_role) => {
return common_response(StatusCode::CONFLICT, "Role name already exists");
}
Err(err) if err.to_string().contains("not found") => {}
Err(e) => {
return common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string());
}
}
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,
) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let repo = RolesRepository::new(state);
let existing_role = match repo.query_role_by_id(id.clone()).await {
Ok(role) => role,
Err(err) if err.to_string().contains("not found") => {
return common_response(StatusCode::NOT_FOUND, "Role not found");
}
Err(e) => {
return common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string());
}
};
if let Some(new_name) = payload.name.clone() {
match repo.query_role_by_name(new_name.clone()).await {
Ok(role_with_same_name) => {
if role_with_same_name.id != existing_role.id {
return common_response(
StatusCode::CONFLICT,
"Role name already exists",
);
}
}
Err(err) if err.to_string().contains("not found") => {}
Err(e) => {
return common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string());
}
}
}
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) -> Response {
let repo = RolesRepository::new(state);
match repo.query_role_by_id(id.clone()).await {
Ok(_) => {}
Err(err) if err.to_string().contains("not found") => {
return common_response(StatusCode::NOT_FOUND, "Role not found");
}
Err(e) => {
return common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string());
}
}
match repo.query_delete_role(id).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
}
+33
View File
@@ -0,0 +1,33 @@
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("/me", get(get_user_me))
.route("/delete/{id}", delete(delete_user))
.route("/detail/{id}", get(get_user_by_id))
.route("/update/{id}", put(put_update_user))
.route("/update/me", put(put_update_user_me))
}
@@ -0,0 +1,242 @@
use axum::extract::{Path, Query};
use axum::http::HeaderMap;
use axum::response::IntoResponse;
use axum::{Extension, Json};
use crate::{
permissions_guard, MessageResponseDto, PermissionsEnum, ResponseListSuccessDto,
ResponseSuccessDto, UsersActiveInactiveRequestDto, UsersCreateRequestDto,
UsersDetailItemDto,
};
use crate::{v1::users_service::UsersService, AppState, MetaRequestDto};
use super::{UsersListItemDto, UsersUpdateRequestDto};
#[utoipa::path(
get,
security(
("Bearer" = [])
),
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<UsersListItemDto>>)
),
tag = "Users"
)]
pub async fn get_user_list(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Query(meta): Query<MetaRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::ReadListUsers],
)
.await
{
Ok(_) => UsersService::get_user_list(&state, meta).await,
Err(response) => response,
}
}
#[utoipa::path(
get,
security(
("Bearer" = [])
),
path = "/v1/users/detail/{id}",
params(
("id" = String, Path, description = "User ID")
),
responses(
(status = 200, description = "Get user by ID", body = ResponseSuccessDto<UsersDetailItemDto>)
),
tag = "Users"
)]
pub async fn get_user_by_id(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::ReadDetailUsers],
)
.await
{
Ok(_) => UsersService::get_user_by_id(&state, id).await,
Err(response) => response,
}
}
#[utoipa::path(
get,
security(
("Bearer" = [])
),
path = "/v1/users/me",
responses(
(status = 200, description = "Get user by ID", body = ResponseSuccessDto<UsersDetailItemDto>)
),
tag = "Users"
)]
pub async fn get_user_me(
Extension(state): Extension<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
match permissions_guard(&headers, state.clone(), vec![]).await {
Ok(_) => UsersService::get_user_me(headers, &state).await,
Err(response) => response,
}
}
#[utoipa::path(
post,
security(
("Bearer" = [])
),
path = "/v1/users/create",
request_body = UsersCreateRequestDto,
responses(
(status = 201, description = "Create new user", body = MessageResponseDto)
),
tag = "Users"
)]
pub async fn post_create_user(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Json(payload): Json<UsersCreateRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::CreateUsers],
)
.await
{
Ok(_) => UsersService::create_user(&state, payload).await,
Err(response) => response,
}
}
#[utoipa::path(
put,
security(
("Bearer" = [])
),
path = "/v1/users/update/{id}",
request_body = UsersUpdateRequestDto,
responses(
(status = 200, description = "Update user", body = MessageResponseDto)
),
tag = "Users"
)]
pub async fn put_update_user(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
Json(payload): Json<UsersUpdateRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::UpdateUsers],
)
.await
{
Ok(_) => UsersService::update_user(&state, id, payload).await,
Err(response) => response,
}
}
#[utoipa::path(
put,
security(
("Bearer" = [])
),
path = "/v1/users/update/me",
request_body = UsersUpdateRequestDto,
responses(
(status = 200, description = "Update user me", body = MessageResponseDto)
),
tag = "Users"
)]
pub async fn put_update_user_me(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Json(payload): Json<UsersUpdateRequestDto>,
) -> impl IntoResponse {
match permissions_guard(&headers, state.clone(), vec![]).await {
Ok(_) => UsersService::update_user_me(&state, headers, payload).await,
Err(response) => response,
}
}
#[utoipa::path(
put,
security(
("Bearer" = [])
),
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(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
Json(payload): Json<UsersActiveInactiveRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::UpdateUsers],
)
.await
{
Ok(_) => UsersService::set_user_active_status(&state, id, payload).await,
Err(response) => response,
}
}
#[utoipa::path(
delete,
security(
("Bearer" = [])
),
path = "/v1/users/delete/{id}",
responses(
(status = 200, description = "Soft delete user", body = MessageResponseDto)
),
tag = "Users"
)]
pub async fn delete_user(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::DeleteUsers],
)
.await
{
Ok(_) => UsersService::delete_user(&state, id).await,
Err(response) => response,
}
}
@@ -0,0 +1,602 @@
use crate::{
apps::v1::users_router, create_mock_app_state, RolesEnum, RolesRepository,
UsersActiveInactiveRequestDto, UsersCreateRequestDto, UsersRepository,
UsersUpdateRequestDto,
};
use axum::{http::StatusCode, Extension};
use axum_test::TestServer;
use surrealdb::Uuid;
#[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_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;
dbg!(res.text());
dbg!(res.status_code());
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;
let role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::User.to_string())
.await
.unwrap();
let role_id = role.id;
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: format!("test-{}@create.com", Uuid::new_v4()).into(),
role_id,
password: "Password1!".into(),
phone_number: "081234567890".into(),
is_active: true,
};
let res = server.post("/v1/users/create").json(&payload).await;
assert_eq!(res.status_code(), StatusCode::CREATED);
}
#[tokio::test]
async fn test_get_user_detail_should_return_200() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::User.to_string())
.await
.unwrap();
let role_id = role.id;
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(),
role_id,
password: "Password1!".into(),
phone_number: "081234567890".into(),
is_active: true,
};
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_delete_user_should_return_200() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::User.to_string())
.await
.expect("Failed to get role");
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: "Test Delete Me".into(),
email: "test@delete-me.com".into(),
password: "Password1!".into(),
role_id: role.id.clone(),
phone_number: "081234567890".into(),
is_active: true,
};
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.clone()).await;
assert!(detail_res.is_ok(), "User not found after creation");
let user = detail_res.unwrap();
let user_id_for_delete = user.id.id.to_raw();
let delete_res = server
.delete(&format!("/v1/users/delete/{}", user_id_for_delete))
.await;
assert_eq!(delete_res.status_code(), StatusCode::OK);
}
#[tokio::test]
async fn test_activate_user_should_return_200() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::User.to_string())
.await
.unwrap();
let role_id = role.id;
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: format!("inactive-{}@test.com", Uuid::new_v4()).into(),
password: "Password1!".into(),
role_id,
phone_number: "081234567890".into(),
is_active: false,
};
let res_create = server.post("/v1/users/create").json(&payload).await;
dbg!(res_create.text());
let user = repo.query_user_by_email(payload.email).await.unwrap();
dbg!(user.fullname);
let user_id = user.id.id.to_raw();
let res = server
.put(&format!("/v1/users/activate/{}", user_id))
.json(&UsersActiveInactiveRequestDto { is_active: true })
.await;
dbg!(res.text());
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 role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::User.to_string())
.await
.unwrap();
let role_id = role.id.clone();
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 unique_email = format!("update_{}@test.com", Uuid::new_v4());
let payload = UsersCreateRequestDto {
fullname: "Old Name".into(),
email: unique_email.clone(),
password: "Password1!".into(),
role_id: role_id.clone(),
phone_number: "081234567890".into(),
is_active: true,
};
let create_res = server.post("/v1/users/create").json(&payload).await;
assert_eq!(create_res.status_code(), StatusCode::CREATED);
let user = repo.query_user_by_email(unique_email).await.unwrap();
let user_id = user.id.id.to_raw();
let update_payload = UsersUpdateRequestDto {
fullname: "Updated Name".into(),
email: payload.email.clone(),
phone_number: "081234567890".into(),
role_id: user.role.id.id.to_raw(),
is_active: true,
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();
let body = res.text();
assert_eq!(status, StatusCode::OK, "Response body: {}", body);
}
#[tokio::test]
async fn test_create_user_should_fail_if_email_taken() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::User.to_string())
.await
.unwrap();
let role_id = role.id.clone();
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state.clone()));
let server = TestServer::new(app).unwrap();
let email = format!("test_{}@example.com", Uuid::new_v4());
let payload = UsersCreateRequestDto {
fullname: "User Satu".into(),
email: email.clone(),
password: "Password1!".into(),
role_id,
phone_number: "081234567890".into(),
is_active: true,
};
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 role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::User.to_string())
.await
.unwrap();
let role_id = role.id.clone();
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(),
role_id,
password: "Password1!".into(),
phone_number: "081234567890".into(),
is_active: true,
};
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 already deleted"));
}
#[tokio::test]
async fn test_update_user_should_fail_if_user_not_found() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::User.to_string())
.await
.unwrap();
let role_id = role.id.clone();
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(),
phone_number: "081234567890".into(),
role_id,
is_active: true,
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_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_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 role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::User.to_string())
.await
.unwrap();
let role_id = role.id.clone();
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 {
role_id,
fullname: "Soft Deleted".into(),
email: "softdeleted@test.com".into(),
password: "Password1!".into(),
phone_number: "081234567890".into(),
is_active: true,
};
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;
dbg!(res.text());
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 role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::User.to_string())
.await
.unwrap();
let role_id = role.id.clone();
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 {
role_id: role_id.clone(),
fullname: "To Be Deleted".into(),
email: "deleteduser@test.com".into(),
password: "Password1!".into(),
phone_number: "081234567890".into(),
is_active: true,
};
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 {
role_id: role_id.clone(),
fullname: "Should Fail".into(),
email: "deleteduser@test.com".into(),
phone_number: "081234567890".into(),
is_active: true,
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 already deleted"));
}
#[tokio::test]
async fn test_update_user_should_fail_if_payload_invalid() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::User.to_string())
.await
.unwrap();
let role_id = role.id.clone();
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(),
role_id: role_id.clone(),
password: "Password1!".into(),
phone_number: "081234567890".into(),
is_active: true,
};
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(),
phone_number: "081234567890".into(),
is_active: true,
role_id: role_id.clone(),
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}"
);
}
+145
View File
@@ -0,0 +1,145 @@
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing;
use utoipa::ToSchema;
use validator::Validate;
use crate::{RolesItemDto, RolesItemDtoRaw};
lazy_static! {
static ref PASSWORD_REGEX: Regex = Regex::new(r"^[A-Za-z\d@$!%*?&]{8,}$").unwrap();
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct UsersActiveInactiveRequestDto {
pub is_active: bool,
}
#[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,
#[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 = 10,
message = "Phone number at least have 10 character"
))]
pub phone_number: String,
pub is_active: bool,
pub role_id: String,
}
#[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 = 10,
message = "Phone number at least have 10 character"
))]
pub phone_number: String,
pub is_active: bool,
#[validate(length(min = 1, message = "Gender is required"))]
pub gender: Option<String>,
#[validate(length(min = 1, message = "Birthdate is required"))]
pub birthdate: Option<String>,
#[validate(length(min = 1, message = "Avatar is required"))]
pub avatar: Option<String>,
pub role_id: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct UsersItemDto {
pub id: String,
pub role: RolesItemDto,
pub fullname: String,
pub email: String,
pub avatar: Option<String>,
pub phone_number: String,
pub is_active: bool,
pub is_deleted: bool,
pub gender: Option<String>,
pub birthdate: Option<String>,
pub password: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct UsersDetailItemDto {
pub id: String,
pub role: RolesItemDto,
pub fullname: String,
pub email: String,
pub avatar: Option<String>,
pub phone_number: String,
pub is_active: bool,
pub gender: Option<String>,
pub birthdate: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct UsersListItemDto {
pub id: String,
pub role: String,
pub fullname: String,
pub email: String,
pub avatar: Option<String>,
pub phone_number: String,
pub is_active: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UsersListItemDtoRaw {
pub id: Thing,
pub role: Option<String>,
pub fullname: String,
pub email: String,
pub avatar: Option<String>,
pub phone_number: String,
pub is_active: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UsersItemDtoRaw {
pub id: Thing,
pub fullname: String,
pub email: String,
pub avatar: Option<String>,
pub phone_number: String,
pub is_active: bool,
pub is_deleted: bool,
pub gender: Option<String>,
pub birthdate: Option<String>,
pub role: RolesItemDtoRaw,
pub password: String,
pub created_at: String,
pub updated_at: String,
}
@@ -0,0 +1,317 @@
use super::{
UsersActiveInactiveSchema, UsersItemDto, UsersItemDtoRaw, UsersListItemDto,
UsersListItemDtoRaw, UsersSchema, UsersSetNewPasswordSchema,
};
use crate::{
extract_id, get_id, make_thing, query_list_with_meta, AppState, MetaRequestDto,
PermissionsItemDto, PermissionsItemDtoRaw, ResourceEnum, ResponseListSuccessDto,
RolesItemDto, RolesItemDtoRaw,
};
use anyhow::{anyhow, bail, Result};
pub struct UsersRepository<'a> {
state: &'a AppState,
}
impl<'a> UsersRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
pub async fn query_user_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<UsersListItemDto>>> {
let db = &self.state.surrealdb_ws;
let mut conditions = vec!["is_deleted = false".to_string()];
if let Some(search) = meta.search.as_deref() {
if !search.is_empty() {
conditions.push("string::contains(fullname ?? '', $search)".to_string());
}
}
if let (Some(filter_by), Some(_filter)) =
(meta.filter_by.as_ref(), meta.filter.as_ref())
{
conditions.push(format!("{} = $filter", filter_by));
}
let where_clause = if !conditions.is_empty() {
format!("WHERE {}", conditions.join(" AND "))
} else {
String::new()
};
let limit = meta.per_page.unwrap_or(10);
let page = meta.page.unwrap_or(1);
if page < 1 || limit < 1 {
return Err(anyhow!("Invalid pagination parameters"));
}
let start = (page - 1) * limit;
let select_query = format!(
"
SELECT
id,
role.name AS role,
fullname,
email,
avatar,
phone_number,
is_active,
gender,
birthdate,
created_at,
updated_at
FROM {}
{}
LIMIT {} START {}
FETCH role
",
ResourceEnum::Users.to_string(),
where_clause,
limit,
start
);
let raw_result = query_list_with_meta::<UsersListItemDtoRaw>(
db,
&ResourceEnum::Users.to_string(),
&meta,
vec![],
Some(select_query),
)
.await?;
let transformed_data = raw_result
.data
.into_iter()
.map(|user| UsersListItemDto {
id: extract_id(&user.id),
fullname: user.fullname,
email: user.email,
avatar: user.avatar,
phone_number: user.phone_number,
is_active: user.is_active,
role: user.role.unwrap_or_else(|| "-".into()),
created_at: user.created_at,
updated_at: user.updated_at,
})
.collect::<Vec<_>>();
Ok(ResponseListSuccessDto {
data: transformed_data,
meta: raw_result.meta,
})
}
pub async fn query_user_by_email(&self, email: String) -> Result<UsersItemDtoRaw> {
let db = &self.state.surrealdb_ws;
let sql = format!(
"SELECT *, role AS role FROM {} WHERE email = $email AND is_deleted = false LIMIT 1 FETCH role, role.permissions",
ResourceEnum::Users.to_string()
);
let response: Option<UsersItemDtoRaw> = db
.query(sql)
.bind(("email", email.clone()))
.await?
.take(0)?;
match response {
Some(user) if !user.role.is_deleted => {
let permissions = user
.role
.permissions
.into_iter()
.map(|perm| PermissionsItemDtoRaw {
id: perm.id,
name: perm.name,
created_at: perm.created_at,
updated_at: perm.updated_at,
})
.collect::<Vec<_>>();
Ok(UsersItemDtoRaw {
id: user.id,
fullname: user.fullname,
email: user.email,
avatar: user.avatar,
phone_number: user.phone_number,
is_active: user.is_active,
is_deleted: user.is_deleted,
gender: user.gender,
birthdate: user.birthdate,
password: user.password,
created_at: user.created_at,
updated_at: user.updated_at,
role: RolesItemDtoRaw {
id: user.role.id,
name: user.role.name,
permissions,
created_at: user.role.created_at,
updated_at: user.role.updated_at,
is_deleted: user.role.is_deleted,
},
})
}
_ => bail!("User not found"),
}
}
pub async fn query_user_by_id(&self, id: String) -> Result<UsersItemDto> {
let db = &self.state.surrealdb_ws;
let query = format!(
"SELECT *, role AS role FROM app_users:⟨{}⟩ FETCH role, role.permissions",
id
);
let mut result = db.query(query).await?;
let response: Option<UsersItemDtoRaw> = result.take(0)?;
match response {
Some(user) if !user.role.is_deleted => {
let permissions = user
.role
.permissions
.into_iter()
.map(|perm| PermissionsItemDto {
id: extract_id(&perm.id),
name: perm.name,
created_at: perm.created_at,
updated_at: perm.updated_at,
})
.collect::<Vec<_>>();
Ok(UsersItemDto {
id: extract_id(&user.id),
fullname: user.fullname,
email: user.email,
avatar: user.avatar,
phone_number: user.phone_number,
is_active: user.is_active,
is_deleted: user.is_deleted,
gender: user.gender,
birthdate: user.birthdate,
password: user.password,
role: RolesItemDto {
id: extract_id(&user.role.id),
name: user.role.name,
permissions,
created_at: user.role.created_at,
updated_at: user.role.updated_at,
},
created_at: user.created_at,
updated_at: user.updated_at,
})
}
_ => bail!("User not found"),
}
}
pub async fn query_create_user(&self, data: UsersSchema) -> Result<String> {
let db = &self.state.surrealdb_ws;
let record: Option<UsersSchema> = db
.create(ResourceEnum::Users.to_string())
.content(data)
.await?;
match record {
Some(_) => Ok("Success create user".into()),
None => bail!("Failed to create user"),
}
}
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 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,
role: make_thing("app_roles", &existing.role.id),
..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"),
}
}
pub async fn query_active_inactive_user(
&self,
email: String,
data: UsersActiveInactiveSchema,
) -> Result<String> {
let db = &self.state.surrealdb_ws;
let user = self.query_user_by_email(email.clone()).await?;
if user.is_deleted {
bail!("User already deleted");
}
let record_key = get_id(&user.id)?;
let record: Option<UsersSchema> = db
.update(record_key)
.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_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,
data: UsersSetNewPasswordSchema,
) -> Result<String> {
let db = &self.state.surrealdb_ws;
let user = self.query_user_by_email(email).await?;
let record: Option<UsersSetNewPasswordSchema> = db
.update((ResourceEnum::Users.to_string(), user.id.id.to_raw()))
.merge(UsersSetNewPasswordSchema {
password: data.password.clone(),
})
.await?;
dbg!(record.clone());
match record {
Some(_) => Ok("Success update password user".into()),
None => bail!("Failed to update password user"),
}
}
pub async fn query_delete_user(&self, id: String) -> Result<String> {
let db = &self.state.surrealdb_ws;
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 id = make_thing(&ResourceEnum::Users.to_string(), &user.id);
let record_key = get_id(&id)?;
let record: Option<UsersSchema> = 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"),
}
}
}
@@ -0,0 +1,234 @@
use crate::{create_mock_app_state, create_test_user, RolesRepository};
use crate::{
MetaRequestDto, UsersActiveInactiveSchema, UsersRepository,
UsersSetNewPasswordSchema,
};
async fn get_role_id(state: &crate::AppState) -> String {
RolesRepository::new(state)
.query_role_by_name("Student".into())
.await
.expect("Role not found")
.id
}
#[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,
&get_role_id(&app_state).await,
);
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 role_repo = RolesRepository::new(&app_state);
let role_id = role_repo
.query_role_by_name("Student".into())
.await
.expect("Role not found")
.id;
let email = "changepass@example.com";
let user = create_test_user(email, "Change Password", true, &role_id);
repo.query_create_user(user).await.unwrap();
let result = repo
.query_update_password_user(
email.to_string(),
UsersSetNewPasswordSchema {
password: "newpass".into(),
},
)
.await;
assert!(
result.is_ok(),
"Update password failed with error: {:?}",
result.err()
);
dbg!(result.unwrap());
}
#[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, &get_role_id(&app_state).await);
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,
&get_role_id(&app_state).await,
);
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,
&get_role_id(&app_state).await,
);
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, &get_role_id(&app_state).await);
repo.query_create_user(user.clone()).await.unwrap();
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(user_detail.id.id.to_raw().clone())
.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("lklklklk".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, &get_role_id(&app_state).await);
repo.query_create_user(user.clone()).await.unwrap();
let first = repo.query_delete_user(user.id.id.to_raw()).await;
assert!(first.is_ok());
let second = repo.query_delete_user(user.id.id.to_raw()).await;
assert!(second.is_err());
assert_eq!(second.unwrap_err().to_string(), "User already deleted");
}
#[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,
&get_role_id(&state).await,
);
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");
}
@@ -0,0 +1,56 @@
use crate::{get_iso_date, make_thing, ResourceEnum};
use serde::{Deserialize, Serialize};
use surrealdb::{sql::Thing, Uuid};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UsersSchema {
pub id: Thing,
pub fullname: String,
pub email: String,
pub password: String,
pub avatar: Option<String>,
pub phone_number: String,
pub is_active: bool,
pub is_deleted: bool,
pub gender: Option<String>,
pub birthdate: Option<String>,
pub role: Thing,
pub created_at: String,
pub updated_at: String,
}
impl Default for UsersSchema {
fn default() -> Self {
UsersSchema {
id: make_thing(
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
),
fullname: String::new(),
email: String::new(),
password: String::new(),
avatar: None,
phone_number: String::new(),
is_active: false,
is_deleted: false,
gender: None,
birthdate: None,
role: make_thing(
&ResourceEnum::Roles.to_string(),
&Uuid::new_v4().to_string(),
),
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UsersSetNewPasswordSchema {
pub password: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UsersActiveInactiveSchema {
pub is_active: bool,
}
@@ -0,0 +1,229 @@
use crate::{
common_response, extract_email, 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::HeaderMap;
use axum::{http::StatusCode, response::Response};
use super::{
UsersActiveInactiveRequestDto, UsersCreateRequestDto, UsersDetailItemDto,
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_id(state: &AppState, id: String) -> Response {
let repo = UsersRepository::new(state);
match repo.query_user_by_id(id).await {
Ok(user) if !user.is_deleted => success_response(ResponseSuccessDto {
data: UsersDetailItemDto {
id: user.id,
role: user.role,
fullname: user.fullname,
email: user.email,
avatar: user.avatar,
phone_number: user.phone_number,
is_active: user.is_active,
gender: user.gender,
birthdate: user.birthdate,
},
}),
Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
}
}
pub async fn get_user_me(headers: HeaderMap, state: &AppState) -> Response {
let repo = UsersRepository::new(state);
let email = extract_email(&headers).unwrap();
let user = repo.query_user_by_email(email).await.unwrap();
match repo.query_user_by_id(user.id.id.to_raw()).await {
Ok(user) => success_response(ResponseSuccessDto {
data: UsersDetailItemDto {
id: user.id,
role: user.role,
fullname: user.fullname,
email: user.email,
avatar: user.avatar,
phone_number: user.phone_number,
is_active: user.is_active,
gender: user.gender,
birthdate: user.birthdate,
},
}),
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");
}
let role_thing = make_thing(&ResourceEnum::Roles.to_string(), &new_user.role_id);
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(),
is_active: new_user.is_active.clone(),
role: role_thing,
..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,
is_active: user.is_active,
gender: user.gender,
birthdate: user.birthdate,
avatar: user.avatar,
role: role_id,
updated_at: 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 update_user_me(
state: &AppState,
headers: HeaderMap,
user: UsersUpdateRequestDto,
) -> Response {
let repo = UsersRepository::new(state);
let email = extract_email(&headers).unwrap();
let user_data = repo.query_user_by_email(email).await.unwrap();
if let Err((status, message)) = validate_request(&user) {
return common_response(status, &message);
}
let user_id =
make_thing(&ResourceEnum::Users.to_string(), &user_data.id.id.to_raw());
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,
is_active: user.is_active,
gender: user.gender,
birthdate: user.birthdate,
avatar: user.avatar,
role: role_id,
updated_at: 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);
let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id);
match repo.query_user_by_id(thing_id.id.to_raw()).await {
Ok(_) => 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()),
},
Err(err) => common_response(StatusCode::BAD_REQUEST, &err.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()),
}
}
}
+9
View File
@@ -0,0 +1,9 @@
use axum::{response::IntoResponse, routing::post, Router};
async fn dummy_login() -> impl IntoResponse {
"Logged in successfully"
}
pub fn auth_router() -> Router {
Router::new().route("/login", post(dummy_login))
}
+9
View File
@@ -0,0 +1,9 @@
use axum::Router;
pub mod auth;
pub use auth::*;
pub async fn routes() -> Router {
let public_routes = Router::new().nest("/auth", auth::auth_router());
Router::new().merge(public_routes)
}
+9
View File
@@ -0,0 +1,9 @@
use imphnen_backend_service::{apps, axum_init};
#[tokio::main]
async fn main() {
axum_init(|surrealdb_ws, surrealdb_mem| async {
apps(surrealdb_ws, surrealdb_mem).await
})
.await;
}
+145
View File
@@ -0,0 +1,145 @@
use imphnen_utils::{get_iso_date, Env};
use serde_json::json;
use std::error::Error;
use surrealdb::{engine::remote::ws::Ws, opt::auth::Root, Surreal};
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let env = Env::new();
let db = Surreal::new::<Ws>(env.surrealdb_url).await?;
db.signin(Root {
username: &env.surrealdb_username,
password: &env.surrealdb_password,
})
.await?;
db.use_ns(env.surrealdb_namespace)
.use_db(env.surrealdb_dbname)
.await?;
let permissions = vec![
(
"023e2dfe-93c3-4008-94a8-b5dff403f73b",
"Create Users",
Some("2025-01-29T06:08:23.838311+00"),
Some("2025-01-29T06:08:23.838312+00"),
),
(
"0269ed71-0ae0-4c43-ad29-e3d861d8f9a0",
"Create Permissions",
None,
None,
),
(
"299cb4d5-6556-4cc9-b6c1-32e6d31e0f9b",
"Update Permissions",
Some("2025-01-29T05:11:01.265+00"),
Some("2025-01-29T05:11:01.265001+00"),
),
(
"319ee593-ff0a-4f29-bbaf-9feb3174a3a2",
"Create Roles",
None,
None,
),
(
"319ee593-ff0a-4f29-bbaf-9feb3174a3a6",
"Read Detail Users",
None,
None,
),
(
"35b0d992-65c8-4b62-b030-e6e0320e4048",
"Delete Roles",
None,
None,
),
(
"4da8b434-89f9-4d91-85ae-eebd63cdbeda",
"Update Activate Users",
Some("2025-02-01T12:38:09.741726+00"),
Some("2025-02-01T12:38:09.741727+00"),
),
(
"73888d18-b3e9-4f62-95a5-ba2c0d69fccb",
"Read Detail Roles",
Some("2025-01-29T05:13:06.445925+00"),
Some("2025-01-29T10:31:46.408564+00"),
),
(
"7c15e31d-36e2-49f9-97db-138c03fb0cf6",
"Read List Users",
Some("2025-01-28T15:02:41.772931+00"),
Some("2025-01-28T15:02:41.772933+00"),
),
(
"7d4b1379-4960-416a-b045-98cd82c0cac9",
"Read Detail Sessions",
Some("2025-02-24T16:52:26.886664+00"),
Some("2025-02-24T16:52:26.886673+00"),
),
(
"8195eeb8-e64f-4172-aa57-596492c84a72",
"Read List Permissions",
Some("2025-01-28T15:05:28.6299+00"),
Some("2025-01-28T15:05:28.629901+00"),
),
(
"81eba91d-b8ab-44b9-bbfe-4e6da2f98952",
"Read List Tests",
Some("2025-02-24T16:52:27.179542+00"),
Some("2025-02-24T16:52:27.179551+00"),
),
(
"9164ca6e-c7e3-4238-a15f-f36ab9577e7e",
"Read List Roles",
None,
None,
),
(
"96df0689-2ae9-4894-bf00-837c19415e5c",
"Delete Users",
Some("2025-02-02T06:52:05.195565+00"),
Some("2025-02-02T06:52:05.195565+00"),
),
(
"98b3dc4c-0124-461f-afcd-166637c5e6e8",
"Update Users",
Some("2025-01-29T05:34:40.621554+00"),
Some("2025-01-29T05:34:40.621555+00"),
),
(
"a00d5608-4c48-4542-845c-dfe004687022",
"Update Roles",
None,
None,
),
(
"b2dc3928-86ba-4c59-a03d-0b57d5183ebc",
"Delete Permissions",
Some("2025-01-29T05:14:22.511084+00"),
Some("2025-01-29T05:14:22.511085+00"),
),
(
"dad435cf-042c-41bd-a946-cea61ed2ffbc",
"Read Detail Permissions",
Some("2025-01-28T15:07:10.990214+00"),
Some("2025-01-28T15:07:10.990214+00"),
),
];
for (id, name, _created_at, _updated_at) in permissions {
db.query("CREATE type::thing('app_permissions', $id) CONTENT $data")
.bind(("id", id))
.bind((
"data",
json!({
"name": name,
"is_deleted": false,
"created_at": get_iso_date(),
"updated_at": get_iso_date()
}),
))
.await?;
println!("✅ Inserted: {}", name);
}
println!("✅ Semua permissions berhasil disimpan ke SurrealDB!");
Ok(())
}
+70
View File
@@ -0,0 +1,70 @@
use imphnen_utils::{get_iso_date, Env};
use serde_json::json;
use std::error::Error;
use surrealdb::{engine::remote::ws::Ws, opt::auth::Root, Surreal};
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let env = Env::new();
let db = Surreal::new::<Ws>(env.surrealdb_url).await?;
db.signin(Root {
username: &env.surrealdb_username,
password: &env.surrealdb_password,
})
.await?;
db.use_ns(env.surrealdb_namespace)
.use_db(env.surrealdb_dbname)
.await?;
let roles = vec![
(
"50133429-f4b1-4249-9f97-7b86e6ee9d86",
"Staf",
Some("2025-02-24T16:52:27.630453+00"),
Some("2025-02-24T16:52:27.630461+00"),
),
(
"5713cb37-dc02-4e87-8048-d7a41d352059",
"User",
None,
Some("2025-02-28T14:53:58.576688+00"),
),
(
"60f1aeb7-dad2-4e06-bcb5-be1ba510c906",
"Staff Aktivasi User",
Some("2025-02-20T02:47:09.660640+00"),
Some("2025-02-20T02:48:30.083283+00"),
),
(
"6d4fea5d-4a08-4b8a-9782-f2ab2183dcf0",
"Admin Pembayaran",
Some("2025-01-29T05:39:28.562667+00"),
Some("2025-03-12T22:56:29.597416+00"),
),
(
"f6b03f25-e416-4893-ac88-caaa690afb07",
"Admin",
None,
Some("2025-02-22T15:38:39.868306+00"),
),
];
for (id, name, _created_at, _updated_at) in roles {
db.query("CREATE type::thing('app_roles', $id) CONTENT $data")
.bind(("id", id))
.bind((
"data",
json!({
"name": name,
"permissions": [],
"is_deleted": false,
"created_at": get_iso_date(),
"updated_at": get_iso_date(),
}),
))
.await?;
println!("✅ Inserted role: {}", name);
}
println!("✅ Semua role berhasil disimpan ke SurrealDB!");
Ok(())
}
@@ -0,0 +1,59 @@
use imphnen_utils::{get_iso_date, make_thing, Env};
use std::error::Error;
use surrealdb::{engine::remote::ws::Ws, opt::auth::Root, Surreal};
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let env = Env::new();
let db = Surreal::new::<Ws>(env.surrealdb_url).await?;
db.signin(Root {
username: &env.surrealdb_username,
password: &env.surrealdb_password,
})
.await?;
db.use_ns(env.surrealdb_namespace)
.use_db(env.surrealdb_dbname)
.await?;
let role_permissions = vec![
(
"50133429-f4b1-4249-9f97-7b86e6ee9d86", // Staf
vec![
"7c15e31d-36e2-49f9-97db-138c03fb0cf6", // Read List Users
"319ee593-ff0a-4f29-bbaf-9feb3174a3a6", // Read Detail Users
],
),
(
"f6b03f25-e416-4893-ac88-caaa690afb07", // Admin
vec![
"023e2dfe-93c3-4008-94a8-b5dff403f73b", // Create Users
"96df0689-2ae9-4894-bf00-837c19415e5c", // Delete Users
"98b3dc4c-0124-461f-afcd-166637c5e6e8", // Update Users
"319ee593-ff0a-4f29-bbaf-9feb3174a3a6", // Read Detail Users
"7c15e31d-36e2-49f9-97db-138c03fb0cf6", // Read List Users
"9164ca6e-c7e3-4238-a15f-f36ab9577e7e", // Read List Roles
"319ee593-ff0a-4f29-bbaf-9feb3174a3a2", // Create Roles
"a00d5608-4c48-4542-845c-dfe004687022", // Update Roles
"35b0d992-65c8-4b62-b030-e6e0320e4048", // Delete Roles
],
),
];
for (role_id, permission_ids) in role_permissions {
let permission_refs: Vec<_> = permission_ids
.into_iter()
.map(|perm_id| make_thing("app_permissions", perm_id))
.collect();
db.query("UPDATE type::thing('app_roles', $role_id) SET permissions = $permissions, updated_at = $updated_at")
.bind(("role_id", role_id))
.bind(("permissions", permission_refs))
.bind(("updated_at", get_iso_date()))
.await?;
println!("✅ Updated permissions for role ID: {}", role_id);
}
println!("✅ Semua roles telah diperbarui dengan permissions di SurrealDB!");
Ok(())
}
+95
View File
@@ -0,0 +1,95 @@
use imphnen_utils::{get_iso_date, hash_password, Env};
use serde::{Deserialize, Serialize};
use std::error::Error;
use surrealdb::{engine::remote::ws::Ws, opt::auth::Root, sql::Thing, Surreal};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UsersSchema {
pub id: Thing,
pub fullname: String,
pub email: String,
pub password: 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 is_deleted: bool,
pub student_type: String,
pub religion: Option<String>,
pub gender: Option<String>,
pub birthdate: Option<String>,
pub is_profile_completed: bool,
pub role: Thing,
pub created_at: String,
pub updated_at: String,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let env = Env::new();
let db = Surreal::new::<Ws>(env.surrealdb_url).await?;
db.signin(Root {
username: &env.surrealdb_username,
password: &env.surrealdb_password,
})
.await?;
db.use_ns(env.surrealdb_namespace)
.use_db(env.surrealdb_dbname)
.await?;
let users = vec![
(
"c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2",
"admin@example.com",
"Admin",
"f6b03f25-e416-4893-ac88-caaa690afb07",
),
(
"a4d23fb5-9e31-423c-9842-fbd6e75a5298",
"staff@example.com",
"Staff",
"50133429-f4b1-4249-9f97-7b86e6ee9d86",
),
(
"d5e89c12-72af-4b1a-abc3-ff1234567890",
"user@example.com",
"User",
"5713cb37-dc02-4e87-8048-d7a41d352059",
),
];
for (id, email, fullname, role_id) in users {
let user = UsersSchema {
id: Thing::from(("app_users", id)),
fullname: fullname.into(),
email: email.into(),
password: hash_password("password").unwrap(),
avatar: None,
phone_number: "081234567890".into(),
referral_code: None,
referred_by: None,
identity_number: None,
is_active: true,
is_deleted: false,
student_type: "TNI".into(),
religion: None,
gender: None,
birthdate: None,
is_profile_completed: false,
role: Thing::from(("app_roles", role_id)),
created_at: get_iso_date(),
updated_at: get_iso_date(),
};
db.create::<Option<UsersSchema>>(("app_users", id))
.content(user)
.await?;
println!("✅ Inserted user: {} ({})", fullname, email);
}
println!("✅ Semua users berhasil disimpan ke SurrealDB!");
Ok(())
}
+32
View File
@@ -0,0 +1,32 @@
use ::surrealdb::Uuid;
use imphnen_entities::*;
use imphnen_libs::*;
use imphnen_utils::*;
pub mod apps;
pub use apps::*;
pub use imphnen_entities::*;
pub use imphnen_libs::*;
pub fn create_test_user(
email: &str,
fullname: &str,
is_active: bool,
role_id: &str,
) -> UsersSchema {
UsersSchema {
id: make_thing("app_users", &Uuid::new_v4().to_string()),
email: email.to_string(),
fullname: format!("Randomize {} {}", fullname, rand::random::<u32>()),
password: hash_password("secret").unwrap(),
is_deleted: false,
avatar: None,
phone_number: "081234567890".to_string(),
is_active,
gender: None,
birthdate: None,
role: make_thing("app_roles", role_id),
..Default::default()
}
}
+9
View File
@@ -0,0 +1,9 @@
use imphnen_backend_service::{apps, axum_init};
#[tokio::main]
async fn main() {
axum_init(|surrealdb_ws, surrealdb_mem| async {
apps(surrealdb_ws, surrealdb_mem).await
})
.await;
}