Add comprehensive tests for mentor repository and authentication

- Implemented tests for creating, retrieving, updating, and deleting mentors in `mentor_repository_test.rs`.
- Added tests for user authentication, including successful login, invalid email formats, and inactive users in `auth_login_tests.rs`.
- Created a mock test environment setup in `mock_test.rs` to facilitate database operations during tests.
- Updated module structure to include new test files for mentors and authentication.
- Ensured cleanup of the database after tests to maintain isolation and prevent side effects.
This commit is contained in:
MythEclipse
2025-07-21 21:29:04 +07:00
parent e66f1f1634
commit 1a2e0c58b6
103 changed files with 7851 additions and 1509 deletions
+24 -8
View File
@@ -1,8 +1,24 @@
PORT=
SURREALDB_URL=
SURREALDB_USERNAME=
SURREALDB_PASSWORD=
SURREALDB_NAMESPACE=
SURREALDB_DBNAME=
ACCESS_TOKEN_SECRET=
REFRESH_TOKEN_SECRET=
RUST_ENV=development
PORT=4099
SURREALDB_URL=ws://localhost:8000/rpc
SURREALDB_USERNAME=root
SURREALDB_PASSWORD=root
SURREALDB_NAMESPACE=test
SURREALDB_DBNAME=test
ACCESS_TOKEN_SECRET=your-access-token-secret-key-here
REFRESH_TOKEN_SECRET=your-refresh-token-secret-key-here
SMTP_EMAIL=your-email@example.com
SMTP_PASSWORD=your-smtp-password
SMTP_NAME="Your App Name"
SMTP_HOST=smtp.gmail.com
REDISDB_URL=localhost
FE_URL=http://localhost
MINIO_ENDPOINT=http://localhost:9000
MINIO_BUCKET_NAME=default_bucket
MINIO_ACCESS_KEY=your-minio-access-key
MINIO_SECRET_KEY=your-minio-secret-key
MAIL_USER=your-email@example.com
MAIL_PASSWORD=your-smtp-password
MAIL_HOST=smtp.gmail.com
MAIL_PORT=465
MAIL_SECURE=true
Generated
+727 -578
View File
File diff suppressed because it is too large Load Diff
+32 -5
View File
@@ -20,28 +20,55 @@ axum = { version = "0.8.4", features = ["multipart"] }
log = "0.4.25"
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.138"
tokio = { version = "1.45.0" }
tokio = { version = "1.45.0", features = ["full"] }
argon2 = { version = "0.5.3", features = ["password-hash"] }
jsonwebtoken = "9.3.1"
chrono = "0.4.41"
utoipa = { version = "5.3.1", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.0", features = ["axum"] }
lettre = { version = "0.11.16", features = ["tokio1-native-tls"] }
surrealdb = { version = "2.3.2", features = ["kv-mem"] }
surrealdb = { version = "2.3.7", features = ["kv-mem", "kv-fdb"] }
thiserror = "2.0.12"
anyhow = "1.0.98"
rand = { version = "0.9.1", features = ["std", "alloc"] }
rand_distr = "0.5.1"
tower-http = { version = "0.6.4", features = ["cors"] }
validator = { version = "0.12", features = ["derive"] }
tower-http = { version = "0.6.4", features = ["cors", "trace"] }
http-body-util = "0.1.1"
validator = { version = "0.20.0", features = ["derive"] }
lazy_static = "1.4.0"
regex = "1.11.1"
axum-test = "17.2.0"
fancy-regex = "0.14.0"
fancy-regex = "0.15.0"
futures = "0.3.31"
tower = "0.5.2"
env_logger = "0.11.8"
dotenvy = "0.15.7"
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
once_cell = "1.21.3"
uuid = { version = "1.8.0", features = ["v4", "fast-rng", "serde"] }
strum = { version = "0.27.1", features = ["derive"] }
strum_macros = "0.27.1"
hyper = "1.6.0"
hyper-util = "0.1.0"
async-trait = "0.1.75"
tokio-tungstenite = "0.23"
url = "2.5"
futures-util = "0.3"
tests = { path = "./tests" }
imphnen-iam = { path = "./imphnen-iam" }
imphnen-cms = { path = "./imphnen-cms" }
imphnen-libs = { path = "./imphnen-libs" }
imphnen-utils = { path = "./imphnen-utils" }
imphnen-gacha = { path = "./imphnen-gacha" }
imphnen-gateway = { path = "./imphnen-gateway" }
imphnen-backend = { path = "./imphnen-backend" }
imphnen-entities = { path = "./imphnen-entities" }
imphnen-dimentorin = { path = "./imphnen-dimentorin" }
imphnen-middleware = { path = "./imphnen-middleware" }
[profile.release]
lto = "fat"
codegen-units = 1
+1 -1
View File
@@ -1,4 +1,4 @@
FROM rust:1.85-alpine AS builder
FROM rust:1.86-alpine AS builder
RUN apk add --no-cache \
curl \
+13 -6
View File
@@ -4,12 +4,13 @@ version = "0.1.0"
edition = "2021"
[dependencies]
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" }
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" }
imphnen-gateway = { version = "0.1.0", path = "../imphnen-gateway" }
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" }
imphnen-iam = { version = "0.1.0", path = "../imphnen-iam" }
imphnen-cms = { version = "0.1.0", path = "../imphnen-cms" }
imphnen-libs.workspace = true
imphnen-utils.workspace = true
imphnen-gateway.workspace = true
imphnen-entities.workspace = true
imphnen-iam.workspace = true
imphnen-cms.workspace = true
imphnen-dimentorin.workspace = true
axum.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -26,3 +27,9 @@ anyhow.workspace = true
tower-http.workspace = true
utoipa-swagger-ui.workspace = true
env_logger.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
uuid.workspace=true
tokio-tungstenite.workspace = true
url.workspace = true
futures-util.workspace = true
-1
View File
@@ -1,4 +1,3 @@
use env_logger;
use imphnen_gateway::gateway_service;
use imphnen_libs::axum_init;
+169
View File
@@ -0,0 +1,169 @@
use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
use url::Url;
use futures_util::{StreamExt, SinkExt};
use serde_json::json;
// Menentukan kredensial dan detail koneksi secara langsung sebagai string statis
static SURREALDB_URL_WS: &str = "ws://localhost:8000/rpc";
static SURREALDB_USERNAME: &str = "root";
static SURREALDB_PASSWORD: &str = "root";
static SURREALDB_NAMESPACE: &str = "test";
static SURREALDB_DBNAME: &str = "test";
// Daftar tabel sebagai variabel static yang tidak dapat diubah
static TABLES_TO_CLEAR: &[&str] = &[
"app_events", "users", "roles", "permissions", "gacha_rolls",
"mentor_users", "gacha_claims", "gacha_credits", "gacha_items",
"mentor_profiles", "roles_permissions", "testimonials",
];
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Tidak perlu memuat env lagi, karena kita menggunakan nilai hardcoded
// imphnen_libs::enviroment::load_env(); // Baris ini tidak lagi dibutuhkan
// let env = Env::new(); // Baris ini tidak lagi dibutuhkan
println!("DEBUG: URL WS: {}", SURREALDB_URL_WS);
println!("DEBUG: Username: {}", SURREALDB_USERNAME);
println!("DEBUG: Namespace: {}", SURREALDB_NAMESPACE);
println!("DEBUG: Database: {}", SURREALDB_DBNAME);
let url = Url::parse(SURREALDB_URL_WS)?; // Menggunakan SURREALDB_URL_WS statis
let (ws_stream, _) = connect_async(url).await?;
let (mut write, mut read) = ws_stream.split();
// Authenticate (signin)
let signin_query = json!({
"method": "signin",
"params": [{
"user": SURREALDB_USERNAME, // Menggunakan SURREALDB_USERNAME statis
"pass": SURREALDB_PASSWORD, // Menggunakan SURREALDB_PASSWORD statis
}],
"id": 1
}).to_string();
println!("DEBUG: Sending signin query: {}", signin_query);
write.send(Message::Text(signin_query)).await?;
let signin_response = read.next().await.ok_or("Failed to read signin response")?;
let signin_response_msg = signin_response?;
let signin_response_str = signin_response_msg.to_text()?;
println!("DEBUG: Signin response: {}", signin_response_str);
if signin_response_str.contains("\"error\":") {
return Err(format!("Signin failed: {}", signin_response_str).into());
}
// Use namespace and database
let use_query = json!({
"method": "use",
"params": [SURREALDB_NAMESPACE, SURREALDB_DBNAME], // Menggunakan NS & DB statis
"id": 2
}).to_string();
println!("DEBUG: Sending use query: {}", use_query);
write.send(Message::Text(use_query)).await?;
let use_response = read.next().await.ok_or("Failed to read use response")?;
let use_response_msg = use_response?;
let use_response_str = use_response_msg.to_text()?;
println!("DEBUG: Use response: {}", use_response_str);
if use_response_str.contains("\"error\":") {
return Err(format!("USE command failed: {}", use_response_str).into());
}
println!("INFO: Attempting to clear database tables via WebSocket...");
let mut all_clear = true;
for (i, table) in TABLES_TO_CLEAR.iter().enumerate() {
let remove_query = format!("REMOVE TABLE {};", table);
let query_json = json!({
"method": "query",
"params": [remove_query],
"id": i + 3
}).to_string();
println!("DEBUG: Attempting REMOVE TABLE {}: {}", table, query_json);
write.send(Message::Text(query_json)).await?;
let response_result = read.next().await.ok_or("Stream ended unexpectedly")?;
match response_result {
Ok(msg) => {
let response_str = msg.to_text()?;
if response_str.contains("\"error\":") {
println!("WARN: Failed to REMOVE TABLE {}: {}. Attempting DELETE type::{}.", table, response_str, table);
let delete_all_query = format!("DELETE FROM {};", table);
let delete_all_json = json!({
"method": "query",
"params": [delete_all_query],
"id": i + 300
}).to_string();
println!("DEBUG: Attempting DELETE {}: {}", table, delete_all_json);
write.send(Message::Text(delete_all_json)).await?;
let delete_response_result = read.next().await.ok_or("Stream ended unexpectedly during DELETE type::")?;
match delete_response_result {
Ok(delete_msg) => {
let delete_response_str = delete_msg.to_text()?;
if delete_response_str.contains("\"error\":") {
println!("ERROR: Failed to DELETE type::{} : {}", table, delete_response_str);
all_clear = false;
} else {
println!("INFO: Successfully DELETED type:: table: {}", table);
}
},
Err(delete_e) => {
println!("ERROR: Error receiving response for DELETE type:: table {}: {}", table, delete_e);
all_clear = false;
}
}
} else {
println!("INFO: Successfully REMOVED TABLE: {}", table);
}
},
Err(e) => {
println!("ERROR: Error receiving response for REMOVE TABLE {}: {}", table, e);
all_clear = false;
}
}
// Check if table is empty after deletion attempt
let select_query = format!("SELECT * FROM {} LIMIT 1;", table);
let select_json = json!({
"method": "query",
"params": [select_query],
"id": i + 1000
}).to_string();
write.send(Message::Text(select_json)).await?;
let select_response_result = read.next().await.ok_or("Stream ended unexpectedly during SELECT check")?;
match select_response_result {
Ok(select_msg) => {
let select_response_str = select_msg.to_text()?;
if select_response_str.contains("does not exist") {
println!("CHECK: Table '{}' does not exist after clear attempt (success).", table);
} else if select_response_str.contains("\"result\":[]") || select_response_str.contains("\"result\":[[]]") {
println!("CHECK: Table '{}' is empty after clear attempt.", table);
} else {
println!("WARNING: Table '{}' is NOT empty after clear attempt! Response: {}", table, select_response_str);
all_clear = false;
}
},
Err(e) => {
println!("ERROR: Error receiving response for SELECT check on table {}: {}", table, e);
all_clear = false;
}
}
}
println!("INFO: Database clearing complete.");
if !all_clear {
eprintln!("ERROR: One or more tables could not be cleared. Check logs for details.");
return Err("Database clearing failed for one or more tables.".into());
}
Ok(())
}
+21 -10
View File
@@ -1,9 +1,9 @@
use imphnen_cms::v1::landing::events::events_schema::EventsSchema;
use imphnen_libs::enviroment::load_env;
use imphnen_utils::{get_iso_date, Env};
use std::error::Error;
use surrealdb::engine::any;
use surrealdb::{opt::auth::Root, sql::Thing};
use imphnen_libs::enviroment::load_env;
use surrealdb::{opt::auth::Root, sql::Thing, Uuid}; // Added Uuid
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
load_env();
@@ -20,7 +20,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
let events = vec![
(
"e1a2b3c4-5d6e-7f8g-9h0i-1j2k3l4m5n6o",
"Tech Conference 2025",
"Annual technology conference featuring the latest innovations in software development, AI, and cloud computing.",
"https://techconf2025.example.com",
@@ -31,7 +30,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
"2025-06-17T18:00:00Z",
),
(
"f2b3c4d5-6e7f-8g9h-0i1j-2k3l4m5n6o7p",
"Online Web Development Workshop",
"Comprehensive workshop covering modern web development frameworks including React, Vue, and Angular.",
"https://webdev-workshop.example.com",
@@ -42,7 +40,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
"2025-07-10T17:00:00Z",
),
(
"g3c4d5e6-7f8g-9h0i-1j2k-3l4m5n6o7p8q",
"Startup Pitch Competition",
"Exciting competition where emerging startups present their innovative ideas to a panel of expert judges and investors.",
"https://startup-pitch.example.com",
@@ -53,7 +50,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
"2025-08-05T16:00:00Z",
),
(
"h4d5e6f7-8g9h-0i1j-2k3l-4m5n6o7p8q9r",
"Digital Marketing Masterclass",
"Learn advanced digital marketing strategies, social media optimization, and data-driven marketing techniques.",
"https://digital-marketing.example.com",
@@ -65,9 +61,20 @@ async fn main() -> Result<(), Box<dyn Error>> {
),
];
for (id, name, description, detail_link, price, location, is_online, start_date, end_date) in events {
for (
name,
description,
detail_link,
price,
location,
is_online,
start_date,
end_date,
) in events
{
let uuid = Uuid::new_v4().to_string(); // Generate new UUID
let event = EventsSchema {
id: Thing::from(("app_events", id)),
id: Thing::from(("app_events", uuid.as_str())), // Use generated UUID
name: name.into(),
description: description.into(),
detail_link: detail_link.into(),
@@ -81,11 +88,15 @@ async fn main() -> Result<(), Box<dyn Error>> {
updated_at: get_iso_date(),
};
db.create::<Option<EventsSchema>>(("app_events", id))
db.create::<Option<EventsSchema>>(("app_events", uuid.as_str())) // Use generated UUID
.content(event)
.await?;
println!("✅ Inserted event: {} ({})", name, if is_online { "Online" } else { "In-person" });
println!(
"✅ Inserted event: {} ({})",
name,
if is_online { "Online" } else { "In-person" }
);
}
println!("✅ All Events seeded");
@@ -0,0 +1,55 @@
use imphnen_libs::enviroment::load_env;
use imphnen_utils::{get_iso_date, Env};
use std::error::Error;
use surrealdb::opt::auth::Root;
use surrealdb::sql::Thing;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
load_env();
let env = Env::new();
use surrealdb::engine::any;
let db = any::connect(&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?;
db.query("DELETE type::thing('app_gacha_items', $id)")
.bind(("id", "gacha_item_test_id"))
.await?;
db.query("DELETE type::thing('app_gacha_rolls', $id)")
.bind(("id", "gacha_roll_test_id"))
.await?;
let gacha_item_id = "gacha_item_test_id";
db.query("CREATE type::thing('app_gacha_items', $id) SET name = $name, image_url = $image_url, is_deleted = $is_deleted, created_at = $created_at, updated_at = $updated_at")
.bind(("id", gacha_item_id))
.bind(("name", "Test Gacha Item"))
.bind(("image_url", "https://example.com/gacha_item.png"))
.bind(("is_deleted", false))
.bind(("created_at", get_iso_date()))
.bind(("updated_at", get_iso_date()))
.await?;
println!("Gacha Item seeded successfully!");
let gacha_roll_id = "gacha_roll_test_id";
db.query("CREATE type::thing('app_gacha_rolls', $id) SET item = $item, quantity = $quantity, weight = $weight, is_deleted = $is_deleted, created_at = $created_at, updated_at = $updated_at")
.bind(("id", gacha_roll_id))
.bind(("item", Thing::from(("app_gacha_items", gacha_item_id))))
.bind(("quantity", 10))
.bind(("weight", 1.0))
.bind(("is_deleted", false))
.bind(("created_at", get_iso_date()))
.bind(("updated_at", get_iso_date()))
.await?;
println!("Gacha Roll seeded successfully!");
println!("✅ Gacha items and rolls seeded.");
Ok(())
}
@@ -0,0 +1,94 @@
use imphnen_libs::enviroment::load_env;
use imphnen_utils::{get_iso_date, hash_password, Env};
use serde_json::json;
use std::error::Error;
use surrealdb::opt::auth::Root;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
load_env();
let env = Env::new();
use surrealdb::engine::any;
let db = any::connect(&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?;
db.query("DELETE type::thing('app_mentors', $id)")
.bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901"))
.await?;
db.query("DELETE type::thing('app_users', $id)")
.bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901"))
.await?;
use surrealdb::sql::Thing;
db.query("CREATE type::thing('app_users', $id) SET fullname = $fullname, email = $email, password = $password, avatar = $avatar, phone_number = $phone_number, is_active = $is_active, is_deleted = $is_deleted, mentor_id = $mentor_id, gender = $gender, birthdate = $birthdate, role = $role, created_at = $created_at, updated_at = $updated_at")
.bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901"))
.bind(("fullname", "Mentor User"))
.bind(("email", "mentor@example.com"))
.bind(("password", hash_password("password").unwrap()))
.bind(("avatar", Option::<String>::None))
.bind(("phone_number", "081234567890"))
.bind(("is_active", true))
.bind(("is_deleted", false))
.bind(("mentor_id", Option::<Thing>::None))
.bind(("gender", "male"))
.bind(("birthdate", "1990-05-15"))
.bind(("role", Thing::from(("app_roles", "3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a"))))
.bind(("created_at", get_iso_date()))
.bind(("updated_at", get_iso_date()))
.await?;
db.query("CREATE type::thing('app_mentors', $id) SET user_id = $user_id, legal_name = $legal_name, identity_document_url = $identity_document_url, phone_for_verification = $phone_for_verification, bio = $bio, linkedin_url = $linkedin_url, github_url = $github_url, cv_url = $cv_url, industries = $industries, expertise = $expertise, languages = $languages, current_company = $current_company, current_role = $current_role, years_of_experience = $years_of_experience, topics_of_interest = $topics_of_interest, preferred_mentee_level = $preferred_mentee_level, preferred_mentoring_formats = $preferred_mentoring_formats, availability_commitment = $availability_commitment, mentoring_rate = $mentoring_rate, status = $status, is_deleted = $is_deleted, created_at = $created_at, updated_at = $updated_at, email = $email")
.bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901"))
.bind(("user_id", Thing::from(("app_users", "e6f78d23-83bf-5c2b-bcd4-001345678901"))))
.bind(("legal_name", "Mentor User"))
.bind(("identity_document_url", "https://example.com/ktp.jpg"))
.bind(("phone_for_verification", "081234567890"))
.bind(("bio", "Saya adalah mentor backend Rust dengan pengalaman 5 tahun dalam pengembangan aplikasi backend yang scalable dan performant."))
.bind(("linkedin_url", "https://linkedin.com/in/mentor"))
.bind(("github_url", "https://github.com/mentor"))
.bind(("cv_url", Option::<String>::None))
.bind(("industries", vec!["Software", "Education"]))
.bind(("expertise", vec!["Rust", "Microservices"]))
.bind(("languages", vec!["Indonesian", "English"]))
.bind(("current_company", "PT Contoh"))
.bind(("current_role", "Senior Backend Engineer"))
.bind(("years_of_experience", 5))
.bind(("topics_of_interest", vec!["Rust Programming", "Backend Development"]))
.bind(("preferred_mentee_level", vec!["beginner", "intermediate"]))
.bind(("preferred_mentoring_formats", vec!["online", "offline"]))
.bind(("availability_commitment", "2 jam per minggu untuk mentoring online dan offline"))
.bind(("mentoring_rate", json!({
"amount": 100000,
"currency": "IDR",
"per_duration": "hour"
})))
.bind(("status", "verified"))
.bind(("is_deleted", false))
.bind(("created_at", get_iso_date()))
.bind(("updated_at", get_iso_date()))
.bind(("email", "mentor@example.com"))
.await?;
println!("Mentor created successfully!");
println!("Updating user with mentor_id...");
db.query("UPDATE type::thing('app_users', $id) SET mentor_id = $mentor_id")
.bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901"))
.bind((
"mentor_id",
Thing::from(("app_mentors", "e6f78d23-83bf-5c2b-bcd4-001345678901")),
))
.await?;
println!("User updated with mentor_id successfully!");
println!("✅ Inserted mentor user: mentor@example.com");
println!("✅ Mentor user seeded");
Ok(())
}
+10 -1
View File
@@ -48,6 +48,14 @@ async fn main() -> Result<(), Box<dyn Error>> {
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::RegisterMentors,
PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::UpdateOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus,
PermissionsEnum::VerifyMentors,
PermissionsEnum::DeleteMentors,
] {
db.query("CREATE type::thing('app_permissions', $id) CONTENT $data")
.bind(("id", permission.id()))
@@ -61,9 +69,10 @@ async fn main() -> Result<(), Box<dyn Error>> {
}),
))
.await?;
println!("✅ Inserted: {}", permission.to_string());
println!("✅ Inserted: {permission}");
}
println!("✅ All Permissions seeded");
Ok(())
}
+12 -3
View File
@@ -1,9 +1,9 @@
use imphnen_libs::enviroment::load_env;
use imphnen_utils::{get_iso_date, Env};
use serde_json::json;
use std::error::Error;
use surrealdb::opt::auth::Root;
use imphnen_libs::enviroment::load_env;
use surrealdb::engine::any;
use surrealdb::opt::auth::Root;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
load_env();
@@ -49,9 +49,18 @@ async fn main() -> Result<(), Box<dyn Error>> {
None,
Some("2025-02-22T15:38:39.868306+00"),
),
(
"3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a",
"Mentor",
None,
Some("2025-07-06T10:00:00.000000+00"),
),
];
for (id, name, _created_at, _updated_at) in roles {
db.query("DELETE type::thing('app_roles', $id)")
.bind(("id", id))
.await?;
db.query("CREATE type::thing('app_roles', $id) CONTENT $data")
.bind(("id", id))
.bind((
@@ -65,7 +74,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
}),
))
.await?;
println!("✅ Inserted role: {}", name);
println!("✅ Inserted role: {name}");
}
println!("✅ All Roles seeded");
Ok(())
+117 -41
View File
@@ -1,8 +1,9 @@
use imphnen_iam::{get_iso_date, make_thing, Env, PermissionsEnum};
use std::error::Error;
use surrealdb::opt::auth::Root;
use surrealdb::engine::any;
use imphnen_libs::enviroment::load_env;
use std::error::Error;
use surrealdb::engine::any;
use surrealdb::opt::auth::Root;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
load_env();
@@ -16,43 +17,118 @@ async fn main() -> Result<(), Box<dyn Error>> {
db.use_ns(env.surrealdb_namespace)
.use_db(env.surrealdb_dbname)
.await?;
let permission_refs_admin: Vec<_> = [
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadDetailUsers,
PermissionsEnum::CreateUsers,
PermissionsEnum::DeleteUsers,
PermissionsEnum::UpdateUsers,
PermissionsEnum::ActivateUsers,
PermissionsEnum::ReadListRoles,
PermissionsEnum::ReadDetailRoles,
PermissionsEnum::CreateRoles,
PermissionsEnum::DeleteRoles,
PermissionsEnum::UpdateRoles,
PermissionsEnum::ReadListPermissions,
PermissionsEnum::ReadDetailPermissions,
PermissionsEnum::CreatePermissions,
PermissionsEnum::DeletePermissions,
PermissionsEnum::UpdatePermissions,
PermissionsEnum::CreateGachaClaims,
PermissionsEnum::ReadDetailGachaClaims,
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::CreateGachaItems,
PermissionsEnum::DeleteGachaItems,
PermissionsEnum::UpdateGachaItems,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
]
.iter()
.map(|perm| make_thing("app_permissions", perm.id()))
.collect();
let admin_role_id = "f6b03f25-e416-4893-ac88-caaa690afb07";
db.query("UPDATE type::thing('app_roles', $role_id) SET permissions = $permissions, updated_at = $updated_at WHERE is_deleted = false")
.bind(("role_id", admin_role_id))
.bind(("permissions", permission_refs_admin))
.bind(("updated_at", get_iso_date()))
.await?;
println!("✅ All permissions successfully added to Admin role");
let roles_permissions = vec![
(
"f6b03f25-e416-4893-ac88-caaa690afb07",
vec![
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadDetailUsers,
PermissionsEnum::CreateUsers,
PermissionsEnum::DeleteUsers,
PermissionsEnum::UpdateUsers,
PermissionsEnum::ActivateUsers,
PermissionsEnum::ReadListRoles,
PermissionsEnum::ReadDetailRoles,
PermissionsEnum::CreateRoles,
PermissionsEnum::DeleteRoles,
PermissionsEnum::UpdateRoles,
PermissionsEnum::ReadListPermissions,
PermissionsEnum::ReadDetailPermissions,
PermissionsEnum::CreatePermissions,
PermissionsEnum::DeletePermissions,
PermissionsEnum::UpdatePermissions,
PermissionsEnum::CreateGachaClaims,
PermissionsEnum::ReadDetailGachaClaims,
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::CreateGachaItems,
PermissionsEnum::DeleteGachaItems,
PermissionsEnum::UpdateGachaItems,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::RegisterMentors,
PermissionsEnum::UpdateMentors,
PermissionsEnum::VerifyMentors,
PermissionsEnum::DeleteMentors,
],
),
(
"3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a",
vec![
PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::UpdateOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
],
),
(
"5713cb37-dc02-4e87-8048-d7a41d352059",
vec![
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::CreateGachaClaims,
PermissionsEnum::ReadDetailGachaClaims,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
PermissionsEnum::RegisterMentors,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus,
],
),
(
"50133429-f4b1-4249-9f97-7b86e6ee9d86",
vec![
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailUsers,
PermissionsEnum::ActivateUsers,
PermissionsEnum::ReadListRoles,
PermissionsEnum::ReadDetailRoles,
PermissionsEnum::ReadListPermissions,
PermissionsEnum::ReadDetailPermissions,
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
],
),
(
"60f1aeb7-dad2-4e06-bcb5-be1ba510c906",
vec![PermissionsEnum::ActivateUsers],
),
("6d4fea5d-4a08-4b8a-9782-f2ab2183dcf0", vec![]),
];
for (role_id, permissions) in roles_permissions {
let permission_refs: Vec<_> = permissions
.iter()
.map(|perm| make_thing("app_permissions", perm.id()))
.collect();
db.query("UPDATE type::thing('app_roles', $role_id) SET permissions = $permissions, updated_at = $updated_at WHERE is_deleted = false")
.bind(("role_id", role_id))
.bind(("permissions", permission_refs))
.bind(("updated_at", get_iso_date()))
.await?;
println!("✅ Permissions updated for role: {role_id}");
}
println!("✅ All roles permissions updated!");
Ok(())
}
+7 -1
View File
@@ -2,6 +2,7 @@ use imphnen_iam::UsersSchema;
use imphnen_libs::enviroment::load_env;
use imphnen_utils::{get_iso_date, hash_password, Env};
use std::error::Error;
use surrealdb::{opt::auth::Root, sql::Thing};
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
@@ -40,6 +41,10 @@ async fn main() -> Result<(), Box<dyn Error>> {
];
for (id, email, fullname, role_id) in users {
db.query("DELETE type::thing('app_users', $id)")
.bind(("id", id))
.await?;
let user = UsersSchema {
id: Thing::from(("app_users", id)),
fullname: fullname.into(),
@@ -49,6 +54,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
phone_number: "081234567890".into(),
is_active: true,
is_deleted: false,
mentor_id: None,
gender: None,
birthdate: None,
role: Thing::from(("app_roles", role_id)),
@@ -60,7 +66,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
.content(user)
.await?;
println!("✅ Inserted user: {} ({})", fullname, email);
println!("✅ Inserted user: {fullname} ({email})");
}
println!("✅ All Users seeded");
+2 -1
View File
@@ -20,7 +20,8 @@ fn main() -> Result<(), Box<dyn Error>> {
run_seed("seed_roles_permissions")?;
run_seed("seed_users")?;
run_seed("seed_events")?;
run_seed("seed_gacha_rolls")?;
run_seed("seed_mentor_user")?;
println!("\n✅ All seeding completed successfully.");
Ok(())
}
+2
View File
@@ -3,6 +3,8 @@ use imphnen_libs::axum_init;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
axum_init(|surrealdb_ws, surrealdb_mem| async {
gateway_service(surrealdb_ws, surrealdb_mem).await
})
+9 -4
View File
@@ -4,10 +4,10 @@ version = "0.1.0"
edition = "2024"
[dependencies]
imphnen-iam = { version = "0.1.0", path = "../imphnen-iam" }
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" }
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" }
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" }
imphnen-iam.workspace = true
imphnen-libs.workspace = true
imphnen-utils.workspace = true
imphnen-entities.workspace = true
axum.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -23,3 +23,8 @@ chrono.workspace = true
anyhow.workspace = true
tower-http.workspace = true
utoipa-swagger-ui.workspace = true
log.workspace = true
tracing.workspace = true
[package.metadata.validator.regex]
VALID_URL_REGEX = "^https?://"
+11 -14
View File
@@ -1,16 +1,14 @@
use chrono::{DateTime, Utc};
use lazy_static::lazy_static;
use regex::Regex;
lazy_static! {
pub static ref VALID_URL_REGEX: regex::Regex =
regex::Regex::new(r"^https?://").unwrap();
}
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing;
use utoipa::ToSchema;
use validator::Validate;
// Lazy static regex for URL validation
lazy_static! {
static ref VALID_URL_REGEX: Regex = Regex::new(r"^https?://").unwrap();
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct EventsCreateRequestDto {
#[validate(length(min = 1, message = "Name is required"))]
@@ -19,13 +17,10 @@ pub struct EventsCreateRequestDto {
#[validate(length(min = 1, message = "Description is required"))]
pub description: String,
#[validate(regex(
path = "VALID_URL_REGEX",
message = "Detail link must be a valid URL"
))]
#[validate(url(message = "Detail link must be a valid URL"))]
pub detail_link: String,
#[validate(range(min = 0, message = "Price cannot be negative"))]
#[validate(range(min = 0.0, message = "Price cannot be negative"))]
pub price: f64,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
@@ -42,16 +37,18 @@ pub struct EventsCreateRequestDto {
pub struct EventsUpdateRequestDto {
#[validate(length(min = 1, message = "Name is required"))]
pub name: String,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub end_date: DateTime<Utc>,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub start_date: DateTime<Utc>,
#[validate(range(min = 0.0, message = "Price cannot be negative"))]
pub price: f64,
pub is_online: bool,
pub description: String,
#[validate(url(message = "Detail link must be a valid URL"))]
pub detail_link: String,
pub location: Option<String>,
}
@@ -2,6 +2,8 @@ use super::{events_dto::EventsQueryDto, events_schema::EventsSchema};
use anyhow::{Result, bail};
use imphnen_libs::{AppState, MetaRequestDto, ResourceEnum, ResponseListSuccessDto};
use imphnen_utils::{DetailQueryBuilder, ListQueryBuilder, get_id, get_iso_date};
use std::time::Instant;
use tracing::instrument;
pub struct EventsRepository<'a> {
state: &'a AppState,
@@ -12,17 +14,25 @@ impl<'a> EventsRepository<'a> {
Self { state }
}
#[instrument(skip(self, meta), err)]
pub async fn query_event_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<EventsQueryDto>>> {
let query = ListQueryBuilder::new(&ResourceEnum::Events.to_string())
let now = Instant::now();
let query = ListQueryBuilder::new(ResourceEnum::Events.to_string())
.with_select_fields(vec!["*"])
.with_pagination(meta.page, Some(10))
.with_sorting(meta.sort_by.as_deref(), meta.order.as_deref())
.build();
let res: Vec<EventsQueryDto> =
self.state.surrealdb_ws.query(query).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_event_list' took: {elapsed:.2?}");
}
let data = ResponseListSuccessDto {
data: res,
meta: None,
@@ -30,8 +40,9 @@ impl<'a> EventsRepository<'a> {
Ok(data)
}
// Get event by ID
#[instrument(skip(self, id), err)]
pub async fn query_event_by_id(&self, id: String) -> Result<EventsQueryDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::Events.to_string())
.with_id(&id)
@@ -39,6 +50,12 @@ impl<'a> EventsRepository<'a> {
let sql = builder.build();
let result: Option<EventsQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_event_by_id' took: {elapsed:.2?}");
}
match result {
Some(event) => {
@@ -51,13 +68,20 @@ impl<'a> EventsRepository<'a> {
}
}
// Create new event
#[instrument(skip(self, data), err)]
pub async fn query_create_event(&self, data: EventsSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let record: Option<EventsSchema> = db
.create(ResourceEnum::Events.to_string())
.content(data)
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_event' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success create event".into()),
@@ -65,17 +89,16 @@ impl<'a> EventsRepository<'a> {
}
}
// Update existing event
#[instrument(skip(self, data), err)]
pub async fn query_update_event(&self, data: EventsSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
// Cek apakah event ada
let existing = self.query_event_by_id(data.id.id.to_raw()).await?;
if existing.is_deleted {
bail!("Event already deleted");
}
// Merge field tertentu jika diperlukan
let merged = EventsSchema {
created_at: existing.created_at,
updated_at: get_iso_date(),
@@ -84,6 +107,12 @@ impl<'a> EventsRepository<'a> {
let record_key = get_id(&merged.id)?;
let record: Option<EventsSchema> = db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_event' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success update event".into()),
@@ -91,8 +120,9 @@ impl<'a> EventsRepository<'a> {
}
}
// Soft delete event (mark is_deleted = true)
#[instrument(skip(self, id), err)]
pub async fn query_delete_event(&self, id: String) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let event = self.query_event_by_id(id).await?;
if event.is_deleted {
@@ -104,6 +134,12 @@ impl<'a> EventsRepository<'a> {
.update(record_key)
.merge(serde_json::json!({ "is_deleted": true }))
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_event' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success delete event".into()),
@@ -1,10 +1,12 @@
use imphnen_libs::ResourceEnum;
use imphnen_utils::{get_iso_date, make_thing};
use serde::{Deserialize, Serialize};
use surrealdb::Uuid;
use surrealdb::sql::Thing;
use serde::{Deserialize, Serialize};
use imphnen_utils::{get_iso_date, make_thing};
use super::events_dto::{EventsCreateRequestDto, EventsQueryDto, EventsUpdateRequestDto};
use super::events_dto::{
EventsCreateRequestDto, EventsQueryDto, EventsUpdateRequestDto,
};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EventsSchema {
@@ -1,86 +1,101 @@
use super::{
events_dto::{EventsCreateRequestDto, EventsDetailItemDto, EventsListItemDto, EventsQueryDto, EventsUpdateRequestDto},
events_repository::EventsRepository,
events_schema::EventsSchema,
events_dto::{
EventsCreateRequestDto, EventsDetailItemDto, EventsListItemDto, EventsQueryDto,
EventsUpdateRequestDto,
},
events_repository::EventsRepository,
events_schema::EventsSchema,
};
use imphnen_libs::{AppState, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto};
use imphnen_utils::{common_response, success_list_response, success_response, validate_request};
use axum::{http::StatusCode, response::Response};
use imphnen_libs::{
AppState, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto,
};
use imphnen_utils::{
common_response, success_list_response, success_response, validate_request,
};
pub struct EventsService;
impl EventsService {
pub async fn get_event_list(state: &AppState, meta: MetaRequestDto) -> Response {
let repo = EventsRepository::new(state);
match repo.query_event_list(meta).await {
Ok(data) => {
let items: Vec<EventsListItemDto> = data.data
.into_iter()
.filter(|e| !e.is_deleted)
.map(EventsQueryDto::from)
.collect();
let response = ResponseListSuccessDto {
data: items,
meta: data.meta,
};
success_list_response(response)
}
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
pub async fn get_event_list(state: &AppState, meta: MetaRequestDto) -> Response {
let repo = EventsRepository::new(state);
match repo.query_event_list(meta).await {
Ok(data) => {
let items: Vec<EventsListItemDto> = data
.data
.into_iter()
.filter(|e| !e.is_deleted)
.map(EventsQueryDto::from)
.collect();
let response = ResponseListSuccessDto {
data: items,
meta: data.meta,
};
success_list_response(response)
}
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
pub async fn get_event_by_id(state: &AppState, id: String) -> Response {
let repo = EventsRepository::new(state);
match repo.query_event_by_id(id).await {
Ok(event) if !event.is_deleted => success_response(ResponseSuccessDto {
data: EventsDetailItemDto {
id: event.id.id.to_raw(),
name: event.name,
description: event.description,
detail_link: event.detail_link,
price: event.price,
is_online: event.is_online,
start_date: event.start_date,
end_date: event.end_date,
created_at: event.created_at,
updated_at: event.updated_at,
location: event.location,
},
}),
Ok(_) => common_response(StatusCode::NOT_FOUND, "Event not found"),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
}
}
pub async fn get_event_by_id(state: &AppState, id: String) -> Response {
let repo = EventsRepository::new(state);
match repo.query_event_by_id(id).await {
Ok(event) if !event.is_deleted => success_response(ResponseSuccessDto {
data: EventsDetailItemDto {
id: event.id.id.to_raw(),
name: event.name,
description: event.description,
detail_link: event.detail_link,
price: event.price,
is_online: event.is_online,
start_date: event.start_date,
end_date: event.end_date,
created_at: event.created_at,
updated_at: event.updated_at,
location: event.location,
},
}),
Ok(_) => common_response(StatusCode::NOT_FOUND, "Event not found"),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
}
}
pub async fn create_event(state: &AppState, payload: EventsCreateRequestDto) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let repo = EventsRepository::new(state);
let schema = EventsSchema::create(payload);
match repo.query_create_event(schema).await {
Ok(msg) => common_response(StatusCode::CREATED, &msg),
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn create_event(
state: &AppState,
payload: EventsCreateRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let repo = EventsRepository::new(state);
let schema = EventsSchema::create(payload);
match repo.query_create_event(schema).await {
Ok(msg) => common_response(StatusCode::CREATED, &msg),
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn update_event(state: &AppState, id: String, payload: EventsUpdateRequestDto) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let repo = EventsRepository::new(state);
let schema = EventsSchema::update(payload, id);
match repo.query_update_event(schema).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
pub async fn update_event(
state: &AppState,
id: String,
payload: EventsUpdateRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let repo = EventsRepository::new(state);
let schema = EventsSchema::update(payload, id);
match repo.query_update_event(schema).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
pub async fn delete_event(state: &AppState, id: String) -> Response {
let repo = EventsRepository::new(state);
match repo.query_delete_event(id).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
}
pub async fn delete_event(state: &AppState, id: String) -> Response {
let repo = EventsRepository::new(state);
match repo.query_delete_event(id).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
}
@@ -73,7 +73,6 @@ pub async fn post_create_testimonial(
Extension(authenticated_user): Extension<UsersDetailQueryDto>,
Json(payload): Json<TestimonialsCreateRequestDto>,
) -> impl IntoResponse {
println!("Authenticated User Now: {:?}", authenticated_user);
TestimonialsService::create_testimonial(&state, payload, &authenticated_user).await
}
@@ -34,7 +34,7 @@ pub struct TestimonialsUpdateRequestDto {
pub struct TestimonialsListItemDto {
pub id: String,
pub user_id: String,
pub user_fullname: String, // Assuming we'll fetch user's full name
pub user_fullname: String,
pub role: String,
pub content: String,
pub created_at: String,
@@ -45,7 +45,7 @@ pub struct TestimonialsListItemDto {
pub struct TestimonialsDetailItemDto {
pub id: String,
pub user_id: String,
pub user_fullname: String, // Assuming we'll fetch user's full name
pub user_fullname: String,
pub role: String,
pub content: String,
pub created_at: String,
@@ -55,7 +55,7 @@ pub struct TestimonialsDetailItemDto {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TestimonialsQueryDto {
pub id: Thing,
pub user: UsersSchema, // Change from Thing to UsersSchema
pub user: UsersSchema,
pub role: String,
pub content: String,
pub is_deleted: bool,
@@ -68,7 +68,7 @@ impl TestimonialsQueryDto {
TestimonialsListItemDto {
id: self.id.id.to_raw(),
user_id: self.user.id.id.to_raw(),
user_fullname: self.user.fullname, // Extract fullname from UsersSchema
user_fullname: self.user.fullname,
role: self.role,
content: self.content,
created_at: self.created_at,
@@ -4,6 +4,9 @@ use super::{
use anyhow::{Result, bail};
use imphnen_libs::{AppState, MetaRequestDto, ResourceEnum, ResponseListSuccessDto};
use imphnen_utils::{DetailQueryBuilder, ListQueryBuilder, get_id, get_iso_date};
use serde_json;
use std::time::Instant;
use tracing::instrument;
pub struct TestimonialsRepository<'a> {
state: &'a AppState,
@@ -14,17 +17,25 @@ impl<'a> TestimonialsRepository<'a> {
Self { state }
}
#[instrument(skip(self, meta), err)]
pub async fn query_testimonial_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<TestimonialsQueryDto>>> {
let query = ListQueryBuilder::new(&ResourceEnum::Testimonials.to_string())
.with_select_fields(vec!["*", "user.* as user"]) // Select user details
let now = Instant::now();
let query = ListQueryBuilder::new(ResourceEnum::Testimonials.to_string())
.with_select_fields(vec!["*", "user.* as user"])
.with_pagination(meta.page, Some(10))
.with_sorting(meta.sort_by.as_deref(), meta.order.as_deref())
.build();
let res: Vec<TestimonialsQueryDto> =
self.state.surrealdb_ws.query(query).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_testimonial_list' took: {elapsed:.2?}");
}
let data = ResponseListSuccessDto {
data: res,
meta: None,
@@ -32,17 +43,26 @@ impl<'a> TestimonialsRepository<'a> {
Ok(data)
}
#[instrument(skip(self, id), err)]
pub async fn query_testimonial_by_id(
&self,
id: String,
) -> Result<TestimonialsQueryDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::Testimonials.to_string())
.with_id(&id)
.with_select_fields(vec!["*", "user.* as user"]); // Select user details
.with_condition("is_deleted = false")
.with_select_fields(vec!["*", "user.* as user"]);
let sql = builder.build();
let result: Option<TestimonialsQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_testimonial_by_id' took: {elapsed:.2?}");
}
match result {
Some(testimonial) => {
@@ -55,15 +75,23 @@ impl<'a> TestimonialsRepository<'a> {
}
}
#[instrument(skip(self, data), err)]
pub async fn query_create_testimonial(
&self,
data: TestimonialsSchema,
) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let record: Option<TestimonialsSchema> = db
.create(ResourceEnum::Testimonials.to_string())
.content(data)
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_testimonial' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success create testimonial".into()),
@@ -71,10 +99,12 @@ impl<'a> TestimonialsRepository<'a> {
}
}
#[instrument(skip(self, data), err)]
pub async fn query_update_testimonial(
&self,
data: TestimonialsSchema,
) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let existing = self.query_testimonial_by_id(data.id.id.to_raw()).await?;
@@ -85,13 +115,19 @@ impl<'a> TestimonialsRepository<'a> {
let merged = TestimonialsSchema {
created_at: existing.created_at,
updated_at: get_iso_date(),
user: existing.user.id, // Preserve user ID
user: existing.user.id,
..data
};
let record_key = get_id(&merged.id)?;
let record: Option<TestimonialsSchema> =
db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_testimonial' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success update testimonial".into()),
@@ -99,7 +135,9 @@ impl<'a> TestimonialsRepository<'a> {
}
}
#[instrument(skip(self, id), err)]
pub async fn query_delete_testimonial(&self, id: String) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let testimonial = self.query_testimonial_by_id(id).await?;
if testimonial.is_deleted {
@@ -111,6 +149,12 @@ impl<'a> TestimonialsRepository<'a> {
.update(record_key)
.merge(serde_json::json!({ "is_deleted": true }))
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_testimonial' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success delete testimonial".into()),
@@ -11,7 +11,7 @@ use super::testimonials_dto::{
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TestimonialsSchema {
pub id: Thing,
pub user: Thing, // Link to app_users table
pub user: Thing,
pub role: String,
pub content: String,
pub is_deleted: bool,
@@ -28,7 +28,7 @@ impl Default for TestimonialsSchema {
),
user: make_thing(
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(), // Placeholder, will be replaced by actual user ID
&Uuid::new_v4().to_string(),
),
role: String::new(),
content: String::new(),
+9 -3
View File
@@ -4,9 +4,10 @@ version = "0.1.0"
edition = "2024"
[dependencies]
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" }
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" }
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" }
imphnen-libs.workspace = true
imphnen-utils.workspace = true
imphnen-entities.workspace = true
imphnen-iam.workspace = true
axum.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -22,3 +23,8 @@ chrono.workspace = true
anyhow.workspace = true
tower-http.workspace = true
utoipa-swagger-ui.workspace = true
tracing.workspace = true
[dev-dependencies]
dotenvy.workspace = true
http-body-util.workspace = true
+2 -14
View File
@@ -1,14 +1,2 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
pub mod v1;
pub use v1::*;
@@ -0,0 +1,356 @@
use super::{
MentorDetailResponseDto, MentorListResponseDto, MentorUpdateRequestDto,
MentorUserRegisterRequestDto, MentorVerifyRequestDto, MentorsService,
};
use crate::v1::mentors::mentors_dto::MentorRegisterResponseDto;
use ::axum::{
extract::{Extension, Json, Path, Query},
http::HeaderMap,
response::{IntoResponse, Response},
};
use imphnen_entities::*;
use imphnen_iam::{PermissionsEnum, permissions_guard};
use imphnen_utils::extract_email;
use serde_json::json;
#[utoipa::path(
post,
path = "/v1/mentors/register",
request_body = MentorUserRegisterRequestDto,
responses(
(status = 200, description = "Mentor registered successfully", body = MentorRegisterResponseDto),
(status = 400, description = "Bad request - validation error"),
(status = 409, description = "Conflict - user already has mentor profile"),
(status = 500, description = "Internal server error")
),
tag = "Mentors"
)]
pub async fn post_register_mentor(
Extension(app_state): Extension<AppState>,
Json(dto): Json<MentorUserRegisterRequestDto>,
) -> Response {
MentorsService::register_mentor(&app_state, dto).await
}
#[utoipa::path(
get,
path = "/v1/mentors",
params(
("page" = Option<u64>, Query, description = "Page number"),
("per_page" = Option<u64>, Query, description = "Items per page"),
("search" = Option<String>, Query, description = "Search query"),
("sort_by" = Option<String>, Query, description = "Sort by field"),
("order" = Option<String>, Query, description = "Sort order (ASC/DESC)"),
),
responses(
(status = 200, description = "Get list of mentors", body = Vec<MentorListResponseDto>),
(status = 500, description = "Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn get_mentor_list(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Query(meta): Query<MetaRequestDto>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
vec![PermissionsEnum::ReadListMentors],
)
.await
{
Ok(_) => MentorsService::get_mentor_list(&app_state, meta).await,
Err(response) => response,
}
}
#[utoipa::path(
get,
path = "/v1/mentors/detail/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
responses(
(status = 200, description = "Get mentor by ID", body = MentorDetailResponseDto),
(status = 404, description = "Mentor not found"),
(status = 500, description = "Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn get_mentor_by_id(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Path(id): Path<String>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
vec![PermissionsEnum::ReadDetailMentors],
)
.await
{
Ok(_) => MentorsService::get_mentor_by_id(&app_state, &id).await,
Err(response) => response,
}
}
#[utoipa::path(
put,
path = "/v1/mentors/update/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
request_body = MentorUpdateRequestDto,
responses(
(status = 200, description = "Mentor updated successfully", body = MentorDetailResponseDto),
(status = 400, description = "Bad request - validation error"),
(status = 404, description = "Mentor not found"),
(status = 500, description = "Internal server error")
),
tag = "Mentors - Admin",
security(
("Bearer" = [])
)
)]
pub async fn put_update_mentor(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Path(id): Path<String>,
Json(dto): Json<MentorUpdateRequestDto>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
vec![PermissionsEnum::UpdateMentors],
)
.await
{
Ok(_) => MentorsService::update_mentor(&app_state, &id, dto).await,
Err(response) => response,
}
}
#[utoipa::path(
delete,
path = "/v1/mentors/delete/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
responses(
(status = 200, description = "Mentor deleted successfully"),
(status = 404, description = "Mentor not found"),
(status = 500, description = "Internal server error")
),
tag = "Mentors - Admin",
security(
("Bearer" = [])
)
)]
pub async fn delete_mentor(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Path(id): Path<String>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
vec![PermissionsEnum::DeleteMentors],
)
.await
{
Ok(_) => MentorsService::delete_mentor(&app_state, &id).await,
Err(response) => response,
}
}
#[utoipa::path(
put,
path = "/v1/mentors/verify/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
request_body = MentorVerifyRequestDto,
responses(
(status = 200, description = "Mentor verified successfully", body = MentorDetailResponseDto),
(status = 400, description = "Bad request - validation error"),
(status = 404, description = "Mentor not found"),
(status = 500, description = "Internal server error")
),
tag = "Mentors - Admin",
security(
("Bearer" = [])
)
)]
pub async fn put_verify_mentor(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Path(id): Path<String>,
Json(dto): Json<MentorVerifyRequestDto>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
vec![PermissionsEnum::VerifyMentors],
)
.await
{
Ok(_) => MentorsService::verify_mentor(&app_state, &id, dto).await,
Err(response) => response,
}
}
#[utoipa::path(
get,
path = "/v1/mentors/me",
responses(
(status = 200, description = "Current user's mentor profile", body = MentorDetailResponseDto),
(status = 401, description = "Unauthorized - invalid token"),
(status = 403, description = "Mentor profile not found for current user"),
(status = 500, description = "Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn get_mentor_me(
Extension(app_state): Extension<AppState>,
headers: HeaderMap,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
vec![PermissionsEnum::ReadOwnMentorProfile],
)
.await
{
Ok(_) => {
let email = match extract_email(&headers) {
Some(email) => email,
None => {
return (
axum::http::StatusCode::UNAUTHORIZED,
Json(json!({
"error": "Unauthorized",
"message": "Token tidak valid"
})),
)
.into_response();
}
};
MentorsService::get_mentor_me(&app_state, &email).await
}
Err(response) => response,
}
}
#[utoipa::path(
put,
path = "/v1/mentors/update/me",
request_body = MentorUpdateRequestDto,
responses(
(status = 200, description = "Mentor profile updated successfully", body = MentorDetailResponseDto),
(status = 400, description = "Bad request - validation error"),
(status = 401, description = "Unauthorized - invalid token"),
(status = 404, description = "Mentor profile not found"),
(status = 500, description = "Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn put_update_mentor_me(
Extension(app_state): Extension<AppState>,
headers: HeaderMap,
Json(dto): Json<MentorUpdateRequestDto>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
vec![PermissionsEnum::UpdateOwnMentorProfile],
)
.await
{
Ok(_) => {
let email = match extract_email(&headers) {
Some(email) => email,
None => {
return imphnen_utils::common_response(
axum::http::StatusCode::UNAUTHORIZED,
"Token tidak valid",
);
}
};
MentorsService::update_mentor_me(&app_state, &email, dto).await
}
Err(response) => response,
}
}
#[utoipa::path(
put,
path = "/v1/mentors/update",
request_body = MentorUpdateRequestDto,
responses(
(status = 400, description = "Bad request - Mentor ID is required for update"),
),
tag = "Mentors - Admin"
)]
pub async fn put_update_mentor_no_id() -> Response {
imphnen_utils::common_response(
axum::http::StatusCode::BAD_REQUEST,
"Mentor ID is required for update",
)
}
#[utoipa::path(
get,
path = "/v1/mentors/status",
responses(
(status = 200, description = "Mentor application status", body = String),
(status = 401, description = "Unauthorized - invalid token"),
(status = 403, description = "No mentor application found for current user"),
(status = 500, description = "Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn get_mentor_status(
Extension(app_state): Extension<AppState>,
headers: HeaderMap,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
vec![PermissionsEnum::ReadOwnMentorStatus],
)
.await
{
Ok(_) => {
let email = match extract_email(&headers) {
Some(email) => email,
None => {
return (
axum::http::StatusCode::UNAUTHORIZED,
Json(json!({
"error": "Unauthorized",
"message": "Token tidak valid"
})),
)
.into_response();
}
};
MentorsService::get_mentor_status(&app_state, &email).await
}
Err(response) => response,
}
}
@@ -0,0 +1,479 @@
use crate::v1::mentors::MentorSchema;
use imphnen_utils::extract_id;
use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing;
use utoipa::ToSchema;
use validator::Validate;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MentorListResponseDto {
pub id: String,
pub fullname: Option<String>,
pub email: Option<String>,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MentorDetailWithUserDto {
pub id: Thing,
pub user_id: Thing,
pub fullname: Option<String>,
pub email: Option<String>,
pub legal_name: String,
pub identity_document_url: String,
pub phone_for_verification: String,
pub bio: String,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
pub current_company: String,
pub current_role: String,
pub years_of_experience: i32,
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
pub availability_commitment: String,
pub mentoring_rate: MentoringRate,
pub status: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MentorDetailResponseDto {
pub id: String,
pub user_id: String,
pub fullname: Option<String>,
pub email: Option<String>,
pub legal_name: String,
pub identity_document_url: String,
pub phone_for_verification: String,
pub bio: String,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
pub current_company: String,
pub current_role: String,
pub years_of_experience: i32,
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
pub availability_commitment: String,
pub mentoring_rate: MentoringRate,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentorRegisterResponseDto {
pub id: String,
pub user_id: String,
pub email: Option<String>,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentorUpdateRequestDto {
#[validate(length(
min = 3,
message = "Legal name must be at least 3 characters"
))]
#[serde(skip_serializing_if = "Option::is_none")]
pub legal_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domicile: Option<String>,
#[validate(url(message = "Invalid identity document URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub identity_document_url: Option<String>,
#[validate(length(
min = 10,
max = 15,
message = "Phone must be 10-15 characters"
))]
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_for_verification: Option<String>,
#[validate(length(min = 50, message = "Bio must be at least 50 characters"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_education: Option<String>,
#[validate(url(message = "Invalid LinkedIn URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub linkedin_url: Option<String>,
#[validate(url(message = "Invalid GitHub URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub github_url: Option<String>,
#[validate(url(message = "Invalid CV URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub cv_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub portfolio_url: Option<String>,
#[validate(length(min = 1, message = "At least 1 industry required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub industries: Option<Vec<String>>,
#[validate(length(min = 1, message = "At least 1 expertise required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub expertise: Option<Vec<String>>,
#[validate(length(min = 1, message = "At least 1 language required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub languages: Option<Vec<String>>,
#[validate(length(min = 1, message = "Current company required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub current_company: Option<String>,
#[validate(length(min = 1, message = "Current role required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub current_role: Option<String>,
#[validate(range(min = 2, message = "At least 2 years of experience required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub years_of_experience: Option<i32>,
#[validate(length(min = 1, message = "At least 1 topic required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub topics_of_interest: Option<Vec<String>>,
#[validate(length(min = 1, message = "At least 1 mentee level required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_mentee_level: Option<Vec<String>>,
#[validate(length(min = 1, message = "At least 1 mentoring format required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_mentoring_formats: Option<Vec<String>>,
#[validate(length(
min = 5,
message = "Availability commitment must be at least 5 characters"
))]
#[serde(skip_serializing_if = "Option::is_none")]
pub availability_commitment: Option<String>,
#[validate(range(min = 1, message = "Amount must be at least 1"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub mentoring_rate_amount: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentorUserRegisterRequestDto {
#[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(custom(
function = "imphnen_iam::auth_dto::validate_password_complexity",
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 = "Phone number is required"))]
pub phone_number: String,
#[validate(nested)]
pub identity_and_verification: IdentityAndVerification,
#[validate(nested)]
pub professional_profile: ProfessionalProfile,
#[validate(nested)]
pub mentoring_logistics: MentoringLogistics,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentorRegisterFromTokenRequestDto {
#[validate(nested)]
pub identity_and_verification: IdentityAndVerification,
#[validate(nested)]
pub professional_profile: ProfessionalProfile,
#[validate(nested)]
pub mentoring_logistics: MentoringLogistics,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct IdentityAndVerification {
#[validate(length(
min = 3,
message = "Legal name must be at least 3 characters"
))]
pub legal_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domicile: Option<String>,
#[validate(url(message = "Invalid identity document URL"))]
pub identity_document_url: String,
#[validate(length(
min = 10,
max = 15,
message = "Phone must be 10-15 characters"
))]
pub phone_for_verification: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct ProfessionalProfile {
#[validate(length(min = 50, message = "Bio must be at least 50 characters"))]
pub bio: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_education: Option<String>,
#[validate(url(message = "Invalid LinkedIn URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub linkedin_url: Option<String>,
#[validate(url(message = "Invalid GitHub URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub github_url: Option<String>,
#[validate(url(message = "Invalid CV URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub cv_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub portfolio_url: Option<String>,
#[validate(length(min = 1, message = "At least 1 industry required"))]
pub industries: Vec<String>,
#[validate(length(min = 1, message = "At least 1 expertise required"))]
pub expertise: Vec<String>,
#[validate(length(min = 1, message = "At least 1 language required"))]
pub languages: Vec<String>,
#[validate(length(min = 1, message = "Current company required"))]
pub current_company: String,
#[validate(length(min = 1, message = "Current role required"))]
pub current_role: String,
#[validate(range(min = 2, message = "At least 2 years of experience required"))]
pub years_of_experience: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentoringLogistics {
#[validate(length(min = 1, message = "At least 1 topic required"))]
pub topics_of_interest: Vec<String>,
#[validate(length(min = 1, message = "At least 1 mentee level required"))]
pub preferred_mentee_level: Vec<String>,
#[validate(length(min = 1, message = "At least 1 mentoring format required"))]
pub preferred_mentoring_formats: Vec<String>,
#[validate(length(
min = 5,
message = "Availability commitment must be at least 5 characters"
))]
pub availability_commitment: String,
#[validate(range(min = 1, message = "Amount must be at least 1"))]
pub mentoring_rate_amount: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate, Default)]
pub struct MentoringRate {
#[validate(range(min = 1, message = "Amount must be at least 1"))]
pub amount: u64,
#[validate(length(min = 1, message = "Currency is required"))]
pub currency: String,
#[validate(length(min = 1, message = "Per duration is required"))]
pub per_duration: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MentorInsertDto {
pub id: Thing,
pub user_id: Option<Thing>,
pub email: Option<String>,
pub legal_name: String,
pub gender: Option<String>,
pub domicile: Option<String>,
pub identity_document_url: String,
pub phone_for_verification: String,
pub bio: String,
pub last_education: Option<String>,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub portfolio_url: Option<String>,
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
pub current_company: String,
pub current_role: String,
pub years_of_experience: i32,
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
pub availability_commitment: String,
pub mentoring_rate: MentoringRate,
pub status: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
impl From<MentorSchema> for MentorInsertDto {
fn from(schema: MentorSchema) -> Self {
MentorInsertDto {
id: schema.id,
user_id: schema.user_id,
email: schema.email,
legal_name: schema.legal_name,
gender: schema.gender,
domicile: schema.domicile,
identity_document_url: schema.identity_document_url,
phone_for_verification: schema.phone_for_verification,
bio: schema.bio,
last_education: schema.last_education,
linkedin_url: schema.linkedin_url,
github_url: schema.github_url,
cv_url: schema.cv_url,
portfolio_url: schema.portfolio_url,
industries: schema.industries,
expertise: schema.expertise,
languages: schema.languages,
current_company: schema.current_company,
current_role: schema.current_role,
years_of_experience: schema.years_of_experience,
topics_of_interest: schema.topics_of_interest,
preferred_mentee_level: schema.preferred_mentee_level,
preferred_mentoring_formats: schema.preferred_mentoring_formats,
availability_commitment: schema.availability_commitment,
mentoring_rate: schema.mentoring_rate,
status: schema.status,
is_deleted: schema.is_deleted,
created_at: schema.created_at,
updated_at: schema.updated_at,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentorVerifyRequestDto {
#[validate(length(min = 1, message = "Status is required"))]
pub status: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MentorDetailQueryDto {
pub id: Thing,
pub user_id: Thing,
pub fullname: Option<String>,
pub email: Option<String>,
pub legal_name: String,
pub gender: Option<String>,
pub domicile: Option<String>,
pub identity_document_url: String,
pub phone_for_verification: String,
pub bio: String,
pub last_education: Option<String>,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub portfolio_url: Option<String>,
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
pub current_company: String,
pub current_role: String,
pub years_of_experience: i32,
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
pub availability_commitment: String,
pub mentoring_rate: MentoringRate,
pub status: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
impl From<MentorDetailQueryDto> for MentorListResponseDto {
fn from(dto: MentorDetailQueryDto) -> Self {
Self {
id: extract_id(&dto.id),
fullname: dto.fullname,
email: dto.email,
status: dto.status,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
}
impl From<MentorDetailQueryDto> for MentorDetailResponseDto {
fn from(dto: MentorDetailQueryDto) -> Self {
Self {
id: extract_id(&dto.id),
user_id: extract_id(&dto.user_id),
fullname: dto.fullname,
email: dto.email,
legal_name: dto.legal_name,
identity_document_url: dto.identity_document_url,
phone_for_verification: dto.phone_for_verification,
bio: dto.bio,
linkedin_url: dto.linkedin_url,
github_url: dto.github_url,
cv_url: dto.cv_url,
industries: dto.industries,
expertise: dto.expertise,
languages: dto.languages,
current_company: dto.current_company,
current_role: dto.current_role,
years_of_experience: dto.years_of_experience,
topics_of_interest: dto.topics_of_interest,
preferred_mentee_level: dto.preferred_mentee_level,
preferred_mentoring_formats: dto.preferred_mentoring_formats,
availability_commitment: dto.availability_commitment,
mentoring_rate: dto.mentoring_rate,
status: dto.status,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
}
impl From<MentorSchema> for MentorRegisterResponseDto {
fn from(schema: MentorSchema) -> Self {
Self {
id: schema.id.to_string(),
user_id: schema.user_id.map(|id| extract_id(&id)).unwrap_or_default(),
email: schema.email,
status: schema.status,
created_at: schema.created_at,
updated_at: schema.updated_at,
}
}
}
impl From<MentorDetailWithUserDto> for MentorDetailQueryDto {
fn from(dto: MentorDetailWithUserDto) -> Self {
MentorDetailQueryDto {
id: dto.id,
user_id: dto.user_id,
fullname: dto.fullname,
email: dto.email,
legal_name: dto.legal_name,
gender: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto
domicile: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto
identity_document_url: dto.identity_document_url,
phone_for_verification: dto.phone_for_verification,
bio: dto.bio,
last_education: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto
linkedin_url: dto.linkedin_url,
github_url: dto.github_url,
cv_url: dto.cv_url,
portfolio_url: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto
industries: dto.industries,
expertise: dto.expertise,
languages: dto.languages,
current_company: dto.current_company,
current_role: dto.current_role,
years_of_experience: dto.years_of_experience,
topics_of_interest: dto.topics_of_interest,
preferred_mentee_level: dto.preferred_mentee_level,
preferred_mentoring_formats: dto.preferred_mentoring_formats,
availability_commitment: dto.availability_commitment,
mentoring_rate: dto.mentoring_rate,
status: dto.status,
is_deleted: dto.is_deleted,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
}
@@ -0,0 +1,299 @@
use anyhow::{Result, bail};
use imphnen_iam::{get_id, make_thing};
use surrealdb::sql::Thing;
use crate::v1::mentors::{MentorDetailWithUserDto, MentorInsertDto, MentorSchema};
use imphnen_libs::{AppState, MetaRequestDto, ResourceEnum, ResponseListSuccessDto};
use imphnen_utils::{DetailQueryBuilder, QueryListBuilder, get_iso_date};
use serde_json::{Map, Value};
use std::time::Instant;
use tracing::instrument;
pub struct MentorsRepository<'a> {
pub state: &'a AppState,
}
impl<'a> MentorsRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
#[instrument(skip(self, meta), err)]
pub async fn query_mentor_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<MentorDetailWithUserDto>>> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let mentors_table = ResourceEnum::Mentors.to_string();
let builder = QueryListBuilder::new(db, &mentors_table, &meta)
.search_field("legal_name")
.select_fields(vec![
"id",
"user_id",
"user_id.fullname as fullname",
"email",
"legal_name",
"identity_document_url",
"phone_for_verification",
"bio",
"linkedin_url",
"github_url",
"cv_url",
"industries",
"expertise",
"languages",
"current_company",
"current_role",
"years_of_experience",
"topics_of_interest",
"preferred_mentee_level",
"preferred_mentoring_formats",
"availability_commitment",
"mentoring_rate",
"status",
"is_deleted",
"created_at",
"updated_at",
]);
let result = builder.build().await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_mentor_list' took: {elapsed:.2?}");
}
let data = result.data.into_iter().collect();
Ok(ResponseListSuccessDto {
data,
meta: result.meta,
})
}
#[instrument(skip(self, email, include_deleted), err)]
pub async fn query_mentor_by_email(
&self,
email: String,
include_deleted: bool,
) -> Result<MentorDetailWithUserDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let mut builder = DetailQueryBuilder::new(ResourceEnum::Mentors.to_string())
.with_where("email", Some(email.clone()))
.with_select_fields(vec![
"id",
"user_id",
"user_id.fullname as fullname",
"email",
"legal_name",
"identity_document_url",
"phone_for_verification",
"bio",
"linkedin_url",
"github_url",
"cv_url",
"industries",
"expertise",
"languages",
"current_company",
"current_role",
"years_of_experience",
"topics_of_interest",
"preferred_mentee_level",
"preferred_mentoring_formats",
"availability_commitment",
"mentoring_rate",
"status",
"is_deleted",
"created_at",
"updated_at",
]);
if !include_deleted {
builder = builder.with_condition("is_deleted = false");
}
let sql = builder.build();
let mentor_opt: Option<MentorDetailWithUserDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_mentor_by_email' took: {elapsed:.2?}");
}
let Some(mentor) = mentor_opt else {
bail!("Mentor not found");
};
Ok(mentor)
}
#[instrument(skip(self, id, include_deleted), err)]
pub async fn query_mentor_by_id(
&self,
id: &Thing,
include_deleted: bool,
) -> Result<MentorDetailWithUserDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let mut builder = DetailQueryBuilder::new(ResourceEnum::Mentors.to_string())
.with_id(get_id(id)?.1)
.with_select_fields(vec![
"id",
"user_id",
"user_id.fullname as fullname",
"email",
"legal_name",
"identity_document_url",
"phone_for_verification",
"bio",
"linkedin_url",
"github_url",
"cv_url",
"industries",
"expertise",
"languages",
"current_company",
"current_role",
"years_of_experience",
"topics_of_interest",
"preferred_mentee_level",
"preferred_mentoring_formats",
"availability_commitment",
"mentoring_rate",
"status",
"is_deleted",
"created_at",
"updated_at",
]);
if !include_deleted {
builder = builder.with_condition("is_deleted = false");
}
let sql = builder.build();
let mentor_opt: Option<MentorDetailWithUserDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_mentor_by_id' took: {elapsed:.2?}");
}
let Some(mentor) = mentor_opt else {
bail!("Mentor not found in database");
};
if mentor.is_deleted && !include_deleted {
bail!("Mentor has been deleted");
}
Ok(mentor)
}
#[instrument(skip(self, data), err)]
pub async fn query_create_mentor(&self, data: MentorSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let dto: MentorInsertDto = data.into();
let record: Option<MentorSchema> = db
.create(ResourceEnum::Mentors.to_string())
.content(dto.clone())
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_mentor' took: {elapsed:.2?}");
}
match record {
Some(mentor) => {
let id_str = mentor.id.id.to_raw();
let _user = format!("{:?}", mentor.user_id);
Ok(id_str)
}
None => {
bail!("Failed to create mentor")
}
}
}
#[instrument(skip(self, data), err)]
pub async fn query_update_mentor(&self, data: MentorSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let id_ref = &data.id;
let record_key = get_id(id_ref)?;
let _existing = self.query_mentor_by_id(id_ref, false).await?;
let mut merged_data_json: Map<String, Value> =
serde_json::to_value(data.clone())
.map_err(|e| anyhow::anyhow!("Failed to serialize MentorSchema: {}", e))?
.as_object()
.cloned()
.unwrap_or_default();
merged_data_json.remove("id");
merged_data_json.remove("user_id");
merged_data_json.remove("created_at");
merged_data_json.insert("updated_at".to_string(), Value::String(get_iso_date()));
let record: Option<MentorSchema> =
db.update(record_key).merge(merged_data_json).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_mentor' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success update mentor".into()),
None => {
bail!("Failed to update mentor")
}
}
}
#[instrument(skip(self, id), err)]
pub async fn query_delete_mentor(&self, id: String) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let thing = make_thing(ResourceEnum::Mentors.to_string().as_str(), &id);
let record_key = get_id(&thing)?;
let mentor_to_delete_res = self.query_mentor_by_id(&thing, true).await;
let _mentor_to_delete = match mentor_to_delete_res {
Ok(mentor) => {
if mentor.is_deleted {
bail!("Mentor is already soft deleted");
}
mentor
}
Err(e) => {
if e.to_string().contains("Mentor has been deleted") {
bail!("Mentor is already soft deleted");
} else {
return Err(e);
}
}
};
let mut patch = Map::new();
patch.insert("is_deleted".to_string(), Value::Bool(true));
patch.insert("updated_at".to_string(), Value::String(get_iso_date()));
let record: Option<MentorSchema> = db.update(record_key).merge(patch).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_mentor' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success soft delete mentor".into()),
None => {
bail!("Failed to soft delete mentor")
}
}
}
}
@@ -0,0 +1,250 @@
use super::{
IdentityAndVerification, MentorDetailQueryDto, MentorUpdateRequestDto,
MentoringLogistics, MentoringRate, ProfessionalProfile,
};
use imphnen_libs::ResourceEnum;
use imphnen_utils::{get_iso_date, make_thing};
use serde::{Deserialize, Serialize};
use surrealdb::{Uuid, sql::Thing};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MentorSchema {
pub id: Thing,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_id: Option<Thing>,
pub email: Option<String>,
pub legal_name: String,
pub gender: Option<String>,
pub domicile: Option<String>,
pub identity_document_url: String,
pub phone_for_verification: String,
pub bio: String,
pub last_education: Option<String>,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub portfolio_url: Option<String>,
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
pub current_company: String,
pub current_role: String,
pub years_of_experience: i32,
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
pub availability_commitment: String,
pub mentoring_rate: MentoringRate,
pub status: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
impl Default for MentorSchema {
fn default() -> Self {
Self {
id: make_thing(
ResourceEnum::Mentors.to_string().as_str(),
&Uuid::new_v4().to_string(),
),
user_id: Some(make_thing(
ResourceEnum::Users.to_string().as_str(),
&Uuid::new_v4().to_string(),
)),
email: None,
legal_name: String::new(),
gender: None,
domicile: None,
identity_document_url: String::new(),
phone_for_verification: String::new(),
bio: String::new(),
last_education: None,
linkedin_url: None,
github_url: None,
cv_url: None,
portfolio_url: None,
industries: Vec::new(),
expertise: Vec::new(),
languages: Vec::new(),
current_company: String::new(),
current_role: String::new(),
years_of_experience: 0,
topics_of_interest: Vec::new(),
preferred_mentee_level: Vec::new(),
preferred_mentoring_formats: Vec::new(),
availability_commitment: String::new(),
mentoring_rate: MentoringRate {
amount: 0,
currency: "IDR".to_string(),
per_duration: "hour".to_string(),
},
status: "pending".to_string(),
is_deleted: false,
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
}
impl MentorSchema {
pub fn create(
identity_and_verification: IdentityAndVerification,
professional_profile: ProfessionalProfile,
mentoring_logistics: MentoringLogistics,
user_id_raw: String,
email_str: String,
) -> Self {
Self {
id: make_thing(
&ResourceEnum::Mentors.to_string(),
&Uuid::new_v4().to_string(),
),
user_id: Some(make_thing(&ResourceEnum::Users.to_string(), &user_id_raw)),
email: Some(email_str),
legal_name: identity_and_verification.legal_name,
gender: identity_and_verification.gender,
domicile: identity_and_verification.domicile,
identity_document_url: identity_and_verification.identity_document_url,
phone_for_verification: identity_and_verification.phone_for_verification,
bio: professional_profile.bio,
last_education: professional_profile.last_education,
linkedin_url: professional_profile.linkedin_url,
github_url: professional_profile.github_url,
cv_url: professional_profile.cv_url,
portfolio_url: professional_profile.portfolio_url,
industries: professional_profile.industries,
expertise: professional_profile.expertise,
languages: professional_profile.languages,
current_company: professional_profile.current_company,
current_role: professional_profile.current_role,
years_of_experience: professional_profile.years_of_experience,
topics_of_interest: mentoring_logistics.topics_of_interest,
preferred_mentee_level: mentoring_logistics.preferred_mentee_level,
preferred_mentoring_formats: mentoring_logistics.preferred_mentoring_formats,
availability_commitment: mentoring_logistics.availability_commitment,
mentoring_rate: MentoringRate {
amount: mentoring_logistics.mentoring_rate_amount,
currency: "IDR".to_string(),
per_duration: "hour".to_string(),
},
status: "pending".to_string(),
is_deleted: false,
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
pub fn from(dto: MentorDetailQueryDto) -> Self {
Self {
id: dto.id,
user_id: Some(dto.user_id),
email: dto.email,
legal_name: dto.legal_name,
gender: dto.gender,
domicile: dto.domicile,
identity_document_url: dto.identity_document_url,
phone_for_verification: dto.phone_for_verification,
bio: dto.bio,
last_education: dto.last_education,
linkedin_url: dto.linkedin_url,
github_url: dto.github_url,
cv_url: dto.cv_url,
portfolio_url: dto.portfolio_url,
industries: dto.industries,
expertise: dto.expertise,
languages: dto.languages,
current_company: dto.current_company,
current_role: dto.current_role,
years_of_experience: dto.years_of_experience,
topics_of_interest: dto.topics_of_interest,
preferred_mentee_level: dto.preferred_mentee_level,
preferred_mentoring_formats: dto.preferred_mentoring_formats,
availability_commitment: dto.availability_commitment,
mentoring_rate: dto.mentoring_rate,
status: dto.status,
is_deleted: dto.is_deleted,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
pub fn update(mut self, dto: MentorUpdateRequestDto) -> Self {
// Update fields only if they are Some(value), otherwise preserve current value
if let Some(val) = dto.legal_name {
self.legal_name = val;
}
if let Some(val) = dto.gender {
self.gender = Some(val);
}
if let Some(val) = dto.domicile {
self.domicile = Some(val);
}
if let Some(val) = dto.identity_document_url {
self.identity_document_url = val;
}
if let Some(val) = dto.phone_for_verification {
self.phone_for_verification = val;
}
if let Some(val) = dto.bio {
self.bio = val;
}
if let Some(val) = dto.last_education {
self.last_education = Some(val);
}
if let Some(val) = dto.linkedin_url {
self.linkedin_url = Some(val);
}
if let Some(val) = dto.github_url {
self.github_url = Some(val);
}
if let Some(val) = dto.cv_url {
self.cv_url = Some(val);
}
if let Some(val) = dto.portfolio_url {
self.portfolio_url = Some(val);
}
if let Some(val) = dto.industries {
self.industries = val;
}
if let Some(val) = dto.expertise {
self.expertise = val;
}
if let Some(val) = dto.languages {
self.languages = val;
}
if let Some(val) = dto.current_company {
self.current_company = val;
}
if let Some(val) = dto.current_role {
self.current_role = val;
}
if let Some(val) = dto.years_of_experience {
self.years_of_experience = val;
}
if let Some(val) = dto.topics_of_interest {
self.topics_of_interest = val;
}
if let Some(val) = dto.preferred_mentee_level {
self.preferred_mentee_level = val;
}
if let Some(val) = dto.preferred_mentoring_formats {
self.preferred_mentoring_formats = val;
}
if let Some(val) = dto.availability_commitment {
self.availability_commitment = val;
}
if let Some(val) = dto.mentoring_rate_amount {
self.mentoring_rate.amount = val;
}
self.updated_at = get_iso_date();
self
}
pub fn update_status(mut self, status: String) -> Self {
self.status = status;
self.updated_at = get_iso_date();
self
}
}
@@ -0,0 +1,386 @@
use crate::v1::mentors::{
MentorDetailQueryDto, MentorDetailResponseDto, MentorListResponseDto,
MentorRegisterResponseDto, MentorSchema, MentorUpdateRequestDto,
MentorUserRegisterRequestDto, MentorVerifyRequestDto, MentorsRepository,
};
use axum::http::StatusCode;
use axum::response::Response;
use imphnen_entities::{
AppState, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto,
};
use imphnen_iam::{
AuthRepository, RolesEnum, RolesRepository, UsersRepository, UsersSchema,
};
use imphnen_libs::ResourceEnum;
use imphnen_utils::{
common_response, success_list_response, success_response, validate_request,
};
use surrealdb::Uuid;
use surrealdb::sql::Thing;
use tracing::error;
pub struct MentorsService;
impl MentorsService {
pub async fn register_mentor(
state: &AppState,
dto: MentorUserRegisterRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&dto) {
return common_response(status, &message);
}
let user_repo = UsersRepository::new(state);
let mentor_repo = MentorsRepository::new(state);
let role_repo = RolesRepository::new(state);
let auth_repo = AuthRepository::new(state);
let user_email = dto.email.clone();
let mut _user_to_update: Option<UsersSchema> = None;
let existing_user_result =
user_repo.query_user_by_email(user_email.clone()).await;
let user_id;
let final_user_email = user_email.clone();
if let Ok(user_detail_query_dto) = existing_user_result {
if mentor_repo
.query_mentor_by_email(user_email.clone(), false)
.await
.is_ok()
{
return common_response(
StatusCode::CONFLICT,
"Mentor profile already exists for this user",
);
}
let mut user_schema = UsersSchema::from(user_detail_query_dto.clone());
user_schema.fullname = dto.fullname.clone();
user_schema.phone_number = dto.phone_number.clone();
user_schema.updated_at = imphnen_utils::get_iso_date();
let hashed_password = match imphnen_utils::hash_password(&dto.password) {
Ok(hash) => hash,
Err(_e) => {
error!(
"Failed to hash password during update for {}: {}",
final_user_email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to hash password",
);
}
};
user_schema.password = hashed_password;
let mentor_role = match role_repo
.query_role_by_name(RolesEnum::Mentor.to_string())
.await
{
Ok(role) => role,
Err(_e) => {
return common_response(StatusCode::BAD_REQUEST, "Mentor Role Not Found");
}
};
user_schema.role =
imphnen_utils::make_thing(&ResourceEnum::Roles.to_string(), &mentor_role.id);
user_schema.is_active = false;
if let Err(_err) = user_repo.query_update_user(user_schema.clone()).await {
error!(
"Failed to update existing user {} to mentor role: {}",
final_user_email, _err
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&_err.to_string(),
);
}
user_id = user_schema.id.clone();
} else {
let mentor_role = match role_repo
.query_role_by_name(RolesEnum::Mentor.to_string())
.await
{
Ok(role) => role,
Err(_e) => {
return common_response(StatusCode::BAD_REQUEST, "Mentor Role Not Found");
}
};
let hashed_password = match imphnen_utils::hash_password(&dto.password) {
Ok(hash) => hash,
Err(_e) => {
error!(
"Failed to hash password for new user {}: {}",
final_user_email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to hash password",
);
}
};
let new_user_schema = UsersSchema {
id: imphnen_utils::make_thing(
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
),
email: dto.email.clone(),
fullname: dto.fullname.clone(),
password: hashed_password,
phone_number: dto.phone_number.clone(),
created_at: imphnen_utils::get_iso_date(),
updated_at: imphnen_utils::get_iso_date(),
role: imphnen_utils::make_thing(
&ResourceEnum::Roles.to_string(),
&mentor_role.id,
),
is_active: false,
..Default::default()
};
user_id = new_user_schema.id.clone();
match user_repo.query_create_user(new_user_schema).await {
Ok(_) => {}
Err(_err) => {
error!("Failed to create new user {}: {}", final_user_email, _err);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&_err.to_string(),
);
}
}
}
let otp = imphnen_utils::generate_otp::OtpManager::generate_otp();
match auth_repo
.query_store_otp(final_user_email.clone(), otp)
.await
{
Ok(_) => {
let message = format!("your otp code is {otp}");
if let Err(_err) =
imphnen_utils::send_email(&final_user_email, "OTP Verification", &message)
{
error!("Failed to send OTP email to {}: {}", final_user_email, _err);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&_err.to_string(),
);
}
}
Err(_err) => {
error!("Failed to store OTP for {}: {}", final_user_email, _err);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&_err.to_string(),
);
}
}
let mentor_schema = MentorSchema::create(
dto.identity_and_verification,
dto.professional_profile,
dto.mentoring_logistics,
user_id.to_raw(),
final_user_email.clone(),
);
match mentor_repo.query_create_mentor(mentor_schema.clone()).await {
Ok(mentor_profile_id) => {
let user_after_mentor_creation_dto = user_repo
.query_user_by_email(final_user_email.clone())
.await
.unwrap();
let mut user_after_mentor_creation_schema =
UsersSchema::from(user_after_mentor_creation_dto);
user_after_mentor_creation_schema = user_after_mentor_creation_schema
.update_mentor_id(Some(mentor_profile_id));
if let Err(_e) = user_repo
.query_update_user(user_after_mentor_creation_schema)
.await
{
error!(
"Failed to update user's mentor_id for {}: {}",
final_user_email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&_e.to_string(),
);
}
let response_dto = MentorRegisterResponseDto::from(mentor_schema);
success_response(ResponseSuccessDto { data: response_dto })
}
Err(_e) => {
error!(
"Failed to create mentor profile for {}: {}",
final_user_email, _e
);
common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string())
}
}
}
pub async fn get_mentor_list(state: &AppState, meta: MetaRequestDto) -> Response {
let repo = MentorsRepository::new(state);
match repo.query_mentor_list(meta).await {
Ok(result) => {
let data: Vec<MentorListResponseDto> = result
.data
.into_iter()
.map(MentorDetailQueryDto::from)
.map(MentorListResponseDto::from)
.collect();
success_list_response(ResponseListSuccessDto {
data,
meta: result.meta,
})
}
Err(_e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string()),
}
}
pub async fn get_mentor_by_id(state: &AppState, id: &str) -> Response {
let repo = MentorsRepository::new(state);
let thing_id = Thing::from((ResourceEnum::Mentors.to_string().as_str(), id));
match repo.query_mentor_by_id(&thing_id, false).await {
Ok(mentor) => {
let dto = MentorDetailResponseDto::from(MentorDetailQueryDto::from(mentor));
success_response(ResponseSuccessDto { data: dto })
}
Err(_e) => common_response(StatusCode::NOT_FOUND, &_e.to_string()),
}
}
pub async fn update_mentor(
state: &AppState,
id: &str,
dto: MentorUpdateRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&dto) {
return common_response(status, &message);
}
let repo = MentorsRepository::new(state);
let thing_id = Thing::from((ResourceEnum::Mentors.to_string().as_str(), id));
let existing_mentor = match repo.query_mentor_by_id(&thing_id, false).await {
Ok(mentor) => mentor,
Err(_e) => return common_response(StatusCode::NOT_FOUND, &_e.to_string()),
};
let mut schema = MentorSchema::from(MentorDetailQueryDto::from(existing_mentor));
schema = schema.update(dto);
match repo.query_update_mentor(schema).await {
Ok(_) => {
let updated_mentor =
repo.query_mentor_by_id(&thing_id, false).await.unwrap();
let response_dto =
MentorDetailResponseDto::from(MentorDetailQueryDto::from(updated_mentor));
success_response(ResponseSuccessDto { data: response_dto })
}
Err(_e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string()),
}
}
pub async fn delete_mentor(state: &AppState, id: &str) -> Response {
let repo = MentorsRepository::new(state);
match repo.query_delete_mentor(id.to_string()).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(_e) => common_response(StatusCode::NOT_FOUND, &_e.to_string()),
}
}
pub async fn get_mentor_me(state: &AppState, email: &str) -> Response {
let repo = MentorsRepository::new(state);
match repo.query_mentor_by_email(email.to_string(), false).await {
Ok(mentor) => {
let dto = MentorDetailResponseDto::from(MentorDetailQueryDto::from(mentor));
success_response(ResponseSuccessDto { data: dto })
}
Err(_e) => common_response(
StatusCode::FORBIDDEN,
"Mentor profile not found for current user",
),
}
}
pub async fn update_mentor_me(
state: &AppState,
email: &str,
dto: MentorUpdateRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&dto) {
return common_response(status, &message);
}
let repo = MentorsRepository::new(state);
let existing_mentor =
match repo.query_mentor_by_email(email.to_string(), false).await {
Ok(mentor) => mentor,
Err(_e) => return common_response(StatusCode::FORBIDDEN, &_e.to_string()),
};
let mut schema = MentorSchema::from(MentorDetailQueryDto::from(existing_mentor));
schema = schema.update(dto);
match repo.query_update_mentor(schema).await {
Ok(_) => {
let updated_mentor = repo
.query_mentor_by_email(email.to_string(), false)
.await
.unwrap();
let response_dto =
MentorDetailResponseDto::from(MentorDetailQueryDto::from(updated_mentor));
success_response(ResponseSuccessDto { data: response_dto })
}
Err(_e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string()),
}
}
pub async fn get_mentor_status(state: &AppState, email: &str) -> Response {
let repo = MentorsRepository::new(state);
match repo.query_mentor_by_email(email.to_string(), false).await {
Ok(mentor) => common_response(StatusCode::OK, &mentor.status),
Err(_e) => common_response(
StatusCode::FORBIDDEN,
"No mentor application found for current user",
),
}
}
pub async fn verify_mentor(
state: &AppState,
id: &str,
dto: MentorVerifyRequestDto,
) -> Response {
let repo = MentorsRepository::new(state);
let thing_id = Thing::from((ResourceEnum::Mentors.to_string().as_str(), id));
let existing_mentor = match repo.query_mentor_by_id(&thing_id, false).await {
Ok(mentor) => mentor,
Err(_e) => return common_response(StatusCode::NOT_FOUND, &_e.to_string()),
};
let mut schema = MentorSchema::from(MentorDetailQueryDto::from(existing_mentor));
schema = schema.update_status(dto.status);
match repo.query_update_mentor(schema).await {
Ok(_) => {
let updated_mentor =
repo.query_mentor_by_id(&thing_id, false).await.unwrap();
let response_dto =
MentorDetailResponseDto::from(MentorDetailQueryDto::from(updated_mentor));
success_response(ResponseSuccessDto { data: response_dto })
}
Err(_e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string()),
}
}
}
+30
View File
@@ -0,0 +1,30 @@
use axum::{
Router,
routing::{delete, get, post, put},
};
pub mod mentors_controller;
pub mod mentors_dto;
pub mod mentors_repository;
pub mod mentors_schema;
pub mod mentors_service;
pub use mentors_controller::*;
pub use mentors_dto::*;
pub use mentors_repository::*;
pub use mentors_schema::*;
pub use mentors_service::*;
pub fn mentors_router() -> Router {
Router::new()
.route("/", get(get_mentor_list))
.route("/register", post(post_register_mentor))
.route("/me", get(get_mentor_me))
.route("/update/me", put(put_update_mentor_me))
.route("/status", get(get_mentor_status))
.route("/detail/{id}", get(get_mentor_by_id))
.route("/update/{id}", put(put_update_mentor))
.route("/update", put(put_update_mentor_no_id))
.route("/delete/{id}", delete(delete_mentor))
.route("/verify/{id}", put(put(put_verify_mentor)))
}
+7
View File
@@ -0,0 +1,7 @@
use axum::Router;
pub mod mentors;
pub fn dimentorin_router() -> Router {
Router::new().nest("/mentors", mentors::mentors_router())
}
+1
View File
@@ -0,0 +1 @@
+1 -5
View File
@@ -1,9 +1,5 @@
use serde::{Deserialize, Serialize};
use surrealdb::{
engine::local::Db,
engine::any::Any,
Surreal,
};
use surrealdb::{Surreal, engine::any::Any, engine::local::Db};
use utoipa::{IntoParams, ToSchema};
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+11 -6
View File
@@ -1,26 +1,31 @@
pub mod error {
use axum::Json;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Response;
use axum::Json;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("database error")]
Db,
#[error("database error: {0}")]
Db(String),
}
impl IntoResponse for Error {
fn into_response(self) -> Response {
(StatusCode::INTERNAL_SERVER_ERROR, Json(self.to_string())).into_response()
let (status, error_message) = match self {
Error::Db(detail) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {detail}"),
),
};
(status, Json(error_message)).into_response()
}
}
impl From<surrealdb::Error> for Error {
fn from(error: surrealdb::Error) -> Self {
eprintln!("{error}");
Self::Db
Self::Db(error.to_string())
}
}
}
+7 -5
View File
@@ -4,10 +4,10 @@ version = "0.1.0"
edition = "2024"
[dependencies]
imphnen-iam ={ version = "0.1.0", path = "../imphnen-iam" }
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" }
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" }
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" }
imphnen-iam.workspace = true
imphnen-libs.workspace = true
imphnen-utils.workspace = true
imphnen-entities.workspace = true
axum.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -23,4 +23,6 @@ chrono.workspace = true
anyhow.workspace = true
tower-http.workspace = true
utoipa-swagger-ui.workspace = true
rand_distr.workspace = true
rand_distr.workspace = true
log.workspace = true
tracing.workspace = true
-4
View File
@@ -1,7 +1,3 @@
use imphnen_entities::*;
use imphnen_libs::*;
use imphnen_utils::*;
pub mod v1;
pub use imphnen_entities::*;
@@ -2,6 +2,8 @@ use super::{GachaClaimQueryDto, GachaClaimSchema};
use crate::{AppState, ResourceEnum};
use anyhow::{Result, bail};
use imphnen_iam::DetailQueryBuilder;
use std::time::Instant;
use tracing::instrument;
pub struct GachaClaimRepository<'a> {
state: &'a AppState,
@@ -12,10 +14,12 @@ impl<'a> GachaClaimRepository<'a> {
Self { state }
}
#[instrument(skip(self, id), err)]
pub async fn query_gacha_claim_by_id(
&self,
id: String,
) -> Result<GachaClaimQueryDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::GachaClaims.to_string())
.with_id(id.clone())
@@ -25,21 +29,35 @@ impl<'a> GachaClaimRepository<'a> {
let sql = builder.build();
let result: Option<GachaClaimQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_gacha_claim_by_id' took: {elapsed:.2?}");
}
match result {
Some(claim) if !claim.is_deleted => Ok(claim),
_ => bail!("Gacha Claim not found"),
}
}
#[instrument(skip(self, data), err)]
pub async fn query_create_gacha_claim(
&self,
data: GachaClaimSchema,
) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let record: Option<GachaClaimSchema> = db
.create(ResourceEnum::GachaClaims.to_string())
.content(data)
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_gacha_claim' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success create Gacha Claim".into()),
None => bail!("Failed to create Gacha Claim"),
@@ -2,7 +2,9 @@ use super::{GachaCreditRequestDto, GachaCreditSchema};
use crate::{AppState, ResourceEnum};
use anyhow::{Result, bail};
use imphnen_iam::make_thing;
use std::time::Instant;
use surrealdb::Uuid;
use tracing::instrument;
pub struct GachaCreditRepository<'a> {
state: &'a AppState,
@@ -13,28 +15,54 @@ impl<'a> GachaCreditRepository<'a> {
Self { state }
}
#[instrument(skip(self, user_id), err)]
pub async fn query_by_user_id(
&self,
user_id: String,
) -> Result<Option<GachaCreditSchema>> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let sql = format!(
"SELECT * FROM {} WHERE user = {}:⟨$user_id⟩ AND is_deleted = false LIMIT 1",
ResourceEnum::GachaCredits.to_string(),
ResourceEnum::Users.to_string()
ResourceEnum::GachaCredits,
ResourceEnum::Users
);
let result: Vec<GachaCreditSchema> =
db.query(sql).bind(("user_id", user_id)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_by_user_id' took: {elapsed:.2?}");
}
Ok(result.into_iter().next())
}
#[instrument(skip(self, user_id), err)]
pub async fn query_consume_credit(&self, user_id: String) -> Result<()> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let credit_opt = self.query_by_user_id(user_id).await?;
let Some(mut credit) = credit_opt else {
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!(
"Query 'query_consume_credit' took: {elapsed:.2?} (no credit to consume)"
);
}
return Ok(());
};
if credit.available_rolls <= 0 {
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!(
"Query 'query_consume_credit' took: {elapsed:.2?} (no rolls remaining)"
);
}
bail!("No extra roll credits remaining");
}
credit.available_rolls -= 1;
@@ -45,13 +73,21 @@ impl<'a> GachaCreditRepository<'a> {
))
.merge(credit)
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_consume_credit' took: {elapsed:.2?}");
}
Ok(())
}
#[instrument(skip(self, payload), err)]
pub async fn query_add_credit(
&self,
payload: GachaCreditRequestDto,
) -> Result<()> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
if let Some(mut credit) = self.query_by_user_id(payload.user_id.clone()).await? {
credit.available_rolls += payload.amount;
@@ -63,7 +99,7 @@ impl<'a> GachaCreditRepository<'a> {
.merge(credit)
.await?;
} else {
let data = GachaCreditSchema::from(&GachaCreditSchema {
let data = GachaCreditSchema {
id: make_thing(
&ResourceEnum::GachaCredits.to_string(),
&Uuid::new_v4().to_string(),
@@ -71,12 +107,18 @@ impl<'a> GachaCreditRepository<'a> {
user: make_thing(&ResourceEnum::Users.to_string(), &payload.user_id),
available_rolls: payload.amount,
..Default::default()
});
};
let _: Option<GachaCreditSchema> = db
.create(&ResourceEnum::GachaCredits.to_string())
.create(ResourceEnum::GachaCredits.to_string())
.content(data)
.await?;
}
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_add_credit' took: {elapsed:.2?}");
}
Ok(())
}
}
@@ -5,6 +5,10 @@ use crate::{
};
use anyhow::{Result, bail};
use imphnen_iam::QueryListBuilder;
use imphnen_utils::get_iso_date;
use serde_json::{Map, Value};
use std::time::Instant;
use tracing::instrument;
pub struct GachaItemRepository<'a> {
state: &'a AppState,
@@ -15,10 +19,12 @@ impl<'a> GachaItemRepository<'a> {
Self { state }
}
#[instrument(skip(self, meta), err)]
pub async fn query_gacha_item_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<GachaItemDto>>> {
let now = Instant::now();
let raw_result: ResponseListSuccessDto<Vec<GachaItemSchema>> =
QueryListBuilder::new(
&self.state.surrealdb_ws,
@@ -30,6 +36,12 @@ impl<'a> GachaItemRepository<'a> {
.select_fields(vec!["*"])
.build()
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_gacha_item_list' took: {elapsed:.2?}");
}
let data = raw_result
.data
.into_iter()
@@ -41,36 +53,54 @@ impl<'a> GachaItemRepository<'a> {
})
}
#[instrument(skip(self, id), err)]
pub async fn query_gacha_item_by_id(&self, id: String) -> Result<GachaItemSchema> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let result: Option<GachaItemSchema> = db
.select((ResourceEnum::GachaItems.to_string(), id.clone()))
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_gacha_item_by_id' took: {elapsed:.2?}");
}
match result {
Some(item) if !item.is_deleted => Ok(item),
_ => bail!("Gacha Item not found"),
}
}
#[instrument(skip(self, data), err)]
pub async fn query_create_gacha_item(
&self,
data: GachaItemSchema,
) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let record: Option<GachaItemSchema> = db
.create(ResourceEnum::GachaItems.to_string())
.content(data)
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_gacha_item' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success create Gacha Item".into()),
None => bail!("Failed to create Gacha Item"),
}
}
#[instrument(skip(self, data), err)]
pub async fn query_update_gacha_item(
&self,
data: GachaItemSchema,
) -> Result<String> {
let now = Instant::now();
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?;
@@ -83,13 +113,21 @@ impl<'a> GachaItemRepository<'a> {
};
let record: Option<GachaItemSchema> =
db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_gacha_item' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success update Gacha Item".into()),
None => bail!("Failed to update Gacha Item"),
}
}
#[instrument(skip(self, id), err)]
pub async fn query_delete_gacha_item(&self, id: String) -> Result<String> {
let now = Instant::now();
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?;
@@ -97,13 +135,20 @@ impl<'a> GachaItemRepository<'a> {
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?;
let mut patch = Map::new();
patch.insert("is_deleted".to_string(), Value::Bool(true));
patch.insert("updated_at".to_string(), Value::String(get_iso_date()));
let record: Option<GachaItemSchema> = db.update(record_key).merge(patch).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_gacha_item' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success delete Gacha Item".into()),
None => bail!("Failed to delete Gacha Item"),
Some(_) => Ok("Success soft delete Gacha Item".into()),
None => bail!("Failed to soft delete Gacha Item"),
}
}
}
@@ -45,7 +45,12 @@ impl GachaItemService {
return common_response(status, &message);
}
let repo = GachaItemRepository::new(state);
let schema = GachaItemSchema::from(payload);
let schema = GachaItemSchema {
id: make_thing(&ResourceEnum::GachaItems.to_string(), &payload.name), // Fixed: Use payload.name or some other identifier
name: payload.name,
image_url: payload.image_url,
..Default::default()
};
match repo.query_create_gacha_item(schema).await {
Ok(msg) => common_response(StatusCode::CREATED, &msg),
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
@@ -91,3 +91,32 @@ pub async fn post_execute_gacha_roll(
Err(response) => response,
}
}
#[utoipa::path(
delete,
path = "/v1/gacha/rolls/delete/{id}",
security(
("Bearer" = [])
),
params(("id" = String, Path, description = "Gacha Roll ID")),
responses(
(status = 200, description = "Delete Gacha Roll (soft delete)", body = MessageResponseDto)
),
tag = "Gacha"
)]
pub async fn delete_gacha_roll(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::DeleteGachaRolls],
)
.await
{
Ok(_) => GachaRollService::soft_delete_gacha_roll(&state, id).await,
Err(response) => response,
}
}
@@ -29,7 +29,7 @@ impl GachaRollItemDto {
Self {
id: dto.id.id.to_raw(),
item: GachaItemDto::from(dto.item.clone()),
weight: dto.weight.clone(),
weight: dto.weight,
quantity: dto.quantity,
is_deleted: dto.is_deleted,
created_at: dto.created_at.clone(),
@@ -1,11 +1,15 @@
use super::GachaRollQueryDto;
use super::GachaRollSchema;
use crate::{AppState, DetailQueryBuilder, ResourceEnum};
use crate::{AppState, DetailQueryBuilder, ResourceEnum, get_id, make_thing};
use anyhow::{Result, bail};
use imphnen_iam::ListQueryBuilder;
use rand::prelude::*;
use rand::rng;
use imphnen_utils::get_iso_date;
use rand_distr::weighted::WeightedIndex;
use serde_json::{Map, Value};
use std::time::Instant;
use tracing::instrument;
pub struct GachaRollRepository<'a> {
state: &'a AppState,
@@ -16,46 +20,70 @@ impl<'a> GachaRollRepository<'a> {
Self { state }
}
#[instrument(skip(self, id), err)]
pub async fn query_gacha_roll_by_id(
&self,
id: String,
) -> Result<GachaRollQueryDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::GachaRolls.to_string())
.with_id(id.clone())
.with_condition("is_deleted = false")
.with_select_fields(vec!["*"])
.with_fetch("item");
let sql = builder.build();
let result: Option<GachaRollQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_gacha_roll_by_id' took: {elapsed:.2?}");
}
match result {
Some(roll) if !roll.is_deleted => Ok(roll),
_ => bail!("Gacha Roll not found"),
}
}
#[instrument(skip(self, data), err)]
pub async fn query_create_gacha_roll(
&self,
data: GachaRollSchema,
) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let record: Option<GachaRollSchema> = db
.create(ResourceEnum::GachaRolls.to_string())
.content(data)
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_gacha_roll' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success create Gacha Roll".into()),
None => bail!("Failed to create Gacha Roll"),
}
}
#[instrument(skip(self), err)]
pub async fn query_all_active_rolls(&self) -> Result<Vec<GachaRollQueryDto>> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let builder = ListQueryBuilder::new(ResourceEnum::GachaRolls.to_string())
.with_select_fields(vec!["*"])
.with_fetch(Some(vec!["item"]));
let sql = builder.build();
let table_name = ResourceEnum::GachaRolls.to_string();
let sql =
format!("SELECT * FROM {table_name} WHERE is_deleted = false FETCH item");
let result: Vec<GachaRollQueryDto> = db.query(sql).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_all_active_rolls' took: {elapsed:.2?}");
}
Ok(result)
}
@@ -72,8 +100,36 @@ impl<'a> GachaRollRepository<'a> {
return None;
}
let dist = WeightedIndex::new(&weights).ok()?;
let mut rng = rng();
let mut rng = rand::rngs::ThreadRng::default();
let index = dist.sample(&mut rng);
Some(filtered[index].clone())
}
#[instrument(skip(self, id), err)]
pub async fn query_soft_delete_gacha_roll(&self, id: String) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let roll_id_thing = make_thing(&ResourceEnum::GachaRolls.to_string(), &id);
let roll = self.query_gacha_roll_by_id(id.clone()).await?;
if roll.is_deleted {
bail!("Gacha Roll already deleted");
}
let record_key = get_id(&roll_id_thing)?;
let mut patch = Map::new();
patch.insert("is_deleted".to_string(), Value::Bool(true));
patch.insert("updated_at".to_string(), Value::String(get_iso_date()));
let record: Option<GachaRollSchema> = db.update(record_key).merge(patch).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_soft_delete_gacha_roll' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success soft delete Gacha Roll".into()),
None => bail!("Failed to soft delete Gacha Roll"),
}
}
}
@@ -63,4 +63,12 @@ impl GachaRollService {
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn soft_delete_gacha_roll(state: &AppState, id: String) -> Response {
let repo = GachaRollRepository::new(state);
match repo.query_soft_delete_gacha_roll(id).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
}
}
}
+8 -7
View File
@@ -4,13 +4,14 @@ version = "0.1.0"
edition = "2024"
[dependencies]
imphnen-iam = { version = "0.1.0", path = "../imphnen-iam" }
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" }
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" }
imphnen-gacha = { version = "0.1.0", path = "../imphnen-gacha" }
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" }
imphnen-middleware = { version = "0.1.0", path = "../imphnen-middleware" }
imphnen-cms = { version = "0.1.0", path = "../imphnen-cms" }
imphnen-iam.workspace = true
imphnen-libs.workspace = true
imphnen-utils.workspace = true
imphnen-gacha.workspace = true
imphnen-entities.workspace = true
imphnen-middleware.workspace = true
imphnen-cms.workspace = true
imphnen-dimentorin.workspace = true
axum.workspace = true
serde.workspace = true
serde_json.workspace = true
+79 -23
View File
@@ -1,18 +1,44 @@
use imphnen_cms::{
events_controller,
events_dto::{EventsDetailItemDto, EventsListItemDto},
testimonials_controller,
testimonials_dto::{
TestimonialsCreateRequestDto, TestimonialsDetailItemDto,
TestimonialsListItemDto, TestimonialsUpdateRequestDto,
},
};
use imphnen_dimentorin::v1::mentors::{
mentors_controller,
mentors_dto::{
IdentityAndVerification, MentorDetailResponseDto, MentorListResponseDto,
MentorRegisterFromTokenRequestDto, MentorRegisterResponseDto,
MentorUpdateRequestDto, MentorUserRegisterRequestDto, MentorVerifyRequestDto,
MentoringLogistics, MentoringRate, ProfessionalProfile,
},
};
use imphnen_gacha::{
GachaClaimItemDto, GachaClaimRequestDto, GachaItemDto, GachaItemRequestDto,
GachaRollItemDto, GachaRollRequestDto, gacha_claims, gacha_items, gacha_rolls,
};
use imphnen_iam::{
auth, permissions, roles, users, AuthLoginRequestDto, AuthLoginResponsetDto, AuthNewPasswordRequestDto, AuthRefreshTokenRequestDto, AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, MessageResponseDto, MetaRequestDto, MetaResponseDto, PermissionsItemDto, PermissionsRequestDto, ResponseListSuccessDto, ResponseSuccessDto, RolesDetailItemDto, RolesListItemDto, RolesRequestCreateDto, RolesRequestUpdateDto, TokenDto, UsersCreateRequestDto, UsersDetailItemDto, UsersListItemDto, UsersUpdateRequestDto
AuthLoginRequestDto, AuthLoginResponsetDto, AuthNewPasswordRequestDto,
AuthRefreshTokenRequestDto, AuthResendOtpRequestDto, AuthVerifyEmailRequestDto,
MessageResponseDto, MetaRequestDto, MetaResponseDto, PermissionsItemDto,
PermissionsRequestDto, ResponseListSuccessDto, ResponseSuccessDto,
RolesDetailItemDto, RolesListItemDto, RolesRequestCreateDto,
RolesRequestUpdateDto, TokenDto, UsersCreateRequestDto, UsersDetailItemDto,
UsersListItemDto, UsersUpdateRequestDto, auth, permissions, roles, users,
};
use utoipa::{
openapi::security::{Http, HttpAuthScheme, SecurityScheme},
Modify, OpenApi,
openapi::security::{Http, HttpAuthScheme, SecurityScheme},
};
use imphnen_gacha::{gacha_claims, gacha_items, gacha_rolls, GachaClaimItemDto, GachaClaimRequestDto, GachaItemDto, GachaItemRequestDto, GachaRollItemDto, GachaRollRequestDto};
use imphnen_cms::{events_controller, events_dto::{EventsDetailItemDto, EventsListItemDto}, testimonials_controller, testimonials_dto::{TestimonialsCreateRequestDto, TestimonialsDetailItemDto, TestimonialsListItemDto, TestimonialsUpdateRequestDto}};
#[derive(OpenApi)]
#[openapi(
paths(
auth::auth_controller::post_login,
auth::auth_controller::post_login_mentor,
auth::auth_controller::post_register,
auth::auth_controller::post_verify_email,
auth::auth_controller::post_resend_otp,
@@ -47,16 +73,25 @@ use imphnen_cms::{events_controller, events_dto::{EventsDetailItemDto, EventsLis
gacha_rolls::get_detail_gacha_roll,
gacha_rolls::post_create_gacha_roll,
gacha_rolls::post_execute_gacha_roll,
events_controller::get_event_list,
events_controller::get_event_by_id,
events_controller::post_create_event,
events_controller::patch_update_event,
events_controller::delete_event,
testimonials_controller::get_testimonial_list,
testimonials_controller::get_testimonial_by_id,
testimonials_controller::post_create_testimonial,
testimonials_controller::patch_update_testimonial,
testimonials_controller::delete_testimonial,
events_controller::get_event_list,
events_controller::get_event_by_id,
events_controller::post_create_event,
events_controller::patch_update_event,
events_controller::delete_event,
testimonials_controller::get_testimonial_list,
testimonials_controller::get_testimonial_by_id,
testimonials_controller::post_create_testimonial,
testimonials_controller::patch_update_testimonial,
testimonials_controller::delete_testimonial,
mentors_controller::get_mentor_list,
mentors_controller::get_mentor_by_id,
mentors_controller::post_register_mentor,
mentors_controller::get_mentor_me,
mentors_controller::put_update_mentor_me,
mentors_controller::get_mentor_status,
mentors_controller::put_update_mentor,
mentors_controller::put_verify_mentor,
mentors_controller::delete_mentor,
),
components(
schemas(
@@ -71,7 +106,7 @@ use imphnen_cms::{events_controller, events_dto::{EventsDetailItemDto, EventsLis
AuthRefreshTokenRequestDto,
ResponseSuccessDto<TokenDto>,
RolesListItemDto,
RolesRequestCreateDto,
RolesRequestCreateDto,
RolesRequestUpdateDto,
PermissionsRequestDto,
PermissionsItemDto,
@@ -96,13 +131,26 @@ use imphnen_cms::{events_controller, events_dto::{EventsDetailItemDto, EventsLis
ResponseSuccessDto<UsersDetailItemDto>,
ResponseListSuccessDto<Vec<PermissionsItemDto>>,
ResponseSuccessDto<PermissionsItemDto>,
ResponseListSuccessDto<Vec<EventsListItemDto>>,
ResponseSuccessDto<EventsDetailItemDto>,
ResponseListSuccessDto<Vec<TestimonialsListItemDto>>,
ResponseSuccessDto<TestimonialsDetailItemDto>,
TestimonialsCreateRequestDto,
TestimonialsUpdateRequestDto,
MessageResponseDto,
ResponseListSuccessDto<Vec<EventsListItemDto>>,
ResponseSuccessDto<EventsDetailItemDto>,
ResponseListSuccessDto<Vec<TestimonialsListItemDto>>,
ResponseSuccessDto<TestimonialsDetailItemDto>,
TestimonialsCreateRequestDto,
TestimonialsUpdateRequestDto,
MentorUserRegisterRequestDto,
MentorRegisterFromTokenRequestDto,
MentorRegisterResponseDto,
MentorListResponseDto,
MentorDetailResponseDto,
MentorUpdateRequestDto,
MentorVerifyRequestDto,
IdentityAndVerification,
ProfessionalProfile,
MentoringLogistics,
MentoringRate,
ResponseListSuccessDto<Vec<MentorListResponseDto>>,
ResponseSuccessDto<MentorDetailResponseDto>,
ResponseSuccessDto<MentorRegisterResponseDto>,
)
),
info(
@@ -121,6 +169,14 @@ use imphnen_cms::{events_controller, events_dto::{EventsDetailItemDto, EventsLis
modifiers(&SecurityAddon),
tags(
(name = "Authentication", description = "List of Authentication Endpoints"),
(name = "Users", description = "User Management Endpoints"),
(name = "Roles", description = "Role Management Endpoints"),
(name = "Permissions", description = "Permission Management Endpoints"),
(name = "Events", description = "Event Management Endpoints"),
(name = "Testimonials", description = "Testimonial Management Endpoints"),
(name = "Mentors", description = "Mentor Management Endpoints"),
(name = "Mentors - Admin", description = "Mentor Admin Management Endpoints (Admin Access Required)"),
(name = "Gacha", description = "Gacha System Endpoints"),
)
)]
+2
View File
@@ -5,6 +5,7 @@ use imphnen_cms::{
events_protected_routes, events_public_routes, testimonials_protected_routes,
testimonials_public_routes,
};
use imphnen_dimentorin::dimentorin_router;
use imphnen_entities::{AppState, SurrealMemClient, SurrealWsClient};
use imphnen_gacha::gacha_router;
use imphnen_iam::{iam_protected_routes, iam_public_routes};
@@ -32,6 +33,7 @@ pub async fn gateway_service(
.merge(iam_protected_routes())
.merge(events_protected_routes())
.merge(testimonials_protected_routes())
.merge(dimentorin_router())
.merge(gacha_router())
.layer(from_fn(auth_middleware));
+13 -3
View File
@@ -4,9 +4,9 @@ version = "0.1.0"
edition = "2024"
[dependencies]
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" }
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" }
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" }
imphnen-libs.workspace = true
imphnen-utils.workspace = true
imphnen-entities.workspace = true
axum.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -22,3 +22,13 @@ chrono.workspace = true
anyhow.workspace = true
tower-http.workspace = true
utoipa-swagger-ui.workspace = true
strum.workspace = true
strum_macros.workspace = true
log.workspace = true
once_cell.workspace = true
tracing.workspace = true
uuid.workspace = true
[dev-dependencies]
dotenvy.workspace = true
http-body-util.workspace = true
-28
View File
@@ -1,34 +1,6 @@
use ::surrealdb::Uuid;
use imphnen_entities::*;
use imphnen_libs::*;
use imphnen_utils::*;
pub mod v1;
pub use imphnen_entities::*;
pub use imphnen_libs::*;
pub use imphnen_utils::*;
pub use v1::*;
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),
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
+20 -2
View File
@@ -2,9 +2,9 @@ use super::{
AuthLoginRequestDto, AuthRefreshTokenRequestDto, AuthRegisterRequestDto,
AuthResendOtpRequestDto, AuthService, AuthVerifyEmailRequestDto,
};
use crate::{v1::AuthLoginResponsetDto, AppState};
use crate::{AppState, v1::AuthLoginResponsetDto};
use crate::{AuthNewPasswordRequestDto, MessageResponseDto, ResponseSuccessDto};
use axum::{response::IntoResponse, Extension, Json};
use axum::{Extension, Json, response::IntoResponse};
#[utoipa::path(
post,
@@ -23,6 +23,24 @@ pub async fn post_login(
AuthService::mutation_login(payload, &state).await
}
#[utoipa::path(
post,
path = "/v1/auth/login-mentor",
request_body = AuthLoginRequestDto,
responses(
(status = 200, description = "Mentor login successful", body = ResponseSuccessDto<AuthLoginResponsetDto>),
(status = 401, description = "Mentor login failed", body = MessageResponseDto),
(status = 403, description = "Forbidden - Not a mentor", body = MessageResponseDto)
),
tag = "Authentication"
)]
pub async fn post_login_mentor(
Extension(state): Extension<AppState>,
Json(payload): Json<AuthLoginRequestDto>,
) -> impl IntoResponse {
AuthService::mutation_mentor_login(payload, &state).await
}
#[utoipa::path(
post,
path = "/v1/auth/register",
+6 -1
View File
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use validator::{Validate, ValidationError};
fn validate_password_complexity(password: &str) -> Result<(), ValidationError> {
pub fn validate_password_complexity(password: &str) -> Result<(), ValidationError> {
let has_uppercase = password.chars().any(|c| c.is_ascii_uppercase());
let has_lowercase = password.chars().any(|c| c.is_ascii_lowercase());
let has_digit = password.chars().any(|c| c.is_ascii_digit());
@@ -117,3 +117,8 @@ pub struct AuthSetNewPasswordRequestDto {
))]
pub password: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserCacheSchema {
pub email: String,
pub permissions: Vec<String>,
}
+64 -13
View File
@@ -1,7 +1,12 @@
use super::AuthOtpSchema;
use crate::{AppState, ResourceEnum, UsersDetailQueryDto, make_thing};
use super::UserCacheSchema;
use crate::{
AppState, PermissionsQueryDto, ResourceEnum, RolesDetailQueryDto,
UsersDetailQueryDto,
};
use anyhow::{Result, anyhow, bail};
use chrono::{Duration, Utc};
use surrealdb::sql::Thing;
pub struct AuthRepository<'a> {
pub state: &'a AppState,
@@ -18,20 +23,26 @@ impl<'a> AuthRepository<'a> {
}
let table = ResourceEnum::UsersCache.to_string();
let user_id = user.email.clone();
let id = make_thing(&table, &user_id);
let _ = self
let permissions: Vec<String> =
user.role.permissions.into_iter().map(|p| p.name).collect();
let user_cache = UserCacheSchema {
email: user_id.clone(),
permissions,
};
let _record: Option<UserCacheSchema> = self
.state
.surrealdb_mem
.delete::<Option<UsersDetailQueryDto>>((table.clone(), user_id.clone()))
.delete::<Option<UserCacheSchema>>((table.clone(), user_id.clone()))
.await?;
let mut user_to_store = user.clone();
user_to_store.id = id.clone();
let record: Option<UsersDetailQueryDto> = self
let record: Option<UserCacheSchema> = self
.state
.surrealdb_mem
.create((table, user_id))
.content(user_to_store)
.content(user_cache)
.await?;
match record {
Some(_) => Ok("Success store user data".to_string()),
None => bail!("Failed store user data"),
@@ -42,13 +53,54 @@ impl<'a> AuthRepository<'a> {
&self,
email: String,
) -> Result<UsersDetailQueryDto> {
let user: Option<UsersDetailQueryDto> = self
let user_cache: Option<UserCacheSchema> = self
.state
.surrealdb_mem
.select((ResourceEnum::UsersCache.to_string(), email))
.select((ResourceEnum::UsersCache.to_string(), email.clone()))
.await?;
match user {
Some(u) => Ok(u),
match user_cache {
Some(cache) => {
let permissions_query_dto: Vec<PermissionsQueryDto> = cache
.permissions
.into_iter()
.map(|name| PermissionsQueryDto {
id: Thing::from((
"app_permissions".to_string(),
surrealdb::sql::Id::rand(),
)),
name,
created_at: None,
updated_at: None,
})
.collect();
let role_detail_query_dto = RolesDetailQueryDto {
id: Thing::from(("app_roles".to_string(), surrealdb::sql::Id::rand())),
name: "CachedRole".to_string(),
permissions: permissions_query_dto,
is_deleted: false,
created_at: None,
updated_at: None,
};
Ok(UsersDetailQueryDto {
id: Thing::from(("app_users".to_string(), email.clone())),
fullname: "Cached User".to_string(),
email: cache.email,
avatar: None,
phone_number: String::new(),
is_active: true,
is_deleted: false,
gender: None,
birthdate: None,
password: String::new(),
role: role_detail_query_dto,
created_at: String::new(),
updated_at: String::new(),
mentor_id: None,
})
}
None => bail!("No stored user data found"),
}
}
@@ -59,7 +111,6 @@ impl<'a> AuthRepository<'a> {
.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"),
+211 -48
View File
@@ -13,6 +13,7 @@ use crate::{
};
use axum::{http::StatusCode, response::Response};
use surrealdb::Uuid;
use tracing::error;
pub struct AuthService;
@@ -49,7 +50,11 @@ impl AuthService {
let access_token = match encode_access_token(payload.email.clone()) {
Ok(token) => token,
Err(_) => {
Err(_e) => {
error!(
"Failed to generate access token for {}: {}",
payload.email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate access token",
@@ -59,7 +64,11 @@ impl AuthService {
let refresh_token = match encode_refresh_token(payload.email.clone()) {
Ok(token) => token,
Err(_) => {
Err(_e) => {
error!(
"Failed to generate refresh token for {}: {}",
payload.email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate refresh token",
@@ -77,13 +86,116 @@ impl AuthService {
},
};
if let Err(_err) = auth_repo.query_store_user(user).await {
return common_response(StatusCode::BAD_REQUEST, "User already login");
if let Err(err_store) = auth_repo.query_store_user(user.clone()).await {
error!(
"Failed to store user cache for {}: {}",
user.email, err_store
);
return common_response(
StatusCode::BAD_REQUEST,
"User already login or failed to cache",
);
}
success_response(response)
}
Err(err) => common_response(StatusCode::UNAUTHORIZED, &err.to_string()),
Err(err_find) => {
common_response(StatusCode::UNAUTHORIZED, &err_find.to_string())
}
}
}
pub async fn mutation_mentor_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);
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 user_detail = UsersDetailItemDto::from(&user);
if user_detail.role.name != RolesEnum::Mentor.to_string() {
return common_response(
StatusCode::FORBIDDEN,
"User does not have mentor privileges",
);
}
let access_token = match encode_access_token(payload.email.clone()) {
Ok(token) => token,
Err(_e) => {
error!(
"Failed to generate access token for {}: {}",
payload.email, _e
);
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(_e) => {
error!(
"Failed to generate refresh token for {}: {}",
payload.email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate refresh token",
);
}
};
let response = ResponseSuccessDto {
data: AuthLoginResponsetDto {
user: UsersDetailItemDto::from(&user),
token: TokenDto {
access_token,
refresh_token,
},
},
};
if let Err(err_store) = auth_repo.query_store_user(user.clone()).await {
error!(
"Failed to store user cache for {}: {}",
user.email, err_store
);
return common_response(
StatusCode::BAD_REQUEST,
"User already login or failed to cache",
);
}
success_response(response)
}
Err(err_find) => {
common_response(StatusCode::UNAUTHORIZED, &err_find.to_string())
}
}
}
@@ -102,7 +214,10 @@ impl AuthService {
.await
{
Ok(role) => role,
Err(_) => return common_response(StatusCode::BAD_REQUEST, "Role Not Found"),
Err(_e) => {
error!("Failed to retrieve User role during registration: {}", _e);
return common_response(StatusCode::BAD_REQUEST, "Role Not Found");
}
};
if user_repo
.query_user_by_email(payload.email.clone())
@@ -113,7 +228,11 @@ impl AuthService {
}
let hashed_password = match hash_password(&payload.password) {
Ok(hash) => hash,
Err(_) => {
Err(_e) => {
error!(
"Failed to hash password during registration for {}: {}",
payload.email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to hash password",
@@ -121,27 +240,34 @@ impl AuthService {
}
};
let new_user = AuthRegisterRequestDto {
email: payload.email,
email: payload.email.clone(),
password: hashed_password,
fullname: payload.fullname,
phone_number: payload.phone_number,
};
let otp = generate_otp::OtpManager::generate_otp();
match auth_repo
.query_store_otp(new_user.email.clone(), otp.clone())
.await
{
match auth_repo.query_store_otp(new_user.email.clone(), otp).await {
Ok(_) => {
let message = format!("your otp code is {}", otp);
if let Err(err) = send_email(&new_user.email, "OTP Verification", &message) {
let message = format!("your otp code is {otp}");
if let Err(err_send) =
send_email(&new_user.email, "OTP Verification", &message)
{
error!(
"Failed to send OTP email to {}: {}",
new_user.email, err_send
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&err.to_string(),
&err_send.to_string(),
);
}
}
Err(err) => {
return common_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string());
Err(err_store) => {
error!("Failed to store OTP for {}: {}", new_user.email, err_store);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&err_store.to_string(),
);
}
}
let role_thing = make_thing(&ResourceEnum::Roles.to_string(), &role.id);
@@ -159,13 +285,15 @@ impl AuthService {
created_at: get_iso_date(),
updated_at: get_iso_date(),
role: role_thing,
is_active: true,
..Default::default()
})
.await
{
Ok(msg) => common_response(StatusCode::CREATED, &msg),
Err(err) => {
common_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string())
Err(err_create) => {
error!("Failed to create user {}: {}", new_user.email, err_create);
common_response(StatusCode::INTERNAL_SERVER_ERROR, &err_create.to_string())
}
}
}
@@ -188,13 +316,22 @@ impl AuthService {
let auth_repo = AuthRepository::new(state);
let _ = auth_repo.query_get_stored_otp(payload.email.clone()).await;
let otp = generate_otp::OtpManager::generate_otp();
let message = format!("Your OTP code is {}", otp);
let message = format!("Your OTP code is {otp}");
match auth_repo.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_send) => {
error!(
"Failed to send OTP email to {}: {}",
payload.email, err_send
);
common_response(StatusCode::BAD_REQUEST, &err_send.to_string())
}
},
Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()),
Err(err_store) => {
error!("Failed to store OTP for {}: {}", payload.email, err_store);
common_response(StatusCode::BAD_REQUEST, &err_store.to_string())
}
}
}
@@ -206,13 +343,14 @@ impl AuthService {
}
let email = match decode_refresh_token(&payload.refresh_token) {
Ok(token) => token.claims.sub,
Err(_) => {
Err(_e) => {
return common_response(StatusCode::UNAUTHORIZED, "Invalid refresh token");
}
};
let access_token = match encode_access_token(email.clone()) {
Ok(token) => token,
Err(_) => {
Err(_e) => {
error!("Failed to generate access token for {}: {}", email, _e);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate access token",
@@ -221,7 +359,8 @@ impl AuthService {
};
let refresh_token = match encode_refresh_token(email.clone()) {
Ok(token) => token,
Err(_) => {
Err(_e) => {
error!("Failed to generate refresh token for {}: {}", email, _e);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate refresh token",
@@ -248,16 +387,27 @@ impl AuthService {
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") => {
Err(err_find) if err_find.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());
Err(err_other) => {
error!(
"Error finding user for forgot password {}: {}",
payload.email, err_other
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&err_other.to_string(),
);
}
};
let token = match encode_reset_password_token(user.email) {
let token = match encode_reset_password_token(user.email.clone()) {
Ok(token) => token,
Err(_) => {
Err(_e) => {
error!(
"Failed to generate reset password token for {}: {}",
user.email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate access token",
@@ -267,12 +417,17 @@ impl AuthService {
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
"You have requested a password reset. Please click the link below to continue: {fe_url}/auth/reset-password?token={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()),
Err(err_send) => {
error!(
"Failed to send reset password email to {}: {}",
payload.email, err_send
);
common_response(StatusCode::BAD_REQUEST, &err_send.to_string())
}
}
}
@@ -288,33 +443,38 @@ impl AuthService {
let email = payload.email.clone();
let user = match user_repo.query_user_by_email(email.clone()).await {
Ok(user) if !user.is_deleted => user,
_ => return common_response(StatusCode::NOT_FOUND, "User not found"),
_ => {
return common_response(StatusCode::NOT_FOUND, "User not found");
}
};
let patch = UsersSchema {
id: user.id.clone(),
is_active: true,
..UsersSchema::from(user)
..UsersSchema::from(user.clone())
};
match auth_repo.query_get_stored_otp(email.clone()).await {
Ok(stored_otp) => match stored_otp == payload.otp {
true => match user_repo.query_update_user(patch).await {
Ok(_) => match auth_repo.query_delete_stored_otp(email).await {
Ok(_) => match auth_repo.query_delete_stored_otp(email.clone()).await {
Ok(_) => common_response(StatusCode::OK, "Email verified successfully"),
Err(e) => {
common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
Err(e_del) => {
error!("Failed to delete OTP for {}: {}", email, e_del);
common_response(StatusCode::INTERNAL_SERVER_ERROR, &e_del.to_string())
}
},
Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()),
Err(err_update) => {
common_response(StatusCode::BAD_REQUEST, &err_update.to_string())
}
},
false => match auth_repo.query_delete_stored_otp(email).await {
false => match auth_repo.query_delete_stored_otp(email.clone()).await {
Ok(_) => common_response(StatusCode::BAD_REQUEST, "Failed to verify OTP"),
Err(e) => common_response(
Err(e_del_mismatch) => common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to delete OTP: {}", e),
&format!("Failed to delete OTP: {e_del_mismatch}"),
),
},
},
Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()),
Err(err_get) => common_response(StatusCode::BAD_REQUEST, &err_get.to_string()),
}
}
@@ -327,14 +487,15 @@ impl AuthService {
}
let repo = UsersRepository::new(state);
let email = match extract_email_token(payload.token.clone()) {
Some(email) => email,
Some(token) => token,
None => {
return common_response(StatusCode::BAD_REQUEST, "Invalid or missing token");
}
};
let password = match hash_password(&payload.password) {
Ok(p) => p,
Err(_) => {
Err(_e) => {
error!("Failed to hash new password for {}: {}", email, _e);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to hash password",
@@ -343,7 +504,9 @@ impl AuthService {
};
let user = match repo.query_user_by_email(email.clone()).await {
Ok(user) if !user.is_deleted => user,
_ => return common_response(StatusCode::NOT_FOUND, "User not found"),
_ => {
return common_response(StatusCode::NOT_FOUND, "User not found");
}
};
let patch = UsersSchema {
id: user.id.clone(),
@@ -352,7 +515,7 @@ impl AuthService {
};
match repo.query_update_user(patch).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
Err(_e) => common_response(StatusCode::BAD_REQUEST, &_e.to_string()),
}
}
}
+1
View File
@@ -15,6 +15,7 @@ pub fn auth_router() -> Router {
Router::new()
.route("/forgot", post(auth_controller::post_forgot_password))
.route("/login", post(auth_controller::post_login))
.route("/login-mentor", post(auth_controller::post_login_mentor))
.route("/new-password", post(auth_controller::post_new_password))
.route("/refresh", post(auth_controller::post_refresh_token))
.route("/register", post(auth_controller::post_register))
@@ -1,6 +1,8 @@
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
use strum_macros::EnumIter;
#[derive(Debug, Clone, PartialEq, Eq, EnumIter)]
pub enum PermissionsEnum {
ReadListUsers,
ReadDetailUsers,
@@ -28,6 +30,16 @@ pub enum PermissionsEnum {
ReadDetailGachaRolls,
CreateGachaRolls,
ExecuteGachaRolls,
DeleteGachaRolls,
ReadListMentors,
ReadDetailMentors,
RegisterMentors,
ReadOwnMentorProfile,
UpdateOwnMentorProfile,
ReadOwnMentorStatus,
UpdateMentors,
VerifyMentors,
DeleteMentors,
}
impl fmt::Display for PermissionsEnum {
@@ -59,8 +71,18 @@ impl fmt::Display for PermissionsEnum {
PermissionsEnum::ReadDetailGachaRolls => "Read Detail Gacha Rolls",
PermissionsEnum::CreateGachaRolls => "Create Gacha Rolls",
PermissionsEnum::ExecuteGachaRolls => "Execute Gacha Rolls",
PermissionsEnum::DeleteGachaRolls => "Delete Gacha Rolls",
PermissionsEnum::ReadListMentors => "Read List Mentors",
PermissionsEnum::ReadDetailMentors => "Read Detail Mentors",
PermissionsEnum::RegisterMentors => "Register Mentors",
PermissionsEnum::ReadOwnMentorProfile => "Read Own Mentor Profile",
PermissionsEnum::UpdateOwnMentorProfile => "Update Own Mentor Profile",
PermissionsEnum::ReadOwnMentorStatus => "Read Own Mentor Status",
PermissionsEnum::UpdateMentors => "Update Mentors",
PermissionsEnum::VerifyMentors => "Verify Mentors",
PermissionsEnum::DeleteMentors => "Delete Mentors",
};
write!(f, "{}", permission_str)
write!(f, "{permission_str}")
}
}
@@ -101,6 +123,20 @@ impl PermissionsEnum {
}
PermissionsEnum::CreateGachaRolls => "18e36c63-fcb7-4877-b911-c5aa611e878f",
PermissionsEnum::ExecuteGachaRolls => "14c6a1cd-5c63-4643-89b5-b1a5f9920cc0",
PermissionsEnum::DeleteGachaRolls => "12345678-ABCD-EFAB-CDEF-0123456789AB",
PermissionsEnum::ReadListMentors => "a1b2c3d4-5e6f-7890-abcd-ef1234567890",
PermissionsEnum::ReadDetailMentors => "b2c3d4e5-6f78-9012-bcde-f23456789012",
PermissionsEnum::RegisterMentors => "c3d4e5f6-7890-1234-cdef-345678901234",
PermissionsEnum::ReadOwnMentorProfile => {
"d4e5f6a7-8901-2345-def0-456789012345"
}
PermissionsEnum::UpdateOwnMentorProfile => {
"e5f6a7b8-9012-3456-ef01-567890123456"
}
PermissionsEnum::ReadOwnMentorStatus => "f6a7b8c9-0123-4567-f012-678901234567",
PermissionsEnum::UpdateMentors => "a7b8c9d0-1234-5678-0123-789012345678",
PermissionsEnum::VerifyMentors => "b8c9d0e1-2345-6789-1234-890123456789",
PermissionsEnum::DeleteMentors => "c9d0e1f2-3456-7890-2345-901234567890",
}
}
}
@@ -1,5 +1,5 @@
use super::PermissionsEnum;
use crate::{common_response, extract_email, AppState, AuthRepository};
use crate::{AppState, AuthRepository, common_response, extract_email};
use axum::{
http::{HeaderMap, StatusCode},
response::Response,
@@ -29,14 +29,16 @@ pub async fn permissions_guard(
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",
));
for required in &required_permissions {
let required_str = required.to_string();
if !role_permissions.contains(&required_str) {
eprintln!(" MISSING REQUIRED PERMISSION: {required_str}");
return Err(common_response(
StatusCode::FORBIDDEN,
"You don't have the required permissions",
));
}
}
Ok(())
}
@@ -4,6 +4,9 @@ use crate::{
};
use anyhow::{Result, bail};
use imphnen_utils::{DetailQueryBuilder, QueryListBuilder, extract_id};
use serde_json;
use std::time::Instant;
use tracing::instrument;
pub struct PermissionsRepository<'a> {
state: &'a AppState,
@@ -14,10 +17,12 @@ impl<'a> PermissionsRepository<'a> {
Self { state }
}
#[instrument(skip(self, meta), err)]
pub async fn query_permission_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<PermissionsItemDto>>> {
let now = Instant::now();
let raw_result: ResponseListSuccessDto<Vec<PermissionsSchema>> =
QueryListBuilder::new(
&self.state.surrealdb_ws,
@@ -30,6 +35,13 @@ impl<'a> PermissionsRepository<'a> {
.build()
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_permission_list' took: {elapsed:.2?}");
}
let transformed_data = raw_result
.data
.into_iter()
@@ -42,25 +54,41 @@ impl<'a> PermissionsRepository<'a> {
})
}
#[instrument(skip(self, id), err)]
pub async fn query_permission_by_id(
&self,
id: String,
) -> Result<PermissionsSchema> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let result: Option<PermissionsSchema> = db
.select((ResourceEnum::Permissions.to_string(), id.clone()))
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_permission_by_id' took: {elapsed:.2?}");
}
match result {
Some(permission) if !permission.is_deleted => Ok(permission),
_ => bail!("Permission not found"),
}
}
#[instrument(skip(self, id), err)]
pub async fn transformed_query_permission_by_id(
&self,
id: String,
) -> Result<PermissionsItemDto> {
let now = Instant::now();
let raw_result = self.query_permission_by_id(id.clone()).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'transformed_query_permission_by_id' took: {elapsed:.2?}");
}
let transformed_data = PermissionsItemDto {
id: extract_id(&raw_result.id),
name: raw_result.name,
@@ -70,43 +98,60 @@ impl<'a> PermissionsRepository<'a> {
Ok(transformed_data)
}
#[instrument(skip(self, name), err)]
pub async fn query_permission_by_name(
&self,
name: String,
) -> Result<PermissionsSchema> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::Permissions.to_string())
.with_where("name")
.where_value(name.clone())
.with_where("name", Some(name.clone()))
.with_select_fields(vec!["*"]);
let sql = builder.build();
let result: Option<PermissionsSchema> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_permission_by_name' took: {elapsed:.2?}");
}
match result {
Some(permission) => Ok(permission),
None => bail!("Permission not found"),
}
}
#[instrument(skip(self, data), err)]
pub async fn query_create_permission(
&self,
data: PermissionsSchema,
) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let record: Option<PermissionsSchema> = db
.create(ResourceEnum::Permissions.to_string())
.content(data)
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_permission' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success create permission".into()),
None => bail!("Failed to create permission"),
}
}
#[instrument(skip(self, data), err)]
pub async fn query_update_permission(
&self,
data: PermissionsSchema,
) -> Result<String> {
let now = Instant::now();
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?;
@@ -119,13 +164,21 @@ impl<'a> PermissionsRepository<'a> {
};
let record: Option<PermissionsSchema> =
db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_permission' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success update permission".into()),
None => bail!("Failed to update permission"),
}
}
#[instrument(skip(self, id), err)]
pub async fn query_delete_permission(&self, id: String) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let permission_id = make_thing(&ResourceEnum::Permissions.to_string(), &id);
let permission = self
@@ -139,6 +192,12 @@ impl<'a> PermissionsRepository<'a> {
.update(record_key)
.merge(serde_json::json!({ "is_deleted": true }))
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_permission' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success delete permission".into()),
None => bail!("Failed to delete permission"),
@@ -1,7 +1,7 @@
use crate::{
common_response, make_thing, success_list_response, success_response,
validate_request, AppState, MetaRequestDto, PermissionsRepository,
PermissionsSchema, ResourceEnum, ResponseListSuccessDto, ResponseSuccessDto,
AppState, MetaRequestDto, PermissionsRepository, PermissionsSchema, ResourceEnum,
ResponseListSuccessDto, ResponseSuccessDto, common_response, make_thing,
success_list_response, success_response, validate_request,
};
use axum::http::StatusCode;
use axum::response::Response;
+13
View File
@@ -64,3 +64,16 @@ pub struct RolesDetailQueryDto {
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
impl Default for RolesDetailQueryDto {
fn default() -> Self {
Self {
id: Thing::from(("".to_string(), surrealdb::sql::Id::Number(0))),
name: String::new(),
permissions: Vec::new(),
is_deleted: false,
created_at: None,
updated_at: None,
}
}
}
+3 -1
View File
@@ -5,6 +5,7 @@ pub enum RolesEnum {
Admin,
User,
Staff,
Mentor, // Added Mentor role
}
impl fmt::Display for RolesEnum {
@@ -13,7 +14,8 @@ impl fmt::Display for RolesEnum {
RolesEnum::Admin => "Admin",
RolesEnum::User => "User",
RolesEnum::Staff => "Staff",
RolesEnum::Mentor => "Mentor", // Added Mentor role
};
write!(f, "{}", roles_str)
write!(f, "{roles_str}")
}
}
+67 -11
View File
@@ -7,8 +7,11 @@ use crate::{
};
use anyhow::{Result, bail};
use imphnen_utils::{DetailQueryBuilder, QueryListBuilder};
use serde_json;
use std::time::Instant;
use surrealdb::Uuid;
use surrealdb::sql::Thing;
use tracing::instrument;
pub struct RolesRepository<'a> {
state: &'a AppState,
@@ -19,10 +22,12 @@ impl<'a> RolesRepository<'a> {
Self { state }
}
#[instrument(skip(self, meta), err)]
pub async fn query_role_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<RolesListItemDto>>> {
let now = Instant::now();
let result: ResponseListSuccessDto<Vec<RolesSchema>> = QueryListBuilder::new(
&self.state.surrealdb_ws,
&ResourceEnum::Roles.to_string(),
@@ -31,6 +36,12 @@ impl<'a> RolesRepository<'a> {
.search_field("name")
.build()
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_role_list' took: {elapsed:.2?}");
}
let data = result
.data
.into_iter()
@@ -42,48 +53,71 @@ impl<'a> RolesRepository<'a> {
})
}
#[instrument(skip(self, name), err)]
pub async fn query_role_by_name(
&self,
name: String,
) -> Result<RolesDetailItemDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::Roles.to_string())
.with_where("name")
.where_value(name.clone())
.with_where("name", Some(name.clone()))
.with_select_fields(vec!["*"])
.with_fetch("permissions");
let sql = builder.build();
let result: Option<RolesDetailQueryDto> = builder
.apply_bindings(db.query(sql).bind(("name", name)))
.await?
.take(0)?;
let role = match result {
Some(r) if !r.is_deleted => r,
_ => bail!("Role not found"),
};
let mut response = builder.apply_bindings(db.query(sql)).await?;
let result_vec: Vec<RolesDetailQueryDto> = response.take(0).map_err(|e| {
anyhow::anyhow!("Failed to take result from response: {:?}", e)
})?;
let role = result_vec
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("Role not found"))?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_role_by_name' took: {elapsed:.2?}");
}
Ok(RolesDetailItemDto::from(&role))
}
#[instrument(skip(self, id), err)]
pub async fn query_role_by_id(&self, id: String) -> Result<RolesDetailItemDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let thing_id = make_thing(&ResourceEnum::Roles.to_string(), &id);
let builder = DetailQueryBuilder::new(ResourceEnum::Roles.to_string())
.with_id(&id)
.with_select_fields(vec!["*"])
.with_fetch("permissions");
let sql = builder.build();
let sql_debug = sql.to_string(); // Move this line here
let result: Option<RolesDetailQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_role_by_id' took: {elapsed:.2?}");
}
let role = match result {
Some(r) if !r.is_deleted => r,
_ => bail!("Role not found"),
_ => bail!("Role not found sql: {} id: {}", sql_debug, thing_id),
};
Ok(RolesDetailItemDto::from(&role))
}
#[instrument(skip(self, payload), err)]
pub async fn query_create_role(
&self,
payload: RolesRequestCreateDto,
) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let role_id = Uuid::new_v4().to_string();
let permission_things: Vec<Thing> = payload
@@ -103,14 +137,22 @@ impl<'a> RolesRepository<'a> {
.create((&ResourceEnum::Roles.to_string(), role_id))
.content(role)
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_role' took: {elapsed:.2?}");
}
Ok("Role with permissions created successfully".into())
}
#[instrument(skip(self, id, data), err)]
pub async fn query_update_role(
&self,
id: String,
data: RolesRequestUpdateDto,
) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let existing = self.query_role_by_id(id.clone()).await?;
if existing.is_deleted {
@@ -119,13 +161,21 @@ impl<'a> RolesRepository<'a> {
let merged = RolesSchema::update(data, id.clone(), existing);
let record: Option<RolesSchema> =
db.update(get_id(&merged.id)?).content(merged).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_role' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success update role".into()),
None => bail!("Failed to update role"),
}
}
#[instrument(skip(self, id), err)]
pub async fn query_delete_role(&self, id: String) -> Result<String> {
let now = Instant::now();
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?;
@@ -137,6 +187,12 @@ impl<'a> RolesRepository<'a> {
.update(record_key)
.merge(serde_json::json!({ "is_deleted": true }))
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_role' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success delete role".into()),
None => bail!("Failed to delete role"),
+29 -5
View File
@@ -1,13 +1,13 @@
use crate::{RolesDetailItemDto, RolesDetailQueryDto};
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing;
use utoipa::ToSchema;
use validator::Validate;
lazy_static! {
static ref PASSWORD_REGEX: Regex = Regex::new(r"^[A-Za-z\d@$!%*?&]{8,}$").unwrap();
static ref PASSWORD_REGEX: regex::Regex =
regex::Regex::new(r"^[A-Za-z\d@$!%*?&]{8,}$").unwrap();
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
@@ -34,7 +34,7 @@ pub struct UsersCreateRequestDto {
message = "Password must have at least 8 characters"
))]
#[validate(regex(
path = "PASSWORD_REGEX",
path = "*PASSWORD_REGEX",
message = "Password must include uppercase, lowercase, number, and special character"
))]
pub password: String,
@@ -62,6 +62,7 @@ pub struct UsersUpdateRequestDto {
min = 8,
message = "Password must have at least 8 characters"
))]
pub password: String,
#[validate(length(min = 2, message = "Fullname at least have 2 character"))]
pub fullname: String,
#[validate(length(
@@ -103,7 +104,7 @@ impl UsersDetailItemDto {
email: dto.email.clone(),
avatar: dto.avatar.clone(),
phone_number: dto.phone_number.clone(),
is_active: dto.is_active.clone(),
is_active: dto.is_active,
gender: dto.gender.clone(),
birthdate: dto.birthdate.clone(),
created_at: dto.created_at.clone(),
@@ -169,18 +170,20 @@ pub struct UsersDetailQueryDto {
pub role: RolesDetailQueryDto,
pub created_at: String,
pub updated_at: String,
pub mentor_id: Option<Thing>,
}
impl UsersDetailQueryDto {
pub fn from(&self) -> Self {
Self {
id: self.id.clone(),
role: RolesDetailQueryDto::from(self.role.clone()),
role: self.role.clone(),
fullname: self.fullname.clone(),
email: self.email.clone(),
avatar: self.avatar.clone(),
phone_number: self.phone_number.clone(),
is_active: self.is_active,
mentor_id: self.mentor_id.clone(),
gender: self.gender.clone(),
is_deleted: self.is_deleted,
password: self.password.clone(),
@@ -190,3 +193,24 @@ impl UsersDetailQueryDto {
}
}
}
impl From<&UsersDetailItemDto> for UsersDetailQueryDto {
fn from(dto: &UsersDetailItemDto) -> Self {
Self {
id: crate::make_thing(&imphnen_libs::ResourceEnum::Users.to_string(), &dto.id),
fullname: dto.fullname.clone(),
email: dto.email.clone(),
avatar: dto.avatar.clone(),
phone_number: dto.phone_number.clone(),
is_active: dto.is_active,
is_deleted: false,
gender: dto.gender.clone(),
birthdate: dto.birthdate.clone(),
password: String::new(),
role: RolesDetailQueryDto::default(),
created_at: dto.created_at.clone(),
updated_at: dto.updated_at.clone(),
mentor_id: None,
}
}
}
+77 -9
View File
@@ -2,10 +2,16 @@ use super::{UsersDetailQueryDto, UsersListItemDto, UsersListQueryDto, UsersSchem
use crate::{
AppState, MetaRequestDto, ResourceEnum, ResponseListSuccessDto, get_id, make_thing,
};
use surrealdb::sql::Thing;
use anyhow::{Result, bail};
use imphnen_utils::{DetailQueryBuilder, QueryListBuilder};
use serde_json;
use std::time::Instant;
use surrealdb::{Surreal, engine::remote::ws::Client};
pub struct UsersRepository<'a> {
state: &'a AppState,
}
@@ -30,10 +36,12 @@ impl<'a> UsersRepository<'a> {
Self { state }
}
pub async fn query_user_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<UsersListItemDto>>> {
let now = Instant::now();
let result: ResponseListSuccessDto<Vec<UsersListQueryDto>> =
QueryListBuilder::new(
&self.state.surrealdb_ws,
@@ -46,6 +54,14 @@ impl<'a> UsersRepository<'a> {
.fetch_fields(vec!["role", "role.permissions"])
.build()
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_user_list' took: {elapsed:.2?}");
}
let data = result
.data
.into_iter()
@@ -57,70 +73,103 @@ impl<'a> UsersRepository<'a> {
})
}
pub async fn query_user_by_email(
&self,
email: String,
) -> Result<UsersDetailQueryDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::Users.to_string())
.with_where("email")
.where_value(email.clone())
.with_where("email", Some(email.clone()))
.with_select_fields(vec!["*"])
.with_fetch("role")
.with_fetch("role.permissions");
let sql = builder.build();
let user_opt: Option<UsersDetailQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_user_by_email' took: {elapsed:.2?}");
}
let Some(user) = user_opt else {
bail!("User not found");
};
if user.is_deleted {
bail!("User not found");
}
if user.role.is_deleted {
if user.role.updated_at.is_none() || user.role.is_deleted {
bail!("User not found");
}
Ok(UsersDetailQueryDto::from(&user))
}
pub async fn query_user_by_id(&self, id: String) -> Result<UsersDetailQueryDto> {
pub async fn query_user_by_id(&self, id: &Thing) -> Result<UsersDetailQueryDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::Users.to_string())
.with_id(&id)
.with_id(&id.id.to_raw())
.with_select_fields(vec!["*"])
.with_fetch("role")
.with_fetch("role.permissions");
let sql = builder.build();
let result: Option<UsersDetailQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_user_by_id' took: {elapsed:.2?}");
}
let Some(user) = result else {
bail!("User not found");
bail!("User not found in database");
};
if user.is_deleted {
bail!("User not found");
}
if user.role.is_deleted {
bail!("User not found");
bail!("User's role has been deleted");
}
Ok(UsersDetailQueryDto::from(&user))
}
pub async fn query_create_user(&self, data: UsersSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let record: Option<UsersSchema> = db
.create(ResourceEnum::Users.to_string())
.content(data)
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_user' took: {elapsed:.2?}");
}
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 now = Instant::now();
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?;
let existing = self.query_user_by_id(&data.id).await?;
if existing.is_deleted {
bail!("User already deleted");
}
@@ -136,15 +185,25 @@ impl<'a> UsersRepository<'a> {
..data.clone()
};
let record: Option<UsersSchema> = db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_user' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success update user".into()),
None => bail!("Failed to update user"),
}
}
pub async fn query_delete_user(&self, id: String) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let user = self.query_user_by_id(id).await?;
let user = self.query_user_by_id(&make_thing(&ResourceEnum::Users.to_string(), &id)).await?;
if user.is_deleted {
bail!("User not found");
}
@@ -153,9 +212,18 @@ impl<'a> UsersRepository<'a> {
.update(record_key)
.merge(serde_json::json!({ "is_deleted": true }))
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_user' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success delete user".into()),
None => bail!("Failed to delete user"),
}
}
}
+33 -1
View File
@@ -1,5 +1,6 @@
use super::{UsersCreateRequestDto, UsersDetailQueryDto, UsersUpdateRequestDto};
use imphnen_libs::{ResourceEnum, hash_password};
use imphnen_utils::extract_id;
use imphnen_utils::{get_iso_date, make_thing};
use serde::{Deserialize, Serialize};
use surrealdb::{Uuid, sql::Thing};
@@ -10,11 +11,16 @@ pub struct UsersSchema {
pub fullname: String,
pub email: String,
pub password: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar: Option<String>,
pub phone_number: String,
pub is_active: bool,
pub is_deleted: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub mentor_id: Option<Thing>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub birthdate: Option<String>,
pub role: Thing,
pub created_at: String,
@@ -35,6 +41,10 @@ impl Default for UsersSchema {
phone_number: String::new(),
is_active: false,
is_deleted: false,
mentor_id: Some(make_thing(
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
)),
gender: None,
birthdate: None,
role: make_thing(
@@ -57,12 +67,18 @@ impl UsersSchema {
phone_number: dto.phone_number,
is_active: dto.is_active,
is_deleted: dto.is_deleted,
mentor_id: Some(dto.mentor_id.unwrap_or_else(|| {
make_thing(
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
)
})),
gender: dto.gender,
birthdate: dto.birthdate,
password: dto.password,
created_at: dto.created_at,
updated_at: dto.updated_at,
role: make_thing(&ResourceEnum::Roles.to_string(), &dto.role.id.id.to_raw()),
role: make_thing(&ResourceEnum::Roles.to_string(), &extract_id(&dto.role.id)),
}
}
@@ -95,6 +111,10 @@ impl UsersSchema {
password,
phone_number: user.phone_number,
is_active: false,
mentor_id: Some(make_thing(
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
)),
gender: None,
birthdate: None,
avatar: None,
@@ -112,4 +132,16 @@ impl UsersSchema {
..Self::from(dto)
}
}
pub fn update_mentor_id(mut self, mentor_id: Option<String>) -> Self {
self.mentor_id = match mentor_id {
Some(id) => Some(make_thing(&ResourceEnum::Users.to_string(), &id)),
None => Some(make_thing(
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
)),
};
self.updated_at = get_iso_date();
self
}
}
+39 -7
View File
@@ -6,12 +6,15 @@ use crate::{
AppState, MetaRequestDto, ResponseListSuccessDto, UsersRepository, UsersSchema,
};
use crate::{
ResourceEnum, ResponseSuccessDto, common_response, extract_email, make_thing,
success_list_response, success_response, validate_request,
ResponseSuccessDto, common_response, extract_email, success_list_response,
success_response, validate_request,
};
use axum::http::HeaderMap;
use axum::{http::StatusCode, response::Response};
use imphnen_libs::{hash_password, verify_password};
use imphnen_libs::{ResourceEnum, hash_password, verify_password};
use imphnen_utils::make_thing;
use uuid::Uuid;
pub struct UsersService;
@@ -31,8 +34,12 @@ impl UsersService {
}
pub async fn get_user_by_id(state: &AppState, id: String) -> Response {
if Uuid::parse_str(&id).is_err() {
return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format");
}
let repo = UsersRepository::new(state);
match repo.query_user_by_id(id).await {
let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id);
match repo.query_user_by_id(&thing_id).await {
Ok(user) if !user.is_deleted => success_response(ResponseSuccessDto {
data: UsersDetailItemDto::from(&user),
}),
@@ -84,6 +91,9 @@ impl UsersService {
id: String,
user: UsersUpdateRequestDto,
) -> Response {
if Uuid::parse_str(&id).is_err() {
return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format");
}
let repo = UsersRepository::new(state);
if let Err((status, message)) = validate_request(&user) {
return common_response(status, &message);
@@ -124,9 +134,12 @@ impl UsersService {
id: String,
payload: UsersActiveInactiveRequestDto,
) -> Response {
if Uuid::parse_str(&id).is_err() {
return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format");
}
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 {
match repo.query_user_by_id(&thing_id).await {
Ok(user) if !user.is_deleted => {
let patch = UsersSchema {
id: user.id.clone(),
@@ -186,9 +199,28 @@ impl UsersService {
}
}
pub async fn delete_user(state: &AppState, id: String) -> Response {
pub async fn get_user_by_mentor_id(
state: &AppState,
mentor_id: String,
) -> Response {
let repo = UsersRepository::new(state);
if repo.query_user_by_id(id.clone()).await.is_err() {
let thing_id = make_thing(&ResourceEnum::Mentors.to_string(), &mentor_id);
match repo.query_user_by_id(&thing_id).await {
Ok(user) if !user.is_deleted => success_response(ResponseSuccessDto {
data: UsersDetailItemDto::from(&user),
}),
Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
}
}
pub async fn delete_user(state: &AppState, id: String) -> Response {
if Uuid::parse_str(&id).is_err() {
return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format");
}
let repo = UsersRepository::new(state);
let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id);
if repo.query_user_by_id(&thing_id).await.is_err() {
return common_response(StatusCode::BAD_REQUEST, "User not found");
}
match repo.query_delete_user(id).await {
+1 -2
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
imphnen-entities = { path = "../imphnen-entities" }
imphnen-entities.workspace = true
log.workspace = true
axum.workspace = true
tokio.workspace = true
@@ -15,4 +15,3 @@ chrono.workspace = true
surrealdb.workspace = true
jsonwebtoken.workspace = true
dotenvy.workspace = true
+4 -4
View File
@@ -1,9 +1,9 @@
use argon2::{
password_hash::{
rand_core::OsRng, Error, PasswordHash, PasswordHasher, PasswordVerifier,
SaltString,
},
Argon2,
password_hash::{
Error, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
rand_core::OsRng,
},
};
pub fn hash_password(password: &str) -> Result<String, Error> {
+3 -10
View File
@@ -1,11 +1,9 @@
use crate::{load_env, surrealdb_init_mem, surrealdb_init_ws, Env};
use crate::{Env, load_env, surrealdb_init_mem, surrealdb_init_ws};
use axum::{Router, serve};
use imphnen_entities::{SurrealMemClient, SurrealWsClient};
use log::{debug, error, info};
use std::{future::Future, net::SocketAddr};
use tokio::net::TcpListener;
pub async fn axum_init<F, Fut>(router_fn: F)
where
F: FnOnce(SurrealWsClient, SurrealMemClient) -> Fut,
@@ -13,24 +11,19 @@ where
{
load_env();
let env = Env::new();
info!("Environment loaded with port: {}", env.port);
let surrealdb_ws = surrealdb_init_ws().await.expect("Failed surrealdb ws");
info!("SurrealDB WS client initialized");
let surrealdb_mem = surrealdb_init_mem().await.expect("Failed surrealdb mem");
info!("SurrealDB MEM client initialized");
let router = router_fn(surrealdb_ws, surrealdb_mem).await;
debug!("Router created successfully");
let port = env.port;
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = TcpListener::bind(&addr).await.unwrap();
info!("Listening on http://{}", addr);
match serve(listener, router).await {
Ok(_) => info!("Server stopped gracefully."),
Err(err) => error!("Server encountered an error: {}", err),
Ok(_) => {}
Err(_err) => {}
}
}
+39 -87
View File
@@ -1,7 +1,7 @@
use std::env;
pub fn load_env() {
dotenvy::dotenv().ok();
if dotenvy::dotenv().is_ok() {}
}
pub struct Env {
@@ -13,6 +13,7 @@ pub struct Env {
pub surrealdb_password: String,
pub surrealdb_namespace: String,
pub surrealdb_dbname: String,
pub surrealdb_url_ws: String,
pub smtp_email: String,
pub smtp_password: String,
pub smtp_name: String,
@@ -31,112 +32,63 @@ impl Env {
pub fn new() -> Self {
Self {
port: env::var("PORT")
.unwrap_or_else(|_| {
println!("INFO: PORT is not set, using default '3000'.");
"3000".to_string()
})
.unwrap_or_else(|_| "3000".to_string())
.parse()
.unwrap_or(3000),
rust_env: env::var("RUST_ENV").unwrap_or_else(|_| {
println!("INFO: RUST_ENV is not set, using default 'development'.");
"development".to_string()
}),
rust_env: env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()),
access_token_secret: env::var("ACCESS_TOKEN_SECRET").unwrap_or_else(|_| {
println!("WARNING: ACCESS_TOKEN_SECRET is not set, using fallback!");
"default_access_secret".to_string()
}),
access_token_secret: env::var("ACCESS_TOKEN_SECRET")
.unwrap_or_else(|_| "default_access_secret".to_string()),
refresh_token_secret: env::var("REFRESH_TOKEN_SECRET").unwrap_or_else(|_| {
println!("WARNING: REFRESH_TOKEN_SECRET is not set, using fallback!");
"default_refresh_secret".to_string()
}),
refresh_token_secret: env::var("REFRESH_TOKEN_SECRET")
.unwrap_or_else(|_| "default_refresh_secret".to_string()),
surrealdb_url: env::var("SURREALDB_URL").unwrap_or_else(|_| {
println!(
"WARNING: SURREALDB_URL is not set, using fallback 'http://localhost:8000'!"
);
"http://localhost:8000".to_string()
}),
surrealdb_url: env::var("SURREALDB_URL")
.unwrap_or_else(|_| "http://localhost:8000".to_string()),
surrealdb_username: env::var("SURREALDB_USERNAME").unwrap_or_else(|_| {
println!("WARNING: SURREALDB_USERNAME is not set, using fallback 'root'!");
"root".to_string()
}),
surrealdb_username: env::var("SURREALDB_USERNAME")
.unwrap_or_else(|_| "root".to_string()),
surrealdb_password: env::var("SURREALDB_PASSWORD").unwrap_or_else(|_| {
println!("WARNING: SURREALDB_PASSWORD is not set, using fallback!");
"password".to_string()
}),
surrealdb_password: env::var("SURREALDB_PASSWORD")
.unwrap_or_else(|_| "password".to_string()),
surrealdb_namespace: env::var("SURREALDB_NAMESPACE").unwrap_or_else(|_| {
println!(
"WARNING: SURREALDB_NAMESPACE is not set, using fallback 'namespace'!"
);
"namespace".to_string()
}),
surrealdb_namespace: env::var("SURREALDB_NAMESPACE")
.unwrap_or_else(|_| "namespace".to_string()),
surrealdb_dbname: env::var("SURREALDB_DBNAME").unwrap_or_else(|_| {
println!("WARNING: SURREALDB_DBNAME is not set, using fallback 'database'!");
"database".to_string()
}),
surrealdb_dbname: env::var("SURREALDB_DBNAME")
.unwrap_or_else(|_| "database".to_string()),
smtp_email: env::var("SMTP_EMAIL").unwrap_or_else(|_| {
println!(
"WARNING: SMTP_EMAIL is not set, using fallback 'no-reply@example.com'!"
);
"no-reply@example.com".to_string()
}),
surrealdb_url_ws: env::var("SURREALDB_URL_WS")
.unwrap_or_else(|_| "ws://localhost:8000/rpc".to_string()),
smtp_password: env::var("SMTP_PASSWORD").unwrap_or_else(|_| {
println!("WARNING: SMTP_PASSWORD is not set, using fallback!");
"default_smtp_password".to_string()
}),
smtp_email: env::var("SMTP_EMAIL")
.unwrap_or_else(|_| "no-reply@example.com".to_string()),
smtp_name: env::var("SMTP_NAME").unwrap_or_else(|_| {
println!("WARNING: SMTP_NAME is not set, using fallback 'MyApp SMTP'!");
"MyApp SMTP".to_string()
}),
smtp_password: env::var("SMTP_PASSWORD")
.unwrap_or_else(|_| "default_smtp_password".to_string()),
smtp_host: env::var("SMTP_HOST").unwrap_or_else(|_| {
println!("WARNING: SMTP_HOST is not set, using fallback 'smtp.gmail.com'!");
"smtp.gmail.com".to_string()
}),
smtp_name: env::var("SMTP_NAME").unwrap_or_else(|_| "MyApp SMTP".to_string()),
redisdb_url: env::var("REDISDB_URL").unwrap_or_else(|_| {
println!("WARNING: REDISDB_URL is not set, using fallback 'localhost'!");
"localhost".to_string()
}),
smtp_host: env::var("SMTP_HOST")
.unwrap_or_else(|_| "smtp.gmail.com".to_string()),
fe_url: env::var("FE_URL").unwrap_or_else(|_| {
println!("WARNING: FE_URL is not set, using fallback 'http://localhost'!");
"http://localhost".to_string()
}),
redisdb_url: env::var("REDISDB_URL")
.unwrap_or_else(|_| "localhost".to_string()),
minio_endpoint: env::var("MINIO_ENDPOINT").unwrap_or_else(|_| {
println!(
"WARNING: MINIO_ENDPOINT is not set, using fallback 'http://localhost:9000'!"
);
"http://localhost:9000".to_string()
}),
fe_url: env::var("FE_URL").unwrap_or_else(|_| "http://localhost".to_string()),
minio_bucket_name: env::var("MINIO_BUCKET_NAME").unwrap_or_else(|_| {
println!(
"WARNING: MINIO_BUCKET_NAME is not set, using fallback 'default_bucket'!"
);
"default_bucket".to_string()
}),
minio_endpoint: env::var("MINIO_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:9000".to_string()),
minio_access_key: env::var("MINIO_ACCESS_KEY").unwrap_or_else(|_| {
println!("WARNING: MINIO_ACCESS_KEY is not set, using fallback!");
"minio_access".to_string()
}),
minio_bucket_name: env::var("MINIO_BUCKET_NAME")
.unwrap_or_else(|_| "default_bucket".to_string()),
minio_secret_key: env::var("MINIO_SECRET_KEY").unwrap_or_else(|_| {
println!("WARNING: MINIO_SECRET_KEY is not set, using fallback!");
"minio_secret".to_string()
}),
minio_access_key: env::var("MINIO_ACCESS_KEY")
.unwrap_or_else(|_| "minio_access".to_string()),
minio_secret_key: env::var("MINIO_SECRET_KEY")
.unwrap_or_else(|_| "minio_secret".to_string()),
}
}
}
+3 -3
View File
@@ -2,7 +2,7 @@ use super::Env;
use axum::http::StatusCode;
use chrono::{Duration, TimeDelta, Utc};
use jsonwebtoken::{
decode, encode, DecodingKey, EncodingKey, Header, TokenData, Validation,
DecodingKey, EncodingKey, Header, TokenData, Validation, decode, encode,
};
use serde::{Deserialize, Serialize};
@@ -51,7 +51,7 @@ pub fn decode_access_token(
let env = Env::new();
let secret: String = env.access_token_secret;
let result: Result<TokenData<Claims>, StatusCode> = decode(
&jwt_token,
jwt_token,
&DecodingKey::from_secret(secret.as_ref()),
&Validation::default(),
)
@@ -81,7 +81,7 @@ pub fn decode_refresh_token(
let env = Env::new();
let secret: String = env.refresh_token_secret;
let result: Result<TokenData<Claims>, StatusCode> = decode(
&jwt_token,
jwt_token,
&DecodingKey::from_secret(secret.as_ref()),
&Validation::default(),
)
+2 -8
View File
@@ -29,13 +29,7 @@ pub fn send_email(
.credentials(smtp_credentials)
.build();
match mailer.send(&email) {
Ok(_) => {
println!("Email sent successfully to {}", to);
Ok(())
}
Err(e) => {
println!("Failed to send email: {}", e);
Err(Box::new(e))
}
Ok(_) => Ok(()),
Err(e) => Err(Box::new(e)),
}
}
+2 -3
View File
@@ -1,10 +1,10 @@
use super::Env;
use crate::SurrealMemClient;
use surrealdb::engine::local::Mem;
use crate::enviroment::load_env;
use surrealdb::engine::any;
use surrealdb::engine::local::Mem;
use surrealdb::opt::auth::Root;
use surrealdb::{Result, Surreal};
use crate::enviroment::load_env;
pub mod resource;
pub use resource::*;
@@ -13,7 +13,6 @@ pub async fn surrealdb_init_ws() -> Result<Surreal<any::Any>> {
load_env();
let env = Env::new();
let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root {
username: &env.surrealdb_username,
+3 -1
View File
@@ -14,6 +14,7 @@ pub enum ResourceEnum {
RolesPermissions,
Events,
Testimonials,
Mentors,
}
impl fmt::Display for ResourceEnum {
@@ -31,7 +32,8 @@ impl fmt::Display for ResourceEnum {
ResourceEnum::GachaCredits => "app_gacha_credits",
ResourceEnum::Events => "app_events",
ResourceEnum::Testimonials => "app_testimonials",
ResourceEnum::Mentors => "app_mentors",
};
write!(f, "{}", str)
write!(f, "{str}")
}
}
+4 -4
View File
@@ -4,10 +4,10 @@ version = "0.1.0"
edition = "2024"
[dependencies]
imphnen-iam = { version = "0.1.0", path = "../imphnen-iam" }
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" }
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" }
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" }
imphnen-iam.workspace = true
imphnen-libs.workspace = true
imphnen-utils.workspace = true
imphnen-entities.workspace = true
axum.workspace = true
serde.workspace = true
serde_json.workspace = true
+6 -2
View File
@@ -4,8 +4,8 @@ version = "0.1.0"
edition = "2024"
[dependencies]
imphnen-libs = { path = "../imphnen-libs"}
imphnen-entities = { path = "../imphnen-entities" }
imphnen-libs.workspace = true
imphnen-entities.workspace = true
surrealdb.workspace = true
axum.workspace = true
chrono.workspace = true
@@ -15,3 +15,7 @@ axum-test.workspace = true
serde.workspace = true
serde_json.workspace = true
validator.workspace = true
strum.workspace = true
strum_macros.workspace = true
uuid.workspace = true
tracing.workspace = true
+2 -2
View File
@@ -1,9 +1,9 @@
use surrealdb::method::Query;
use surrealdb::engine::any;
use surrealdb::method::Query;
pub fn bind_filter_value(
query: Query<'_, any::Any>,
val: String,
) -> Query<'_, any::Any> {
query.bind(("filter", val)) // langsung string aja, udah cukup
query.bind(("filter", val))
}
+3 -14
View File
@@ -1,24 +1,13 @@
use crate::decode_access_token;
use axum::http::{header::AUTHORIZATION, HeaderMap};
use axum::http::{HeaderMap, header::AUTHORIZATION};
pub fn extract_email(headers: &HeaderMap) -> Option<String> {
println!("📥 Received headers: {:?}", headers);
let auth_header = headers.get(AUTHORIZATION)?.to_str().ok()?;
println!("🔍 Authorization Header: {}", auth_header);
let token = auth_header.strip_prefix("Bearer ")?;
println!("🧪 Token: {}", token);
match decode_access_token(token) {
Ok(data) => {
println!("✅ Token claims: {:?}", data.claims);
Some(data.claims.sub)
}
Err(e) => {
eprintln!("❌ Failed to decode token: {}", e);
None
}
Ok(data) => Some(data.claims.sub),
Err(_e) => None,
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
use rand::{rng, Rng};
use rand::{Rng, rng};
pub struct OtpManager;
+1 -2
View File
@@ -11,6 +11,5 @@ pub fn get_id(thing: &Thing) -> Result<(&str, &str)> {
}
pub fn extract_id(thing: &Thing) -> String {
let id = thing.id.to_raw();
id
thing.id.to_raw()
}
+5 -5
View File
@@ -1,16 +1,13 @@
use imphnen_entities::*;
use imphnen_libs::*;
pub mod bind_filter;
pub mod extract_email;
pub mod generate_date;
pub mod generate_otp;
pub mod get_id;
pub mod make_thing;
pub mod mock_test;
pub mod query_builder;
pub mod query_list;
pub mod response_format;
pub mod serde_helpers;
pub mod validator;
pub use bind_filter::*;
@@ -21,8 +18,11 @@ pub use get_id::*;
pub use imphnen_entities::*;
pub use imphnen_libs::*;
pub use make_thing::*;
pub use mock_test::*;
pub use query_builder::*;
pub use query_list::*;
pub use response_format::*;
pub use serde_helpers::{
option_thing_or_string, serialize_option_thing, serialize_thing,
string_or_empty_string, thing_or_string,
};
pub use validator::*;
+1 -1
View File
@@ -5,5 +5,5 @@ pub fn make_thing(table: &str, id: &str) -> Thing {
}
pub fn make_thing_str(table: &str, id: &str) -> String {
format!("{}:⟨{}", table, id)
format!("{table}:⟨{id}")
}
-51
View File
@@ -1,52 +1 @@
use crate::AppState;
use imphnen_libs::enviroment::load_env;
use surrealdb::engine::any;
use surrealdb::{Surreal, engine::local::Mem, opt::auth::Root};
use super::Env;
pub async fn create_mock_app_state() -> AppState {
load_env();
let env = Env::new();
let db_mem = Surreal::new::<Mem>(()).await.unwrap();
let db_ws = any::connect(&env.surrealdb_url).await.unwrap();
db_mem.use_ns("test").use_db("test").await.unwrap();
db_ws
.signin(Root {
username: "root",
password: "root",
})
.await
.unwrap();
db_ws.use_ns("test").use_db("test").await.unwrap();
AppState {
surrealdb_mem: db_mem,
surrealdb_ws: db_ws,
}
}
pub async fn cleanup_db() {
let app_state = create_mock_app_state().await;
let _ = app_state
.surrealdb_mem
.query(
r#"
REMOVE TABLE app_users;
REMOVE TABLE app_roles;
REMOVE TABLE app_users_cache;
REMOVE TABLE app_otp_cache;
"#,
)
.await;
let _ = app_state
.surrealdb_ws
.query(
r#"
REMOVE TABLE app_users;
REMOVE TABLE app_roles;
REMOVE TABLE app_users_cache;
REMOVE TABLE app_otp_cache;
"#,
)
.await;
}
+75 -38
View File
@@ -1,7 +1,8 @@
use imphnen_libs::MetaRequestDto;
use serde_json::{Map, Value};
use surrealdb::engine::any;
use surrealdb::method::Query;
use surrealdb::sql::Thing;
use surrealdb::engine::any;
pub struct ListQueryBuilder {
resource: String,
@@ -57,8 +58,7 @@ impl ListQueryBuilder {
if let Some(search) = search {
if !search.is_empty() {
self.conditions.push(format!(
"string::contains(string::lowercase({} ?? ''), string::lowercase($search))",
field
"string::contains(string::lowercase({field} ?? ''), string::lowercase($search))"
));
}
}
@@ -69,8 +69,7 @@ impl ListQueryBuilder {
if let (Some(f), Some(v)) = (field, value) {
if !v.is_empty() {
self.conditions.push(format!(
"string::contains(string::join('', [{}]), $filter)",
f
"string::contains(string::join('', [{f}]), $filter)"
));
}
}
@@ -111,7 +110,7 @@ impl ListQueryBuilder {
let order_clause = if let Some(field) = self.order_by {
let ord = self.order.unwrap_or_else(|| "ASC".into());
format!("ORDER BY {} {}", field, ord)
format!("ORDER BY {field} {ord}")
} else {
String::new()
};
@@ -145,16 +144,26 @@ impl ListQueryBuilder {
fetch_clause
)
}
pub fn build_count(self) -> String {
let where_clause = if !self.conditions.is_empty() {
format!("WHERE {}", self.conditions.join(" AND "))
} else {
String::new()
};
format!("SELECT count() FROM {} {}", self.resource, where_clause)
}
}
pub struct DetailQueryBuilder {
resource: String,
id: Option<String>,
thing: Option<String>,
where_field: Option<String>,
where_value: Option<String>,
select_fields: Vec<String>,
fetch_fields: Vec<String>,
conditions: Vec<String>,
bindings: Map<String, Value>,
}
impl DetailQueryBuilder {
@@ -163,40 +172,59 @@ impl DetailQueryBuilder {
resource: resource.into(),
id: None,
thing: None,
where_field: None,
where_value: None,
select_fields: vec![],
fetch_fields: vec![],
conditions: vec![],
bindings: Map::new(),
}
}
pub fn with_id(mut self, id: impl Into<String>) -> Self {
if self.where_field.is_some() || self.thing.is_some() {
panic!("Cannot use with_id() after with_where() or with_thing()");
if self.thing.is_some() || !self.conditions.is_empty() {
panic!(
"Cannot use with_id() after with_thing() or with_where()/with_condition()"
);
}
self.id = Some(id.into());
self
}
pub fn with_thing(mut self, thing: &Thing) -> Self {
if self.id.is_some() || self.where_field.is_some() {
panic!("Cannot use with_thing() after with_id() or with_where()");
if self.id.is_some() || !self.conditions.is_empty() {
panic!(
"Cannot use with_thing() after with_id() or with_where()/with_condition()"
);
}
self.thing = Some(thing.to_string()); // app_users:uuid
self.resource = thing.tb.clone(); // update resource dari thing
self.thing = Some(thing.to_string());
self.resource = thing.tb.clone();
self
}
pub fn with_where(mut self, field: impl Into<String>) -> Self {
// Modified with_where method
pub fn with_where(
mut self,
field: impl Into<String>,
value: Option<impl Into<String>>,
) -> Self {
if self.id.is_some() || self.thing.is_some() {
panic!("Cannot use with_where() after with_id() or with_thing()");
}
self.where_field = Some(field.into());
let field_str = field.into();
if let Some(val) = value {
// Using a distinct binding key to avoid conflicts
self.conditions.push(format!("{field_str} = $value_where"));
self
.bindings
.insert("value_where".to_string(), Value::String(val.into()));
} else {
// If no value, assume it's a direct condition string (e.g., "is_active = true")
self.conditions.push(field_str);
}
self
}
pub fn where_value(mut self, value: impl Into<String>) -> Self {
self.where_value = Some(value.into());
pub fn with_condition(mut self, condition: &str) -> Self {
self.conditions.push(condition.to_string());
self
}
@@ -220,32 +248,41 @@ impl DetailQueryBuilder {
let fetch_clause = if self.fetch_fields.is_empty() {
String::new()
} else {
format!("FETCH {}", self.fetch_fields.join(", "))
format!("FETCH {}", self.fetch_fields.join(", ")) // Fixed: Changed self.fetch to self.fetch_fields
};
let from_clause = if let Some(thing) = &self.thing {
// Determine the base FROM clause
let mut from_clause_base = if let Some(thing) = &self.thing {
thing.to_string()
} else if let Some(id) = &self.id {
format!("{}:⟨{}", self.resource, id)
} else if let (Some(field), Some(_)) = (&self.where_field, &self.where_value) {
format!("{} WHERE {} = $value", self.resource, field)
} else if let Some(id_val) = &self.id {
format!("{}:⟨{}", self.resource, id_val)
} else {
panic!(
"You must set one of with_id(), with_thing(), or with_where()+where_value()"
);
self.resource.clone() // Start with resource name for WHERE queries
};
format!(
"SELECT {} FROM {} {}",
select_clause, from_clause, fetch_clause
)
// Add WHERE clause based on accumulated conditions
if !self.conditions.is_empty() {
// This logic needs to be careful: if `from_clause_base` already contains `WHERE` (e.g. from `id` lookup),
// then `conditions` should append with `AND`. But for `DetailQueryBuilder`, only one `WHERE` style is expected.
// The panic conditions in `with_id`, `with_thing`, `with_where` should prevent logical conflicts.
from_clause_base = format!(
"{} WHERE {}",
from_clause_base,
self.conditions.join(" AND ")
);
}
format!("SELECT {select_clause} FROM {from_clause_base} {fetch_clause}")
}
pub fn apply_bindings<'q>(&self, query: Query<'q, any::Any>) -> Query<'q, any::Any> {
if let (Some(_), Some(value)) = (&self.where_field, &self.where_value) {
query.bind(("value", value.clone()))
} else {
query
// Modified apply_bindings to clone both key and value
pub fn apply_bindings<'q>(
&self,
mut query: Query<'q, any::Any>,
) -> Query<'q, any::Any> {
for (key, val) in &self.bindings {
query = query.bind((key.clone(), val.clone())); // Clone both key and value
}
query
}
}
+122 -97
View File
@@ -1,117 +1,142 @@
use crate::{CountResult, MetaRequestDto, MetaResponseDto, ResponseListSuccessDto};
use anyhow::Result;
use serde::{Serialize, de::DeserializeOwned};
use surrealdb::Surreal;
use imphnen_entities::{
CountResult, MetaRequestDto, MetaResponseDto, ResponseListSuccessDto,
};
use serde::{de::DeserializeOwned, Serialize};
use surrealdb::engine::any;
use surrealdb::Surreal;
use tracing;
pub struct QueryListBuilder<'a> {
db: &'a Surreal<any::Any>,
table: &'a str,
meta: &'a MetaRequestDto,
conditions: Vec<String>,
search_field: String,
select_fields: Option<Vec<&'a str>>,
fetch_fields: Option<Vec<&'a str>>,
db: &'a Surreal<any::Any>,
table: &'a str,
meta: &'a MetaRequestDto,
conditions: Vec<String>,
search_field: String,
select_fields: Option<Vec<&'a str>>,
fetch_fields: Option<Vec<&'a str>>,
cast_thing_fields: bool,
}
impl<'a> QueryListBuilder<'a> {
pub fn new(
db: &'a Surreal<any::Any>,
table: &'a str,
meta: &'a MetaRequestDto,
) -> Self {
Self {
db,
table,
meta,
conditions: vec![],
search_field: "name".to_string(),
select_fields: None,
fetch_fields: None,
}
}
pub fn new(
db: &'a Surreal<any::Any>,
table: &'a str,
meta: &'a MetaRequestDto,
) -> Self {
Self {
db,
table,
meta,
conditions: vec![],
search_field: "name".to_string(),
select_fields: None,
fetch_fields: None,
cast_thing_fields: false,
}
}
pub fn search_field(mut self, field: &'a str) -> Self {
self.search_field = field.to_string();
self
}
pub fn search_field(mut self, field: &'a str) -> Self {
self.search_field = field.to_string();
self
}
pub fn select_fields(mut self, fields: Vec<&'a str>) -> Self {
self.select_fields = Some(fields);
self
}
pub fn select_fields(mut self, fields: Vec<&'a str>) -> Self {
self.select_fields = Some(fields);
self
}
pub fn fetch_fields(mut self, fields: Vec<&'a str>) -> Self {
self.fetch_fields = Some(fields);
self
}
pub fn fetch_fields(mut self, fields: Vec<&'a str>) -> Self {
self.fetch_fields = Some(fields);
self
}
pub fn with_condition(mut self, condition: &str) -> Self {
self.conditions.push(condition.to_string());
self
}
pub fn with_condition(mut self, condition: &str) -> Self {
self.conditions.push(condition.to_string());
self
}
pub async fn build<T>(self) -> Result<ResponseListSuccessDto<Vec<T>>>
where
T: DeserializeOwned + Serialize,
{
let page = self.meta.page.unwrap_or(1).max(1);
let per_page = self.meta.per_page.unwrap_or(10).max(1);
let start = (page - 1) * per_page;
pub fn with_cast_thing_fields(mut self) -> Self {
self.cast_thing_fields = true;
self
}
let sql = crate::ListQueryBuilder::from_meta(
self.table,
self.meta,
&self.search_field,
self.select_fields,
self.fetch_fields,
)
.build();
pub async fn build<T>(self) -> Result<ResponseListSuccessDto<Vec<T>>>
where
T: DeserializeOwned + Serialize,
{
let page = self.meta.page.unwrap_or(1).max(1);
let per_page = self.meta.per_page.unwrap_or(10).max(1);
let start = (page - 1) * per_page;
let mut query_exec = self.db.query(sql);
if let Some(search) = &self.meta.search {
if !search.is_empty() {
query_exec = query_exec.bind(("search", search.to_lowercase()));
}
}
if let Some(filter_val) = &self.meta.filter {
query_exec = crate::bind_filter_value(query_exec, filter_val.clone());
}
query_exec = query_exec
.bind(("per_page", per_page))
.bind(("start", start));
// --- Data Query ---
let data_query_builder = crate::ListQueryBuilder::from_meta(
self.table,
self.meta,
&self.search_field,
self.select_fields,
self.fetch_fields,
);
let data_sql = data_query_builder.build();
let raw: Vec<T> = query_exec.await?.take(0)?;
// --- Count Query ---
let count_query_builder = crate::ListQueryBuilder::from_meta(
self.table,
self.meta,
&self.search_field,
None, // No select fields for count
None, // No fetch fields for count
);
let count_sql = count_query_builder.build_count();
let mut count_query = self.db.query(format!(
"SELECT count() FROM {} {}",
self.table,
if self.conditions.is_empty() {
"".into()
} else {
format!("WHERE {}", self.conditions.join(" AND "))
}
));
// Combine both queries into a single query string within a transaction for a single database call
let combined_sql = format!(
"BEGIN; {}; {}; COMMIT;",
data_sql,
count_sql
);
if let Some(search) = &self.meta.search {
if !search.is_empty() {
count_query = count_query.bind(("search", search.clone()));
}
}
if let Some(filter_val) = &self.meta.filter {
count_query = crate::bind_filter_value(count_query, filter_val.clone());
}
let mut query_exec = self.db.query(combined_sql.clone());
let count_result: Vec<CountResult> = count_query.await?.take(0)?;
let total = count_result.first().map(|c| c.count);
// Bind parameters for both data and count queries.
// It's assumed that the parameters are named consistently and applied to both.
// The ListQueryBuilder already uses $search, $per_page, $start, $filter.
if let Some(search) = &self.meta.search {
if !search.is_empty() {
query_exec = query_exec.bind(("search", search.to_lowercase()));
}
}
if let Some(filter_val) = &self.meta.filter {
query_exec = crate::bind_filter_value(query_exec, filter_val.clone());
}
query_exec = query_exec
.bind(("per_page", per_page))
.bind(("start", start));
Ok(ResponseListSuccessDto {
data: raw,
meta: Some(MetaResponseDto {
page: Some(page),
per_page: Some(per_page),
total,
}),
})
}
let query_debug_str = format!("{:?}", &query_exec);
let mut response = query_exec.await.map_err(|e| {
tracing::error!(
query = %combined_sql, // `combined_sql` is cloned, so it can be borrowed here
full_query_object = %query_debug_str,
"Failed to execute combined query: {:?}", e
);
e
})?;
// Extract results: first for the data, then for the count
let raw: Vec<T> = response.take(0)?; // First result is the data
let count_result: Vec<CountResult> = response.take(1)?; // Second result is the count
let total = count_result.first().map(|c| c.count);
Ok(ResponseListSuccessDto {
data: raw,
meta: Some(MetaResponseDto {
page: Some(page),
per_page: Some(per_page),
total,
}),
})
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use serde_json::json;
+130
View File
@@ -0,0 +1,130 @@
use serde::{self, Deserialize, Deserializer, Serializer};
use surrealdb::sql::Thing;
use serde::de::{self};
use serde::ser::Serialize;
use serde_json::Value;
use std::str::FromStr;
pub fn thing_or_string<'de, D>(deserializer: D) -> Result<Thing, D::Error>
where
D: Deserializer<'de>,
{
let v = Value::deserialize(deserializer)?;
match &v {
Value::Object(map) => {
if let Some(id_val) = map.get("Id") {
if let Value::Object(id_map) = id_val {
if let Some(Value::String(s)) = id_map.get("String") {
return Thing::from_str(s).map_err(|e| {
de::Error::custom(format!("Thing::from_str error: {e:?}"))
});
}
}
}
serde_json::from_value(v).map_err(de::Error::custom)
}
Value::String(s) => {
if s.is_empty() {
Thing::from_str("unknown:empty")
.map_err(|e| de::Error::custom(format!("Thing::from_str error: {e:?}")))
} else {
Thing::from_str(s)
.map_err(|e| de::Error::custom(format!("Thing::from_str error: {e:?}")))
}
}
_ => Err(de::Error::custom(
"Expected SurrealDB Thing object, string, or enum Id::String",
)),
}
}
pub fn option_thing_or_string<'de, D>(
deserializer: D,
) -> Result<Option<Thing>, D::Error>
where
D: Deserializer<'de>,
{
let v = Value::deserialize(deserializer)?;
match &v {
Value::Null => Ok(None),
Value::Object(map) => {
if let Some(id_val) = map.get("Id") {
if let Value::Object(id_map) = id_val {
if let Some(Value::String(s)) = id_map.get("String") {
return Ok(Some(Thing::from_str(s).map_err(|e| {
de::Error::custom(format!("Thing::from_str error: {e:?}"))
})?));
}
}
}
Ok(Some(serde_json::from_value(v).map_err(de::Error::custom)?))
}
Value::String(s) => {
if s.is_empty() {
Ok(None)
} else {
Ok(Some(Thing::from_str(s).map_err(|e| {
de::Error::custom(format!("Thing::from_str error: {e:?}"))
})?))
}
}
_ => Err(de::Error::custom(
"Expected SurrealDB Thing object, string, enum Id::String, or null",
)),
}
}
pub fn string_or_empty_string<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
let v = Value::deserialize(deserializer)?;
match v {
Value::String(s) => Ok(s),
Value::Null => Ok(String::new()),
_ => Err(de::Error::custom("Expected a string or null")),
}
}
pub fn serialize_thing<S>(thing: &Thing, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
thing.to_string().serialize(serializer)
}
pub fn serialize_option_thing<S>(
thing: &Option<Thing>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match thing {
Some(t) => Some(t.to_string()).serialize(serializer),
None => None::<String>.serialize(serializer),
}
}
pub fn serialize_datetime<S>(
datetime: &chrono::DateTime<chrono::Utc>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&datetime.to_rfc3339())
}
pub fn deserialize_datetime<'de, D>(
deserializer: D,
) -> Result<chrono::DateTime<chrono::Utc>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
chrono::DateTime::parse_from_rfc3339(&s)
.map_err(de::Error::custom)
.map(|dt| dt.with_timezone(&chrono::Utc))
}
+905
View File
@@ -0,0 +1,905 @@
#!/bin/bash
# ==============================================================================
# IMPHNEN API Comprehensive Test Suite (Bash Version)
# ==============================================================================
BASE_URL="http://127.0.0.1:4099"
TEST_EMAIL="admin@example.com"
TEST_PASSWORD="password"
declare -A ALL_USERS=(
["admin@example.com"]="Admin"
["staff@example.com"]="Staff"
["user@example.com"]="User"
["mentor@example.com"]="Mentor User"
)
START_SERVER=false
SKIP_BASIC=false
SKIP_COMPREHENSIVE=false
SKIP_CRUD=false
GENERATE_REPORT=false
VERBOSE=false
while getopts "sbcrgvh" opt; do
case ${opt} in
s ) START_SERVER=true ;;
b ) SKIP_BASIC=true ;;
c ) SKIP_COMPREHENSIVE=true ;;
r ) SKIP_CRUD=true ;;
g ) GENERATE_REPORT=true ;;
v ) VERBOSE=true ;;
h )
echo "IMPHNEN API Test Suite"
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " -s Start server automatically"
echo " -b Skip basic tests (auth, error handling)"
echo " -c Skip comprehensive tests (users, roles, mentors, etc.)"
echo " -r Skip CRUD and advanced tests"
echo " -g Generate JSON test report"
echo " -v Verbose output (show all INFO logs)"
echo " -h Show this help message"
echo ""
echo "Examples:"
echo " $0 # Run all tests"
echo " $0 -s # Start server and run all tests"
echo " $0 -g # Run tests and generate report"
echo " $0 -sv # Start server with verbose output"
echo " $0 -bc # Run only public endpoint tests"
exit 0
;;
\? ) echo "Invalid option: -$OPTARG" >&2; echo "Use -h for help" >&2; exit 1 ;;
esac
done
if ! command -v curl &> /dev/null; then
echo "Error: 'curl' tidak ditemukan. Mohon install terlebih dahulu." >&2
exit 1
fi
if ! command -v jq &> /dev/null; then
echo "Error: 'jq' tidak ditemukan. Mohon install terlebih dahulu." >&2
exit 1
fi
TEST_START_TIME=$(date +%s)
AUTH_TOKEN=""
SERVER_PID=""
TEST_RESULTS=()
FALED_TESTS_SUMMARY=()
PASS_COUNT=0
FAIL_COUNT=0
TEST_TESTIMONIAL_ID=""
CYAN='\033[0;36m'
YELLOW='\033[0;33m'
GREEN='\033[0;32m'
RED='\033[0;31m'
BLUE='\033[0;34m'
NC='\033[0m'
cleanup() {
if [ -n "$SERVER_PID" ]; then
printf "\n${YELLOW}Menghentikan proses server...${NC}\n"
kill "$SERVER_PID" &>/dev/null
fi
}
trap cleanup EXIT
write_test_log() {
local level=$1
local message=$2
local color=$NC
case $level in
"SUCCESS") color=$GREEN ;;
"ERROR") color=$RED ;;
"WARN") color=$YELLOW ;;
"INFO") color=$CYAN ;;
esac
if [[ "$VERBOSE" = true || "$level" != "INFO" ]]; then
printf "[$(date +'%H:%M:%S')] [${color}%-7s${NC}] %s\n" "$level" "$message" >&2
fi
}
test_api_endpoint() {
local test_name=$1
local method=$2
local endpoint=$3
local expected_status=$4
local body=$5
local require_auth=$6
local headers=(-H "Content-Type: application/json")
if [[ "$require_auth" = true && -n "$AUTH_TOKEN" ]]; then
headers+=(-H "Authorization: Bearer $AUTH_TOKEN")
elif [[ "$require_auth" = true && -z "$AUTH_TOKEN" ]]; then
write_test_log "WARN" "$test_name - Dilewati: token autentikasi tidak tersedia"
return
fi
local start_req_time=$(date +%s%3N)
response=$(curl -s -w "\n%{http_code}" -X "$method" "${headers[@]}" -d "$body" "$BASE_URL$endpoint")
http_status=$(echo "$response" | tail -n1)
response_body=$(echo "$response" | sed '$d')
local end_req_time=$(date +%s%3N)
local duration=$((end_req_time - start_req_time))
local status="FAIL"
local error_msg=""
if [ "$http_status" -eq "$expected_status" ]; then
status="PASS"
((PASS_COUNT++))
write_test_log "SUCCESS" "$test_name - Sukses (Status: $http_status, Waktu: ${duration}ms)"
else
status="FAIL"
((FAIL_COUNT++))
write_test_log "ERROR" " Request Body: $body"
write_test_log "ERROR" " Response Body: $response_body"
error_msg="Status yang diharapkan $expected_status, tetapi mendapat $http_status."
write_test_log "ERROR" "$test_name - Gagal: $error_msg"
FAILED_TESTS_SUMMARY+=("$test_name - $error_msg")
fi
result_json=$(jq -n --arg name "$test_name" --arg ep "$endpoint" --arg meth "$method" \
--arg stat "$status" --arg code "$http_status" --arg dur "$duration" \
--arg err "$error_msg" \
'{TestName: $name, Endpoint: $ep, Method: $meth, Status: $stat, StatusCode: $code, ResponseTimeMs: $dur, Error: $err}')
TEST_RESULTS+=("$result_json")
# Return response_body for further processing if needed by the caller
}
test_server_connection() {
curl -s --head "$BASE_URL/v1/cms/landing/events" > /dev/null
return $?
}
clear_database() {
write_test_log "INFO" "Membersihkan database via WebSocket..."
if ! cargo run --bin clear_db_test --release; then
write_test_log "ERROR" "Gagal membersihkan database."
exit 1
fi
write_test_log "SUCCESS" "Pembersihan database selesai."
}
get_auth_token() {
write_test_log "INFO" "Mengautentikasi test user..."
local login_data
login_data=$(jq -n --arg email "$TEST_EMAIL" --arg pass "$TEST_PASSWORD" '{email: $email, password: $pass}')
local headers=(-H "Content-Type: application/json")
local start_req_time=$(date +%s%3N)
response=$(curl -s -w "\n%{http_code}" -X "POST" "${headers[@]}" -d "$login_data" "$BASE_URL/v1/auth/login")
local http_status=$(echo "$response" | tail -n1)
local response_body=$(echo "$response" | sed '$d')
local end_req_time=$(date +%s%3N)
local duration=$((end_req_time - start_req_time))
if [ "$http_status" -eq 200 ]; then
if echo "$response_body" | jq . > /dev/null 2>&1; then
AUTH_TOKEN=$(echo "$response_body" | jq -r '.data.token.access_token // empty')
if [[ -n "$AUTH_TOKEN" && "$AUTH_TOKEN" != "null" ]]; then
write_test_log "SUCCESS" "✓ User Authentication - Sukses (Status: $http_status, Waktu: ${duration}ms)"
write_test_log "SUCCESS" "Autentikasi berhasil"
((PASS_COUNT++))
else
write_test_log "ERROR" "Autentikasi gagal - token tidak ditemukan dalam response"
AUTH_TOKEN=""
((FAIL_COUNT++))
fi
else
write_test_log "ERROR" "Autentikasi gagal - response bukan JSON valid"
AUTH_TOKEN=""
((FAIL_COUNT++))
fi
else
write_test_log "ERROR" "✗ User Authentication - Gagal (Status: $http_status, Waktu: ${duration}ms)"
AUTH_TOKEN=""
((FAIL_COUNT++))
fi
local status="PASS"
local error_msg=""
if [ "$http_status" -ne 200 ] || [[ -z "$AUTH_TOKEN" ]]; then
status="FAIL"
error_msg="Authentication failed"
fi
result_json=$(jq -n --arg name "User Authentication" --arg ep "/v1/auth/login" --arg meth "POST" \
--arg stat "$status" --arg code "$http_status" --arg dur "$duration" \
--arg err "$error_msg" \
'{TestName: $name, Endpoint: $ep, Method: $meth, Status: $stat, StatusCode: $code, ResponseTimeMs: $dur, Error: $err}')
TEST_RESULTS+=("$result_json")
}
test_all_users_login_performance() {
printf "\n${CYAN}=== Menguji Login Performance Semua User ===${NC}\n"
local total_login_time=0
local successful_logins=0
local failed_logins=0
local email="admin@example.com"
local fullname="${ALL_USERS[$email]}"
write_test_log "INFO" "Testing login for: $fullname ($email)"
local login_data
login_data=$(jq -n --arg email "$email" --arg pass "$TEST_PASSWORD" '{email: $email, password: $pass}')
local start_time=$(date +%s%3N)
response=$(curl -s -w "\n%{http_code}" -X "POST" \
-H "Content-Type: application/json" \
-d "$login_data" \
"$BASE_URL/v1/auth/login")
local http_status=$(echo "$response" | tail -n1)
local response_body=$(echo "$response" | sed '$d')
local end_time=$(date +%s%3N)
local duration=$((end_time - start_time))
total_login_time=$((total_login_time + duration))
if [ "$http_status" -eq 200 ]; then
if echo "$response_body" | jq -e '.data.token.access_token' > /dev/null 2>&1; then
((successful_logins++))
((PASS_COUNT++))
write_test_log "SUCCESS" "✓ Login $fullname - ${duration}ms"
result_json=$(jq -n --arg name "Login Performance - $fullname" --arg ep "/v1/auth/login" --arg meth "POST" \
--arg stat "PASS" --arg code "$http_status" --arg dur "$duration" \
--arg err "" \
'{TestName: $name, Endpoint: $ep, Method: $meth, Status: $stat, StatusCode: $code, ResponseTimeMs: $dur, Error: $err}')
TEST_RESULTS+=("$result_json")
else
((failed_logins++))
((FAIL_COUNT++))
write_test_log "ERROR" "✗ Login $fullname - No token (${duration}ms)"
FAILED_TESTS_SUMMARY+=("✗ Login $fullname - No token in response")
fi
else
((failed_logins++))
((FAIL_COUNT++))
write_test_log "ERROR" "✗ Login $fullname - HTTP $http_status (${duration}ms)"
FAILED_TESTS_SUMMARY+=("✗ Login $fullname - HTTP $http_status")
fi
local total_users=1
local avg_login_time=0
if [ "$total_users" -gt 0 ]; then
avg_login_time=$((total_login_time / total_users))
fi
printf "\n${BLUE}=== Login Performance Summary ===${NC}\n"
printf "Total Users Tested: %d\n" "$total_users"
printf "${GREEN}Successful Logins: %d${NC}\n" "$successful_logins"
printf "${RED}Failed Logins: %d${NC}\n" "$failed_logins"
printf "${BLUE}Average Login Time: %dms${NC}\n" "$avg_login_time"
printf "${BLUE}Total Login Time: %dms${NC}\n" "$total_login_time"
if [ "$avg_login_time" -lt 2000 ]; then
printf "${GREEN}✅ Performance Status: EXCELLENT (< 2s average)${NC}\n"
elif [ "$avg_login_time" -lt 5000 ]; then
printf "${YELLOW}⚠️ Performance Status: GOOD (2-5s average)${NC}\n"
else
printf "${RED}❌ Performance Status: POOR (> 5s average)${NC}\n"
fi
printf "\n"
}
test_with_user() {
local email=$1
local fullname=$2
local test_name=$3
write_test_log "INFO" "Testing $test_name dengan user: $fullname ($email)"
local login_data
login_data=$(jq -n --arg email "$email" --arg pass "$TEST_PASSWORD" '{email: $email, password: $pass}')
local start_time=$(date +%s%3N)
response=$(curl -s -w "\n%{http_code}" -X "POST" \
-H "Content-Type: application/json" \
-d "$login_data" \
"$BASE_URL/v1/auth/login")
local http_status=$(echo "$response" | tail -n1)
local response_body=$(echo "$response" | sed '$d')
local end_time=$(date +%s%3N)
local duration=$((end_time - start_time))
if [ "$http_status" -eq 200 ]; then
if echo "$response_body" | jq -e '.data.token.access_token' > /dev/null 2>&1; then
local user_auth_token=$(echo "$response_body" | jq -r '.data.token.access_token')
write_test_log "SUCCESS" "✓ Login $fullname berhasil - ${duration}ms"
local me_response
me_response=$(curl -s -w "\n%{http_code}" -X "GET" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $user_auth_token" \
"$BASE_URL/v1/users/me")
local me_status=$(echo "$me_response" | tail -n1)
local me_body=$(echo "$me_response" | sed '$d')
if [ "$me_status" -eq 200 ]; then
((PASS_COUNT++))
write_test_log "SUCCESS" "✓ Get profile $fullname berhasil"
local user_email=$(echo "$me_body" | jq -r '.data.email // empty')
local user_name=$(echo "$me_body" | jq -r '.data.fullname // empty')
if [ "$user_email" = "$email" ]; then
write_test_log "SUCCESS" "✓ User data verified: $user_name ($user_email)"
else
write_test_log "WARN" "⚠ User data mismatch: expected $email, got $user_email"
fi
else
((FAIL_COUNT++))
write_test_log "ERROR" "✗ Get profile $fullname gagal - HTTP $me_status"
FAILED_TESTS_SUMMARY+=("✗ Get profile $fullname - HTTP $me_status")
fi
else
((FAIL_COUNT++))
write_test_log "ERROR" "✗ Login $fullname - No token (${duration}ms)"
FAILED_TESTS_SUMMARY+=("✗ Login $fullname - No token in response")
fi
else
write_test_log "ERROR" "✗ Login $fullname gagal - HTTP $http_status (${duration}ms)"
FAILED_TESTS_SUMMARY+=("✗ Login $fullname - HTTP $http_status")
fi
}
test_all_users_individually() {
printf "\n${CYAN}=== Menguji Semua User Secara Individual ===${NC}\n"
for email in "${!ALL_USERS[@]}"; do
local fullname="${ALL_USERS[$email]}"
test_with_user "$email" "$fullname" "Individual User Test"
echo ""
done
}
test_comprehensive_with_user() {
printf "\n${CYAN}=== Comprehensive Test untuk $fullname ($email) ===${NC}\n"
local login_data
login_data=$(jq -n --arg email "$email" --arg pass "$TEST_PASSWORD" '{email: $email, password: $pass}')
local start_time=$(date +%s%3N)
response=$(curl -s -w "\n%{http_code}" -X "POST" \
-H "Content-Type: application/json" \
-d "$login_data" \
"$BASE_URL/v1/auth/login")
local http_status=$(echo "$response" | tail -n1)
local response_body=$(echo "$response" | sed '$d')
local end_time=$(date +%s%3N)
local duration=$((end_time - start_time))
if [ "$http_status" -eq 200 ]; then
if echo "$response_body" | jq -e '.data.token.access_token' > /dev/null 2>&1; then
user_token=$(echo "$response_body" | jq -r '.data.token.access_token')
write_test_log "SUCCESS" "✓ Login $fullname berhasil - ${duration}ms"
local original_auth_token="$AUTH_TOKEN"
AUTH_TOKEN="$user_token"
printf "\n${BLUE}--- Testing dengan $fullname (Expected results berdasarkan role) ---${NC}\n"
test_api_endpoint "Get Current User Profile - $fullname" "GET" "/v1/users/me" 200 "" true
case "$email" in
"admin@example.com")
test_api_endpoint "Get Users List - $fullname" "GET" "/v1/users" 200 "" true
test_api_endpoint "Get Roles List - $fullname" "GET" "/v1/roles" 200 "" true
test_api_endpoint "Get Permissions List - $fullname" "GET" "/v1/permissions" 200 "" true
test_api_endpoint "Get Mentors List - $fullname" "GET" "/v1/mentors" 200 "" true
test_api_endpoint "Get Mentor Me - $fullname" "GET" "/v1/mentors/me" 403 "" true # Admin is not a mentor
test_api_endpoint "Get Mentor Status - $fullname" "GET" "/v1/mentors/status" 403 "" true # Admin is not a mentor
test_api_endpoint "Get Gacha Items - $fullname" "GET" "/v1/gacha/items" 200 "" true
test_api_endpoint "Execute Gacha Roll - $fullname" "POST" "/v1/gacha/rolls/execute" 200 "" true
local testimonial_data
testimonial_data=$(jq -n --arg content "Test testimonial by $fullname $(date +%s)" '{role: "Student", content: $content}')
test_api_endpoint "Create Testimonial - $fullname" "POST" "/v1/cms/landing/testimonials/create" 201 "$testimonial_data" true
;;
"staff@example.com")
test_api_endpoint "Get Users List - $fullname" "GET" "/v1/users" 200 "" true
test_api_endpoint "Get Roles List - $fullname" "GET" "/v1/roles" 200 "" true
test_api_endpoint "Get Permissions List - $fullname" "GET" "/v1/permissions" 200 "" true
test_api_endpoint "Get Mentors List - $fullname" "GET" "/v1/mentors" 200 "" true
test_api_endpoint "Get Gacha Items - $fullname" "GET" "/v1/gacha/items" 200 "" true
test_api_endpoint "Execute Gacha Roll - $fullname" "POST" "/v1/gacha/rolls/execute" 200 "" true
local testimonial_data
testimonial_data=$(jq -n --arg content "Test testimonial by $fullname $(date +%s)" '{role: "Student", content: $content}')
test_api_endpoint "Create Testimonial - $fullname" "POST" "/v1/cms/landing/testimonials/create" 201 "$testimonial_data" true
;;
"mentor@example.com")
test_api_endpoint "Get Users List - $fullname" "GET" "/v1/users" 403 "" true
test_api_endpoint "Get Roles List - $fullname" "GET" "/v1/roles" 403 "" true
test_api_endpoint "Get Permissions List - $fullname" "GET" "/v1/permissions" 403 "" true
test_api_endpoint "Get Mentors List - $fullname" "GET" "/v1/mentors" 200 "" true
test_api_endpoint "Get Mentor Me - $fullname" "GET" "/v1/mentors/me" 200 "" true
test_api_endpoint "Get Mentor Status - $fullname" "GET" "/v1/mentors/status" 200 "" true
test_api_endpoint "Get Gacha Items - $fullname" "GET" "/v1/gacha/items" 200 "" true
test_api_endpoint "Execute Gacha Roll - $fullname" "POST" "/v1/gacha/rolls/execute" 200 "" true
local testimonial_data
testimonial_data=$(jq -n --arg content "Test testimonial by $fullname $(date +%s)" '{role: "Student", content: $content}')
test_api_endpoint "Create Testimonial - $fullname" "POST" "/v1/cms/landing/testimonials/create" 201 "$testimonial_data" true
;;
"user@example.com")
test_api_endpoint "Get Users List - $fullname" "GET" "/v1/users" 403 "" true
test_api_endpoint "Get Roles List - $fullname" "GET" "/v1/roles" 403 "" true
test_api_endpoint "Get Permissions List - $fullname" "GET" "/v1/permissions" 403 "" true
test_api_endpoint "Get Mentors List - $fullname" "GET" "/v1/mentors" 200 "" true
test_api_endpoint "Get Mentor Me - $fullname" "GET" "/v1/mentors/me" 403 "" true # User is not a mentor
test_api_endpoint "Get Mentor Status - $fullname" "GET" "/v1/mentors/status" 403 "" true # User is not a mentor
test_api_endpoint "Get Gacha Items - $fullname" "GET" "/v1/gacha/items" 200 "" true
test_api_endpoint "Execute Gacha Roll - $fullname" "POST" "/v1/gacha/rolls/execute" 200 "" true
local testimonial_data
testimonial_data=$(jq -n --arg content "Test testimonial by $fullname $(date +%s)" '{role: "Student", content: $content}')
test_api_endpoint "Create Testimonial - $fullname" "POST" "/v1/cms/landing/testimonials/create" 201 "$testimonial_data" true
;;
esac
test_api_endpoint "Events with Advanced Filter - $fullname" "GET" "/v1/cms/landing/events?filter=online&filter_by=is_online" 200 "" false
test_api_endpoint "Testimonials with Search - $fullname" "GET" "/v1/cms/landing/testimonials?search=test" 200 "" false
case "$email" in
"admin@example.com"|"staff@example.com")
test_api_endpoint "Users with Sort - $fullname" "GET" "/v1/users?sort_by=created_at&order=DESC" 200 "" true
;;
*)
test_api_endpoint "Users with Sort - $fullname" "GET" "/v1/users?sort_by=created_at&order=DESC" 403 "" true
;;
esac
AUTH_TOKEN="$original_auth_token"
write_test_log "SUCCESS" "✓ Comprehensive test untuk $fullname selesai"
else
write_test_log "ERROR" "✗ Login $fullname gagal - No token (${duration}ms)"
fi
else
write_test_log "ERROR" "✗ Login $fullname gagal - HTTP $http_status (${duration}ms)"
fi
}
test_all_endpoints_with_all_users() {
printf "\n${CYAN}=== Menjalankan Semua Test dengan Semua User ===${NC}\n"
for email in "${!ALL_USERS[@]}"; do
local fullname="${ALL_USERS[$email]}"
test_comprehensive_with_user "$email" "$fullname"
printf "\n${BLUE}--- Selesai testing dengan $fullname ---${NC}\n\n"
done
}
test_public_endpoints() {
printf "\n${CYAN}=== Menguji Public Endpoints ===${NC}\n"
test_api_endpoint "Get Events List" "GET" "/v1/cms/landing/events" 200
test_api_endpoint "Get Testimonials List" "GET" "/v1/cms/landing/testimonials" 200
}
test_authentication_endpoints() {
printf "\n${CYAN}=== Menguji Authentication Endpoints ===${NC}\n"
get_auth_token
local invalid_login
invalid_login=$(jq -n '{email: "invalid@example.com", password: "wrongpassword"}')
test_api_endpoint "Invalid Login Test" "POST" "/v1/auth/login" 401 "$invalid_login"
# User registration and verification tests currently rely on external email service or OTP logic
# that is not easily testable in a simple curl script without actual email sending/receiving.
# Skipping these tests for now.
# local register_email="test_user_$(date +%s%N)@example.com"
# local register_data=$(jq -n --arg email "$register_email" --arg pass "$TEST_PASSWORD" --arg fullname "Test Register" --arg phone "081234567899" '{email: $email, password: $pass, fullname: $fullname, phone_number: $phone}')
# test_api_endpoint "User Registration Test" "POST" "/v1/auth/register" 200 "$register_data"
# local verify_otp_data=$(jq -n --arg email "$register_email" --arg otp "123456" '{email: $email, otp: ($otp | tonumber)}')
# test_api_endpoint "Verify Email Test (Invalid OTP)" "POST" "/v1/auth/verify-email" 400 "$verify_otp_data"
local forgot_password_data
forgot_password_data=$(jq -n --arg email "$TEST_EMAIL" '{email: $email}')
test_api_endpoint "Forgot Password Test" "POST" "/v1/auth/forgot" 400 "$forgot_password_data"
local new_password_data
new_password_data=$(jq -n --arg token "some_reset_token" --arg pass "newpassword123!A" '{token: $token, password: $pass}')
test_api_endpoint "New Password Test (Invalid Token)" "POST" "/v1/auth/new-password" 400 "$new_password_data"
local refresh_token=$(curl -s -X POST -H "Content-Type: application/json" -d "$(jq -n --arg email "$TEST_EMAIL" --arg pass "$TEST_PASSWORD" '{email: $email, password: $pass}')" "$BASE_URL/v1/auth/login" | jq -r '.data.token.refresh_token // empty')
if [ -n "$refresh_token" ]; then
local refresh_data
refresh_data=$(jq -n --arg token "$refresh_token" '{refresh_token: $token}')
test_api_endpoint "Refresh Token Test" "POST" "/v1/auth/refresh" 200 "$refresh_data"
else
write_test_log "WARN" "✗ Refresh Token Test - Dilewati: Refresh token tidak tersedia dari login"
fi
}
test_error_handling() {
printf "\n${CYAN}=== Menguji Error Handling ===${NC}\n"
test_api_endpoint "Non-existent Endpoint" "GET" "/v1/nonexistent" 404
test_api_endpoint "Unauthorized Access" "GET" "/v1/users" 401 "" false
}
test_user_management_endpoints() {
printf "\n${CYAN}=== Menguji User Management Endpoints ===${NC}\n"
test_api_endpoint "Get Users List" "GET" "/v1/users" 200 "" true
local test_user_id="c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2"
test_api_endpoint "Get User By ID" "GET" "/v1/users/detail/$test_user_id" 200 "" true
local new_user_email="new_test_user_$(date +%s%N)@example.com"
local new_user_fullname="New Test User $(date +%s%N)"
local new_user_phone="089876543211"
local new_user_password="NewPassword123!"
local new_user_role_id="5713cb37-dc02-4e87-8048-d7a41d352059" # User role ID from seed_users.rs
local create_user_data=$(jq -n \
--arg email "$new_user_email" \
--arg pass "$new_user_password" \
--arg fullname "$new_user_fullname" \
--arg phone "$new_user_phone" \
--arg is_active true \
--arg role_id "$new_user_role_id" \
'{email: $email, password: $pass, fullname: $fullname, phone_number: $phone, is_active: $is_active | fromjson, role_id: $role_id}')
test_api_endpoint "Create New User" "POST" "/v1/users/create" 201 "$create_user_data" true
# Assuming the created user can be fetched by email for update/delete
local created_user_id=$(curl -s -X GET -H "Authorization: Bearer $AUTH_TOKEN" "$BASE_URL/v1/users?search=$new_user_email" | jq -r '.data[0].id // empty')
if [ -n "$created_user_id" ]; then
local updated_user_fullname="Updated Test User $(date +%s%N)"
local updated_user_data=$(jq -n \
--arg email "$new_user_email" \
--arg pass "$new_user_password" \
--arg fullname "$updated_user_fullname" \
--arg phone "$new_user_phone" \
--arg is_active true \
--arg gender "Male" \
--arg birthdate "1990-01-01" \
--arg avatar "https://example.com/avatar.jpg" \
--arg role_id "$new_user_role_id" \
'{email: $email, password: $pass, fullname: $fullname, phone_number: $phone, is_active: $is_active | fromjson, gender: $gender, birthdate: $birthdate, avatar: $avatar, role_id: $role_id}')
test_api_endpoint "Update User" "PUT" "/v1/users/update/$created_user_id" 200 "$updated_user_data" true
local set_active_data=$(jq -n --arg is_active false '{is_active: $is_active | fromjson}')
test_api_endpoint "Deactivate User" "PUT" "/v1/users/activate/$created_user_id" 200 "$set_active_data" true
local set_active_data=$(jq -n --arg is_active true '{is_active: $is_active | fromjson}')
test_api_endpoint "Reactivate User" "PUT" "/v1/users/activate/$created_user_id" 200 "$set_active_data" true
test_api_endpoint "Delete User" "DELETE" "/v1/users/delete/$created_user_id" 200 "" true
else
write_test_log "WARN" "✗ Skipping User Update/Delete tests: Failed to retrieve ID of newly created user."
fi
}
test_crud_operations() {
printf "\n${CYAN}=== Menguji CRUD Operations ===${NC}\n"
local testimonial_data
testimonial_data=$(jq -n --arg content "Test testimonial via Bash $(date +%s)" '{role: "Student", content: $content}')
local testimonial_response=$(test_api_endpoint "Create Testimonial" "POST" "/v1/cms/landing/testimonials/create" 201 "$testimonial_data" true)
TEST_TESTIMONIAL_ID=$(echo "$testimonial_response" | jq -r '.data.id // empty')
write_test_log "INFO" "TEST_TESTIMONIAL_ID: $TEST_TESTIMONIAL_ID"
write_test_log "INFO" "Captured Testimonial ID: $TEST_TESTIMONIAL_ID"
sleep 0.2
local permission_data
permission_data=$(jq -n --arg name "Test Permission $(date +%s)" '{name: $name}')
test_api_endpoint "Create Permission" "POST" "/v1/permissions/create" 201 "$permission_data" true
local gacha_item_data
gacha_item_data=$(jq -n --arg name "Test Item $(date +%s)" '{name: $name, image_url: "https://example.com/id.jpg"}')
test_api_endpoint "Create Gacha Item" "POST" "/v1/gacha/items/create" 201 "$gacha_item_data" true
local event_data
event_data=$(jq -n --arg name "Test Event $(date +%s)" '{
name: $name,
description: "Test event description",
detail_link: "https://example.com/event",
price: 50.0,
is_online: true,
start_date: "2025-12-01T10:00:00Z",
end_date: "2025-12-01T16:00:00Z",
location: null
}')
test_api_endpoint "Create Event" "POST" "/v1/cms/landing/events/create" 201 "$event_data" true
}
test_roles_and_permissions() {
printf "\n${CYAN}=== Menguji Roles & Permissions Endpoints ===${NC}\n"
test_api_endpoint "Get Roles List" "GET" "/v1/roles" 200 "" true
test_api_endpoint "Get Permissions List" "GET" "/v1/permissions" 200 "" true
local test_role_id="3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a"
test_api_endpoint "Get Role By ID" "GET" "/v1/roles/detail/$test_role_id" 200 "" true
}
test_mentor_endpoints() {
printf "\n${CYAN}=== Menguji Mentor Endpoints ===${NC}\n"
test_api_endpoint "Get Mentors List" "GET" "/v1/mentors" 200 "" true
# These tests are run with AUTH_TOKEN set to admin. Since admin is not a mentor, these should be 403.
test_api_endpoint "Get Mentor Me" "GET" "/v1/mentors/me" 403 "" true
test_api_endpoint "Get Mentor Status" "GET" "/v1/mentors/status" 403 "" true
local test_mentor_id="e6f78d23-83bf-5c2b-bcd4-001345678901"
test_api_endpoint "Get Mentor By ID" "GET" "/v1/mentors/detail/$test_mentor_id" 200 "" true
local mentor_register_data
mentor_register_data=$(jq -n --arg email "test.mentor.$(date +%s%N)@example.com" '{
identity_and_verification: {
legal_name: "Test Mentor Legal Name",
identity_document_url: "https://example.com/id.jpg",
phone_for_verification: "+1234567890"
},
professional_profile: {
bio: "Test mentor bio",
linkedin_url: "https://linkedin.com/in/testmentor",
industries: ["Technology", "Software"],
expertise: ["JavaScript", "Python"],
languages: ["English", "Indonesian"],
current_company: "Test Company",
current_role: "Senior Developer",
years_of_experience: 5
},
mentoring_logistics: {
topics_of_interest: ["Career Development", "Technical Skills"],
preferred_mentee_level: ["Junior", "Mid-level"],
preferred_mentoring_formats: ["1-on-1", "Group"],
availability_commitment: "2-3 hours per week",
mentoring_rate: {
amount: 100000,
currency: "IDR",
per_duration: "hour"
}
},
email: $email
}')
test_api_endpoint "Register as Mentor" "POST" "/v1/mentors/register" 422 "$mentor_register_data" true
}
test_events_endpoints() {
printf "\n${CYAN}=== Menguji Events Endpoints ===${NC}\n"
test_api_endpoint "Get Events with Pagination" "GET" "/v1/cms/landing/events?page=1&per_page=5" 200
test_api_endpoint "Get Events with Search" "GET" "/v1/cms/landing/events?search=tech" 200
local test_event_id="e1a2b3c4-5d6e-7f8g-9h0i-1j2k3l4m5n6o"
test_api_endpoint "Get Event By ID" "GET" "/v1/cms/landing/events/detail/$test_event_id" 200
}
test_testimonials_endpoints() {
printf "\n${CYAN}=== Menguji Testimonials Endpoints ===${NC}\n"
test_api_endpoint "Get Testimonials with Pagination" "GET" "/v1/cms/landing/testimonials?page=1&per_page=5" 200
# Ensure TEST_TESTIMONIAL_ID is not empty before testing
if [ -n "$TEST_TESTIMONIAL_ID" ]; then
test_api_endpoint "Get Testimonial By ID" "GET" "/v1/cms/landing/testimonials/detail/$TEST_TESTIMONIAL_ID" 200
else
write_test_log "WARN" "✗ Get Testimonial By ID - Dilewati: TEST_TESTIMONIAL_ID tidak tersedia"
fi
}
test_gacha_endpoints() {
printf "\n${CYAN}=== Menguji Gacha Endpoints ===${NC}\n"
test_api_endpoint "Get Gacha Items" "GET" "/v1/gacha/items" 200 "" true
test_api_endpoint "Execute Gacha Roll" "POST" "/v1/gacha/rolls/execute" 200 "" true
}
test_advanced_scenarios() {
printf "\n${CYAN}=== Menguji Advanced Scenarios ===${NC}\n"
test_api_endpoint "Events with Advanced Filter" "GET" "/v1/cms/landing/events?filter=online&filter_by=is_online" 200
test_api_endpoint "Users with Sort" "GET" "/v1/users?sort_by=created_at&order=DESC" 200 "" true
test_api_endpoint "Testimonials with Search" "GET" "/v1/cms/landing/testimonials?search=test" 200
local mentor_register_data
mentor_register_data=$(jq -n --arg email "test.mentor.$(date +%s%N)@example.com" '{
identity_and_verification: {
legal_name: "Test Mentor Legal Name",
identity_document_url: "https://example.com/id.jpg",
phone_for_verification: "+1234567890"
},
professional_profile: {
bio: "Test mentor bio",
linkedin_url: "https://linkedin.com/in/testmentor",
industries: ["Technology", "Software"],
expertise: ["JavaScript", "Python"],
languages: ["English", "Indonesian"],
current_company: "Test Company",
current_role: "Senior Developer",
years_of_experience: 5
},
mentoring_logistics: {
topics_of_interest: ["Career Development", "Technical Skills"],
preferred_mentee_level: ["Junior", "Mid-level"],
preferred_mentoring_formats: ["1-on-1", "Group"],
availability_commitment: "2-3 hours per week",
mentoring_rate: {
amount: 100000,
currency: "IDR",
per_duration: "hour"
}
},
email: $email
}')
test_api_endpoint "Register as Mentor" "POST" "/v1/mentors/register" 422 "$mentor_register_data" true
test_api_endpoint "Invalid POST to GET endpoint" "POST" "/v1/cms/landing/events" 405
test_api_endpoint "Invalid PUT with Invalid ID" "PUT" "/v1/users/update/some_invalid_id" 400 "" true
}
show_test_summary() {
printf "\n${YELLOW}=== Test Coverage Summary ===${NC}\n"
printf "📋 Authentication: Login, Forgot Password, OTP\n"
printf "👥 Users: List, Details, Profile Management\n"
printf "🔐 Roles & Permissions: RBAC System Testing\n"
printf "👨‍🏫 Mentors: Registration, Profile, Status\n"
printf "📅 Events: CRUD Operations, Filtering\n"
printf "💬 Testimonials: Management & Creation\n"
printf "🎲 Gacha: Items, Rolls, Claims\n"
printf "🔧 Advanced: Pagination, Search, Edge Cases\n"
printf "❌ Error Handling: 401, 404, Invalid Requests\n"
printf "\n"
}
printf "${CYAN}=== IMPHNEN API Comprehensive Test Suite ===${NC}\n"
printf "${YELLOW}Base URL: %s${NC}\n" "$BASE_URL"
show_test_summary
if [ "$START_SERVER" = true ]; then
if ! command -v cargo &> /dev/null; then
write_test_log "ERROR" "Perintah 'cargo' tidak ditemukan. Tidak bisa memulai server."
exit 1
fi
printf "${YELLOW}Memulai server backend...${NC}\n"
cargo run --bin api &
SERVER_PID=$!
printf "${YELLOW}Menunggu server siap...${NC}\n"
retries=0
max_retries=100 # Increased from 15 to 30
until test_server_connection; do
((retries++))
if [ $retries -ge $max_retries ]; then
write_test_log "ERROR" "Gagal memulai server dalam timeout\. Cek output terminal untuk detail\."
exit 1
fi
sleep 2
done
write_test_log "SUCCESS" "Server berjalan!"
else
if ! test_server_connection; then
write_test_log "ERROR" "Server tidak berjalan di $BASE_URL"
write_test_log "WARN" "Silakan jalankan server secara manual atau gunakan flag -s"
exit 1
fi
write_test_log "SUCCESS" "Server sudah berjalan di $BASE_URL"
fi
clear_database
printf "\n${CYAN}=== Menjalankan Seeders ===${NC}\n"
if ! cargo run --bin seeder; then
write_test_log "ERROR" "Gagal menjalankan seeder roles permissions."
exit 1
fi
write_test_log "SUCCESS" "Seeders selesai."
printf "\n${CYAN}=== Menampilkan User yang Tersedia ===${NC}\n"
for email in "${!ALL_USERS[@]}"; do
fullname="${ALL_USERS[$email]}"
printf "${BLUE}$fullname${NC} - ${email}\n"
done
printf "\n"
test_public_endpoints
test_all_users_login_performance
test_all_users_individually
if [ "$SKIP_BASIC" = false ]; then
test_authentication_endpoints
test_error_handling
fi
if [[ "$SKIP_CRUD" = false && -n "$AUTH_TOKEN" ]]; then
test_crud_operations # This will now set TEST_TESTIMONIAL_ID
fi
if [ "$SKIP_COMPREHENSIVE" = false ]; then
test_all_endpoints_with_all_users
fi
if [[ "$SKIP_COMPREHENSIVE" = false && -n "$AUTH_TOKEN" ]]; then
printf "\n${CYAN}=== Test Comprehensive dengan Admin Token ===${NC}\n"
test_user_management_endpoints
test_roles_and_permissions
test_mentor_endpoints
test_events_endpoints
test_testimonials_endpoints # This will now use TEST_TESTIMONIAL_ID
test_gacha_endpoints
fi
test_advanced_scenarios
TEST_END_TIME=$(date +%s)
TOTAL_DURATION=$((TEST_END_TIME - TEST_START_TIME))
TOTAL_TESTS=$((PASS_COUNT + FAIL_COUNT))
SUCCESS_RATE="0"
if [ "$TOTAL_TESTS" -gt 0 ]; then
SUCCESS_RATE=$(( (PASS_COUNT * 100) / TOTAL_TESTS ))
fi
if [ "$GENERATE_REPORT" = true ]; then
printf "\n${CYAN}=== Membuat Laporan Tes ===${NC}\n"
all_results_json=$(printf "%s," "${TEST_RESULTS[@]}")
all_results_json="[${all_results_json%,}]"
report_file="api-test-report-$(date +'%Y%m%d-%H%M%S').json"
jq -n --arg start "$(date -d @$TEST_START_TIME +'%Y-%m-%d %H:%M:%S')" \
--arg end "$(date -d @$TEST_END_TIME +'%Y-%m-%d %H:%M:%S')" \
--arg dur "$TOTAL_DURATION" \
--arg url "$BASE_URL" \
--arg total "$TOTAL_TESTS" \
--arg pass "$PASS_COUNT" \
--arg fail "$FAIL_COUNT" \
--arg rate "${SUCCESS_RATE}%" \
--argjson results "$all_results_json" \
'{
TestRun: {StartTime: $start, EndTime: $end, DurationSec: $dur, BaseUrl: $url},
Summary: {TotalTests: $total, PassedTests: $pass, FailedTests: $fail, SuccessRate: $rate},
Results: $results
}' > "$report_file"
printf "${BLUE}Laporan tes detail disimpan di: %s${NC}\n" "$report_file"
fi
printf "\n${CYAN}=== Ringkasan Test Suite ===${NC}\n"
printf "Total Durasi: %s detik\n" "$TOTAL_DURATION"
printf "Total Tes : %s\n" "$TOTAL_TESTS"
printf "${GREEN}Lolos : %s${NC}\n" "$PASS_COUNT"
printf "${RED}Gagal : %s${NC}\n" "$FAIL_COUNT"
printf "Tingkat Sukses: %s%%\n" "$SUCCESS_RATE"
if [ "$FAIL_COUNT" -gt 0 ]; then
printf "\n${RED}Tes yang Gagal:${NC}\n"
for summary in "${FAILED_TESTS_SUMMARY[@]}"; do
printf " %s\n" "$summary"
done
fi
if [ "$FAIL_COUNT" -eq 0 ]; then
printf "\n${GREEN}Test suite selesai dengan sukses.${NC}\n"
exit 0
else
printf "\n${RED}Test suite selesai dengan beberapa kegagalan.${NC}\n"
exit 1
fi
+23 -10
View File
@@ -1,16 +1,29 @@
[package]
name = "tests"
version = "0.1.0"
edition = "2024"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
imphnen-iam = { version = "0.1.0", path = "../imphnen-iam" }
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" }
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" }
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" }
axum.workspace = true
axum-test.workspace = true
chrono.workspace = true
surrealdb.workspace = true
tokio.workspace = true
tokio.workspace = true
imphnen-iam.workspace = true
imphnen-dimentorin.workspace = true
imphnen-entities.workspace = true
imphnen-libs.workspace = true
imphnen-utils.workspace = true
http-body-util.workspace = true
hyper.workspace = true
hyper-util.workspace = true
serde_json.workspace = true
dotenvy.workspace = true
tower.workspace = true
tracing.workspace = true
surrealdb.workspace = true
serde.workspace = true
strum.workspace = true
uuid.workspace = true
rand.workspace = true
async-trait.workspace = true
chrono.workspace = true
axum.workspace = true
@@ -0,0 +1,758 @@
use axum::{
body::Body,
http::{Method, Request, StatusCode},
response::Response,
routing::post,
Router,
};
use http_body_util::BodyExt; // for `buffer` method
use imphnen_dimentorin::{
mentors_controller,
mentors_dto::{MentorUserRegisterRequestDto, MentorRegisterResponseDto},
};
use imphnen_entities::{AppState, SurrealMemClient, SurrealWsClient};
use imphnen_iam::{RolesRepository, UsersRepository};
use imphnen_iam::{RolesEnum, UsersSchema, RolesSchema};
use imphnen_utils::{hash_password, make_thing, get_iso_date};
use surrealdb::{Uuid, sql::Thing};
use imphnen_libs::{ResourceEnum, surrealdb_init_ws, surrealdb_init_mem, Env};
use dotenvy::dotenv;
use tower::ServiceExt; // Added ServiceExt for .oneshot()
use crate::mock_test::setup_all_test_environment; // Import the new setup function
// Helper function to create a test AppState
async fn setup_app_state() -> AppState {
dotenv().ok();
// Use the surrealdb initialization functions from imphnen_libs
let surrealdb_ws = surrealdb_init_ws().await.expect("Failed to initialize SurrealDB WS client");
let surrealdb_mem = surrealdb_init_mem().await.expect("Failed to initialize SurrealDB MEM client");
let app_state = AppState {
surrealdb_ws,
surrealdb_mem,
};
// Manually seed roles since seed_roles is not directly callable as a repository method
let db = &app_state.surrealdb_ws;
let roles_to_seed = vec![
(RolesEnum::Admin.to_string(), Vec::new()),
(RolesEnum::User.to_string(), Vec::new()),
(RolesEnum::Staff.to_string(), Vec::new()),
(RolesEnum::Mentor.to_string(), Vec::new()),
];
for (name, permissions) in roles_to_seed {
// Check if role already exists to avoid errors on rerun
let existing_role: Option<RolesSchema> = db.query(format!("SELECT * FROM ONLY role WHERE name = '{}'", name)).await.unwrap().take(0).unwrap_or(None);
if existing_role.is_none() {
let role_id = Uuid::new_v4().to_string();
let role = RolesSchema {
id: make_thing(&ResourceEnum::Roles.to_string(), &role_id),
name: name.clone(),
is_deleted: false,
permissions,
created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()),
};
db.create::<RolesSchema>((ResourceEnum::Roles.to_string().as_str(), role_id)).content(role).await.unwrap(); // Corrected syntax
}
}
app_state
}
// Helper function to create a test application (router)
fn app(app_state: AppState) -> Router {
Router::new()
.route("/v1/mentors/register", post(mentors_controller::post_register_mentor))
.with_state(app_state)
}
#[tokio::test]
async fn test_register_new_user_as_mentor_success() {
let app_state = setup_all_test_environment().await; // Use the new setup function
let app = app(app_state.clone());
let test_email = "newmentor@example.com";
let test_password = "Password123!";
let test_fullname = "New Mentor User";
let test_phone = "1234567890";
// Clean up before test
let user_repo = UsersRepository::new(&app_state);
let _ = user_repo.query_delete_user(test_email.to_string()).await;
let dto = MentorUserRegisterRequestDto {
email: test_email.to_string(),
password: test_password.to_string(),
fullname: test_fullname.to_string(),
phone_number: test_phone.to_string(),
identity_and_verification: imphnen_dimentorin::mentors_dto::IdentityAndVerification {
legal_name: "Legal Name".to_string(),
gender: Some("Laki-laki".to_string()),
domicile: Some("Jakarta Selatan".to_string()),
identity_document_url: "http://example.com/id.pdf".to_string(),
phone_for_verification: "0987654321".to_string(),
},
professional_profile: imphnen_dimentorin::mentors_dto::ProfessionalProfile {
bio: "Experienced professional seeking to mentor others in software development.".to_string(),
last_education: Some("S1".to_string()),
linkedin_url: Some("http://linkedin.com/in/mentor".to_string()),
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/portfolio".to_string()),
industries: vec!["Technology".to_string()],
expertise: vec!["Rust".to_string(), "Debugging".to_string()],
languages: vec!["English".to_string()],
current_company: "Acme Corp".to_string(),
current_role: "Senior Engineer".to_string(),
years_of_experience: 5,
},
mentoring_logistics: imphnen_dimentorin::mentors_dto::MentoringLogistics {
topics_of_interest: vec!["Career Development".to_string()],
preferred_mentee_level: vec!["Beginner".to_string()],
preferred_mentoring_formats: vec!["Online".to_string()],
availability_commitment: "5 hours/week".to_string(),
mentoring_rate_amount: 100,
},
};
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/mentors/register")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&dto).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let mentor_register_response: MentorRegisterResponseDto = serde_json::from_slice(&body).unwrap();
assert!(!mentor_register_response.id.is_empty());
assert!(!mentor_register_response.user_id.is_empty());
assert_eq!(mentor_register_response.email, Some(test_email.to_string()));
assert_eq!(mentor_register_response.status, "pending".to_string()); // New mentors should be pending verification
// Verify user created and has mentor role, and is inactive
let user = user_repo.query_user_by_email(test_email.to_string()).await.unwrap();
assert_eq!(user.email, test_email);
assert_eq!(user.is_active, false); // Should be inactive awaiting OTP
let role_repo = RolesRepository::new(&app_state); // Use RolesRepository to get role
let mentor_role = role_repo.query_role_by_name(RolesEnum::Mentor.to_string()).await.unwrap();
assert_eq!(user.role.to_raw(), mentor_role.id.to_raw()); // Compare raw IDs
// Clean up after test
let _ = user_repo.query_delete_user(test_email.to_string()).await;
}
#[tokio::test]
async fn test_register_existing_user_as_mentor_success() {
let app_state = setup_all_test_environment().await; // Use the new setup function
let app = app(app_state.clone());
let test_email = "existinguser_mentor@example.com";
let test_password = "Password123!";
let test_fullname = "Existing User Becoming Mentor";
let test_phone = "1122334455";
// Clean up and create an existing user
let user_repo = UsersRepository::new(&app_state);
let _ = user_repo.query_delete_user(test_email.to_string()).await;
// Manually create a user with 'User' role first
let role_repo = RolesRepository::new(&app_state); // Use RolesRepository to get role
let user_role_id_item = role_repo.query_role_by_name(RolesEnum::User.to_string()).await.unwrap(); // Get RolesDetailItemDto
let user_role_id_thing = make_thing(&ResourceEnum::Roles.to_string(), &user_role_id_item.id); // Convert to Thing
let hashed_password = hash_password(test_password).unwrap();
user_repo.query_create_user(imphnen_iam::UsersSchema {
id: make_thing(&ResourceEnum::Users.to_string(), &Uuid::new_v4().to_string()),
email: test_email.to_string(),
password: hashed_password,
fullname: "Original User Name".to_string(),
phone_number: "0000000000".to_string(),
is_active: true, // Initially active
role: user_role_id_thing,
created_at: get_iso_date(),
updated_at: get_iso_date(),
..Default::default()
}).await.unwrap();
let dto = MentorUserRegisterRequestDto {
email: test_email.to_string(),
password: test_password.to_string(), // Keep password same for simplicity in test
fullname: test_fullname.to_string(),
phone_number: test_phone.to_string(),
identity_and_verification: imphnen_dimentorin::mentors_dto::IdentityAndVerification {
legal_name: "Legal Name Updated".to_string(),
gender: Some("Perempuan".to_string()),
domicile: Some("Surabaya".to_string()),
identity_document_url: "http://example.com/id_updated.pdf".to_string(),
phone_for_verification: "0987654322".to_string(),
},
professional_profile: imphnen_dimentorin::mentors_dto::ProfessionalProfile {
bio: "Existing user now a mentor.".to_string(),
last_education: Some("S2".to_string()),
linkedin_url: Some("http://linkedin.com/in/mentor_existing".to_string()),
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/portfolio_existing".to_string()),
industries: vec!["Education".to_string()],
expertise: vec!["Marketing".to_string()],
languages: vec!["Indonesian".to_string()],
current_company: "New Company".to_string(),
current_role: "Manager".to_string(),
years_of_experience: 10,
},
mentoring_logistics: imphnen_dimentorin::mentors_dto::MentoringLogistics {
topics_of_interest: vec!["Business Strategy".to_string()],
preferred_mentee_level: vec!["Experienced".to_string()],
preferred_mentoring_formats: vec!["Offline".to_string()],
availability_commitment: "10 hours/month".to_string(),
mentoring_rate_amount: 200,
},
};
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/mentors/register")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&dto).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let mentor_register_response: MentorRegisterResponseDto = serde_json::from_slice(&body).unwrap();
assert!(!mentor_register_response.id.is_empty());
assert!(!mentor_register_response.user_id.is_empty());
assert_eq!(mentor_register_response.email, Some(test_email.to_string()));
assert_eq!(mentor_register_response.status, "pending".to_string()); // Should be pending verification
// Verify user updated and has mentor role, and is inactive
let user = user_repo.query_user_by_email(test_email.to_string()).await.unwrap();
assert_eq!(user.email, test_email);
assert_eq!(user.fullname, test_fullname);
assert_eq!(user.phone_number, test_phone);
assert_eq!(user.is_active, false); // Should be inactive awaiting OTP
let role_repo = RolesRepository::new(&app_state); // Use RolesRepository to get role
let mentor_role = role_repo.query_role_by_name(RolesEnum::Mentor.to_string()).await.unwrap();
assert_eq!(user.role.to_raw(), mentor_role.id.to_raw()); // Compare raw IDs
// Clean up after test
let _ = user_repo.query_delete_user(test_email.to_string()).await;
}
#[tokio::test]
async fn test_register_mentor_already_has_profile() {
let app_state = setup_all_test_environment().await; // Use the new setup function
let app = app(app_state.clone());
let test_email = "existing_mentor_profile@example.com";
let test_password = "Password123!";
let test_fullname = "Existing Mentor Profile";
let test_phone = "5551234567";
// Clean up and create a user that is already a mentor
let user_repo = UsersRepository::new(&app_state);
let mentor_repo = imphnen_dimentorin::mentors_repository::MentorsRepository::new(&app_state);
let _ = user_repo.query_delete_user(test_email.to_string()).await;
// Manually create a user with 'Mentor' role
let role_repo = RolesRepository::new(&app_state); // Use RolesRepository to get role
let mentor_role_thing = role_repo.query_role_by_name(RolesEnum::Mentor.to_string()).await.unwrap().id;
let hashed_password = imphnen_utils::hash_password(test_password).unwrap();
let user_id = imphnen_utils::make_thing(&ResourceEnum::Users.to_string(), &Uuid::new_v4().to_string());
user_repo.query_create_user(imphnen_iam::UsersSchema {
id: user_id.clone(),
email: test_email.to_string(),
password: hashed_password,
fullname: test_fullname.to_string(),
phone_number: test_phone.to_string(),
is_active: true,
role: mentor_role_thing.clone(),
created_at: imphnen_utils::get_iso_date(),
updated_at: imphnen_utils::get_iso_date(),
// Removed mentor_user_id
}).await.unwrap();
// Manually create a mentor profile for this user
let existing_mentor_profile_id = imphnen_utils::make_thing(&ResourceEnum::Mentors.to_string(), &Uuid::new_v4().to_string());
mentor_repo.query_create_mentor(imphnen_dimentorin::mentors_schema::MentorSchema {
id: existing_mentor_profile_id.clone(),
user_id: Some(user_id.clone()),
email: Some(test_email.to_string()),
legal_name: "Existing Mentor Legal Name".to_string(),
gender: Some("Laki-laki".to_string()),
domicile: Some("Bandung".to_string()),
identity_document_url: "http://example.com/existing_id.pdf".to_string(),
phone_for_verification: "1234567890".to_string(),
bio: "Already an existing mentor in the system.".to_string(),
last_education: Some("S3".to_string()),
linkedin_url: None,
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/existing_portfolio".to_string()),
industries: vec!["Finance".to_string()],
expertise: vec!["Investments".to_string()],
languages: vec!["English".to_string()],
current_company: "Finance Corp".to_string(),
current_role: "Analyst".to_string(),
years_of_experience: 7,
topics_of_interest: vec!["Stocks".to_string()],
preferred_mentee_level: vec!["All".to_string()],
preferred_mentoring_formats: vec!["Any".to_string()],
availability_commitment: "Flexible".to_string(),
mentoring_rate: imphnen_dimentorin::mentors_dto::MentoringRate::default(),
status: "Approved".to_string(),
is_deleted: false,
created_at: imphnen_utils::get_iso_date(),
updated_at: imphnen_utils::get_iso_date(),
}).await.unwrap();
// Update user to link mentor profile
let user_after_mentor_creation_dto = user_repo.query_user_by_email(test_email.to_string()).await.unwrap();
let mut user_after_mentor_creation_schema = UsersSchema::from(user_after_mentor_creation_dto);
user_after_mentor_creation_schema = user_after_mentor_creation_schema.update_mentor_id(Some(existing_mentor_profile_id.clone().to_raw()));
user_repo.query_update_user(user_after_mentor_creation_schema).await.unwrap();
let dto = MentorUserRegisterRequestDto {
email: test_email.to_string(),
password: test_password.to_string(),
fullname: test_fullname.to_string(),
phone_number: test_phone.to_string(),
identity_and_verification: imphnen_dimentorin::mentors_dto::IdentityAndVerification {
legal_name: "Legal Name".to_string(),
gender: Some("Perempuan".to_string()),
domicile: Some("Yogyakarta".to_string()),
identity_document_url: "http://example.com/id.pdf".to_string(),
phone_for_verification: "0987654321".to_string(),
},
professional_profile: imphnen_dimentorin::mentors_dto::ProfessionalProfile {
bio: "Experienced professional seeking to mentor others in software development.".to_string(),
last_education: Some("SMA".to_string()),
linkedin_url: Some("http://linkedin.com/in/mentor".to_string()),
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/portfolio_new".to_string()),
industries: vec!["Technology".to_string()],
expertise: vec!["Rust".to_string(), "Debugging".to_string()],
languages: vec!["English".to_string()],
current_company: "Acme Corp".to_string(),
current_role: "Senior Engineer".to_string(),
years_of_experience: 5,
},
mentoring_logistics: imphnen_dimentorin::mentors_dto::MentoringLogistics {
topics_of_interest: vec!["Career Development".to_string()],
preferred_mentee_level: vec!["Beginner".to_string()],
preferred_mentoring_formats: vec!["Online".to_string()],
availability_commitment: "5 hours/week".to_string(),
mentoring_rate_amount: 100,
},
};
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/mentors/register")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&dto).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT); // Expecting conflict
// Clean up after test
let _ = user_repo.query_delete_user(test_email.to_string()).await;
let _ = mentor_repo.query_delete_mentor(&existing_mentor_profile_id.to_raw()).await;
}
#[tokio::test]
async fn test_register_mentor_invalid_email_format() {
let app_state = setup_all_test_environment().await;
let app = app(app_state.clone());
let test_email = "invalid-email"; // Invalid email format
let test_password = "Password123!";
let test_fullname = "Test User";
let test_phone = "1234567890";
let dto = MentorUserRegisterRequestDto {
email: test_email.to_string(),
password: test_password.to_string(),
fullname: test_fullname.to_string(),
phone_number: test_phone.to_string(),
identity_and_verification: imphnen_dimentorin::mentors_dto::IdentityAndVerification {
legal_name: "Legal Name".to_string(),
gender: Some("Laki-laki".to_string()),
domicile: Some("Jakarta Selatan".to_string()),
identity_document_url: "http://example.com/id.pdf".to_string(),
phone_for_verification: "0987654321".to_string(),
},
professional_profile: imphnen_dimentorin::mentors_dto::ProfessionalProfile {
bio: "Experienced professional seeking to mentor others in software development.".to_string(),
last_education: Some("S1".to_string()),
linkedin_url: Some("http://linkedin.com/in/mentor".to_string()),
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/portfolio".to_string()),
industries: vec!["Technology".to_string()],
expertise: vec!["Rust".to_string(), "Debugging".to_string()],
languages: vec!["English".to_string()],
current_company: "Acme Corp".to_string(),
current_role: "Senior Engineer".to_string(),
years_of_experience: 5,
},
mentoring_logistics: imphnen_dimentorin::mentors_dto::MentoringLogistics {
topics_of_interest: vec!["Career Development".to_string()],
preferred_mentee_level: vec!["Beginner".to_string()],
preferred_mentoring_formats: vec!["Online".to_string()],
availability_commitment: "5 hours/week".to_string(),
mentoring_rate_amount: 100,
},
};
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/mentors/register")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&dto).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.into_body().collect().await.unwrap().to_bytes();
let error_response: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(error_response["message"].as_str().unwrap().contains("email"));
}
#[tokio::test]
async fn test_register_mentor_weak_password() {
let app_state = setup_all_test_environment().await;
let app = app(app_state.clone());
let test_email = "weakpass@example.com";
let test_password = "weak"; // Weak password
let test_fullname = "Test User";
let test_phone = "1234567890";
let dto = MentorUserRegisterRequestDto {
email: test_email.to_string(),
password: test_password.to_string(),
fullname: test_fullname.to_string(),
phone_number: test_phone.to_string(),
identity_and_verification: imphnen_dimentorin::mentors_dto::IdentityAndVerification {
legal_name: "Legal Name".to_string(),
gender: Some("Laki-laki".to_string()),
domicile: Some("Jakarta Selatan".to_string()),
identity_document_url: "http://example.com/id.pdf".to_string(),
phone_for_verification: "0987654321".to_string(),
},
professional_profile: imphnen_dimentorin::mentors_dto::ProfessionalProfile {
bio: "Experienced professional seeking to mentor others in software development.".to_string(),
last_education: Some("S1".to_string()),
linkedin_url: Some("http://linkedin.com/in/mentor".to_string()),
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/portfolio".to_string()),
industries: vec!["Technology".to_string()],
expertise: vec!["Rust".to_string(), "Debugging".to_string()],
languages: vec!["English".to_string()],
current_company: "Acme Corp".to_string(),
current_role: "Senior Engineer".to_string(),
years_of_experience: 5,
},
mentoring_logistics: imphnen_dimentorin::mentors_dto::MentoringLogistics {
topics_of_interest: vec!["Career Development".to_string()],
preferred_mentee_level: vec!["Beginner".to_string()],
preferred_mentoring_formats: vec!["Online".to_string()],
availability_commitment: "5 hours/week".to_string(),
mentoring_rate_amount: 100,
},
};
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/mentors/register")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&dto).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.into_body().collect().await.unwrap().to_bytes();
let error_response: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(error_response["message"].as_str().unwrap().contains("password"));
}
#[tokio::test]
async fn test_register_mentor_missing_fullname() {
let app_state = setup_all_test_environment().await;
let app = app(app_state.clone());
let test_email = "missingfullname@example.com";
let test_password = "Password123!";
let test_phone = "1234567890";
let dto = MentorUserRegisterRequestDto {
email: test_email.to_string(),
password: test_password.to_string(),
fullname: "".to_string(), // Missing fullname
phone_number: test_phone.to_string(),
identity_and_verification: imphnen_dimentorin::mentors_dto::IdentityAndVerification {
legal_name: "Legal Name".to_string(),
gender: Some("Laki-laki".to_string()),
domicile: Some("Jakarta Selatan".to_string()),
identity_document_url: "http://example.com/id.pdf".to_string(),
phone_for_verification: "0987654321".to_string(),
},
professional_profile: imphnen_dimentorin::mentors_dto::ProfessionalProfile {
bio: "Experienced professional seeking to mentor others in software development.".to_string(),
last_education: Some("S1".to_string()),
linkedin_url: Some("http://linkedin.com/in/mentor".to_string()),
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/portfolio".to_string()),
industries: vec!["Technology".to_string()],
expertise: vec!["Rust".to_string(), "Debugging".to_string()],
languages: vec!["English".to_string()],
current_company: "Acme Corp".to_string(),
current_role: "Senior Engineer".to_string(),
years_of_experience: 5,
},
mentoring_logistics: imphnen_dimentorin::mentors_dto::MentoringLogistics {
topics_of_interest: vec!["Career Development".to_string()],
preferred_mentee_level: vec!["Beginner".to_string()],
preferred_mentoring_formats: vec!["Online".to_string()],
availability_commitment: "5 hours/week".to_string(),
mentoring_rate_amount: 100,
},
};
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/mentors/register")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&dto).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.into_body().collect().await.unwrap().to_bytes();
let error_response: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(error_response["message"].as_str().unwrap().contains("fullname"));
}
#[tokio::test]
async fn test_register_mentor_missing_phone_number() {
let app_state = setup_all_test_environment().await;
let app = app(app_state.clone());
let test_email = "missingphone@example.com";
let test_password = "Password123!";
let test_fullname = "Test User";
let dto = MentorUserRegisterRequestDto {
email: test_email.to_string(),
password: test_password.to_string(),
fullname: test_fullname.to_string(),
phone_number: "".to_string(), // Missing phone number
identity_and_verification: imphnen_dimentorin::mentors_dto::IdentityAndVerification {
legal_name: "Legal Name".to_string(),
gender: Some("Laki-laki".to_string()),
domicile: Some("Jakarta Selatan".to_string()),
identity_document_url: "http://example.com/id.pdf".to_string(),
phone_for_verification: "0987654321".to_string(),
},
professional_profile: imphnen_dimentorin::mentors_dto::ProfessionalProfile {
bio: "Experienced professional seeking to mentor others in software development.".to_string(),
last_education: Some("S1".to_string()),
linkedin_url: Some("http://linkedin.com/in/mentor".to_string()),
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/portfolio".to_string()),
industries: vec!["Technology".to_string()],
expertise: vec!["Rust".to_string(), "Debugging".to_string()],
languages: vec!["English".to_string()],
current_company: "Acme Corp".to_string(),
current_role: "Senior Engineer".to_string(),
years_of_experience: 5,
},
mentoring_logistics: imphnen_dimentorin::mentors_dto::MentoringLogistics {
topics_of_interest: vec!["Career Development".to_string()],
preferred_mentee_level: vec!["Beginner".to_string()],
preferred_mentoring_formats: vec!["Online".to_string()],
availability_commitment: "5 hours/week".to_string(),
mentoring_rate_amount: 100,
},
};
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/mentors/register")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&dto).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.into_body().collect().await.unwrap().to_bytes();
let error_response: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(error_response["message"].as_str().unwrap().contains("phone_number"));
}
#[tokio::test]
async fn test_register_mentor_missing_identity_document_url() {
let app_state = setup_all_test_environment().await;
let app = app(app_state.clone());
let test_email = "missingdocurl@example.com";
let test_password = "Password123!";
let test_fullname = "Test User";
let test_phone = "1234567890";
let dto = MentorUserRegisterRequestDto {
email: test_email.to_string(),
password: test_password.to_string(),
fullname: test_fullname.to_string(),
phone_number: test_phone.to_string(),
identity_and_verification: imphnen_dimentorin::mentors_dto::IdentityAndVerification {
legal_name: "Legal Name".to_string(),
gender: Some("Laki-laki".to_string()),
domicile: Some("Jakarta Selatan".to_string()),
identity_document_url: "".to_string(), // Missing identity document url
phone_for_verification: "0987654321".to_string(),
},
professional_profile: imphnen_dimentorin::mentors_dto::ProfessionalProfile {
bio: "Experienced professional seeking to mentor others in software development.".to_string(),
last_education: Some("S1".to_string()),
linkedin_url: Some("http://linkedin.com/in/mentor".to_string()),
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/portfolio".to_string()),
industries: vec!["Technology".to_string()],
expertise: vec!["Rust".to_string(), "Debugging".to_string()],
languages: vec!["English".to_string()],
current_company: "Acme Corp".to_string(),
current_role: "Senior Engineer".to_string(),
years_of_experience: 5,
},
mentoring_logistics: imphnen_dimentorin::mentors_dto::MentoringLogistics {
topics_of_interest: vec!["Career Development".to_string()],
preferred_mentee_level: vec!["Beginner".to_string()],
preferred_mentoring_formats: vec!["Online".to_string()],
availability_commitment: "5 hours/week".to_string(),
mentoring_rate_amount: 100,
},
};
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/mentors/register")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&dto).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.into_body().collect().await.unwrap().to_bytes();
let error_response: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(error_response["message"].as_str().unwrap().contains("identity_document_url"));
}
#[tokio::test]
async fn test_register_mentor_invalid_phone_for_verification_format() {
let app_state = setup_all_test_environment().await;
let app = app(app_state.clone());
let test_email = "invalidphoneveri@example.com";
let test_password = "Password123!";
let test_fullname = "Test User";
let test_phone = "1234567890";
let dto = MentorUserRegisterRequestDto {
email: test_email.to_string(),
password: test_password.to_string(),
fullname: test_fullname.to_string(),
phone_number: test_phone.to_string(),
identity_and_verification: imphnen_dimentorin::mentors_dto::IdentityAndVerification {
legal_name: "Legal Name".to_string(),
gender: Some("Laki-laki".to_string()),
domicile: Some("Jakarta Selatan".to_string()),
identity_document_url: "http://example.com/id.pdf".to_string(),
phone_for_verification: "invalid".to_string(), // Invalid phone for verification
},
professional_profile: imphnen_dimentorin::mentors_dto::ProfessionalProfile {
bio: "Experienced professional seeking to mentor others in software development.".to_string(),
last_education: Some("S1".to_string()),
linkedin_url: Some("http://linkedin.com/in/mentor".to_string()),
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/portfolio".to_string()),
industries: vec!["Technology".to_string()],
expertise: vec!["Rust".to_string(), "Debugging".to_string()],
languages: vec!["English".to_string()],
current_company: "Acme Corp".to_string(),
current_role: "Senior Engineer".to_string(),
years_of_experience: 5,
},
mentoring_logistics: imphnen_dimentorin::mentors_dto::MentoringLogistics {
topics_of_interest: vec!["Career Development".to_string()],
preferred_mentee_level: vec!["Beginner".to_string()],
preferred_mentoring_formats: vec!["Online".to_string()],
availability_commitment: "5 hours/week".to_string(),
mentoring_rate_amount: 100,
},
};
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/mentors/register")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&dto).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.into_body().collect().await.unwrap().to_bytes();
let error_response: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(error_response["message"].as_str().unwrap().contains("phone_for_verification"));
}
@@ -0,0 +1,349 @@
use anyhow::Result;
use imphnen_dimentorin::v1::mentors::{MentorSchema, MentoringRate, MentorsRepository};
use surrealdb::sql::Thing;
use imphnen_iam::v1::users::UsersRepository;
use tests::{
create_test_user, get_role_id, ResourceEnum, cleanup_db,
generate_unique_email, setup_all_test_environment,
};
use surrealdb::Uuid;
/// Helper to create a full-featured MentorSchema for tests
fn create_full_mentor_schema(id: &str, user_id: &str, email: &str, legal_name: &str) -> MentorSchema {
MentorSchema {
id: Thing::from(("app_mentors", id)),
user_id: Some(Thing::from(("app_users", user_id))),
email: Some(email.to_string()),
legal_name: legal_name.to_string(),
gender: Some("Laki-laki".to_string()),
domicile: Some("Jakarta".to_string()),
identity_document_url: "https://example.com/ktp.jpg".to_string(),
phone_for_verification: "+6281234567890".to_string(),
bio: "Saya adalah mentor backend Rust dengan pengalaman 5 tahun dalam pengembangan aplikasi backend yang scalable dan performant.".to_string(),
last_education: Some("S1".to_string()),
linkedin_url: Some("https://linkedin.com/in/mentor".to_string()),
github_url: Some("http://github.com/test".to_string()),
cv_url: Some("http://example.com/cv.pdf".to_string()),
portfolio_url: Some("http://example.com/portfolio".to_string()),
industries: vec!["Software".to_string(), "Education".to_string()],
expertise: vec!["Rust".to_string(), "Microservices".to_string()],
languages: vec!["Indonesian".to_string(), "English".to_string()],
current_company: "PT Contoh".to_string(),
current_role: "Senior Backend Engineer".to_string(),
years_of_experience: 5,
topics_of_interest: vec!["Rust Programming".to_string(), "Backend Development".to_string()],
preferred_mentee_level: vec!["beginner".to_string(), "intermediate".to_string()],
preferred_mentoring_formats: vec!["Test Format".to_string()],
availability_commitment: "2 jam per minggu untuk mentoring online dan offline".to_string(),
mentoring_rate: MentoringRate {
amount: 100_000,
currency: "IDR".to_string(),
per_duration: "hour".to_string(),
},
status: "verified".to_string(),
is_deleted: false,
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
..Default::default()
}
}
#[tokio::test]
async fn test_create_mentor() -> Result<()> {
cleanup_db().await; // Keep for now as setup_all_test_environment doesn't clean up
let app_state = setup_all_test_environment().await;
let repo = MentorsRepository::new(&app_state);
let id = Uuid::new_v4().to_string();
let email = generate_unique_email("test_create_mentor");
let user_repo = UsersRepository::new(&app_state);
let mut user = create_test_user(&email, "Mentor User", true, &get_role_id(&app_state).await);
user.id = Thing::from(("app_users", id.as_str()));
user.email = email.to_string();
user.mentor_id = Some(Thing::from(("app_mentors", id.as_str())));
let create_result = user_repo.query_create_user(user.clone()).await;
assert!(create_result.is_ok(), "Failed to create user: {:?}", create_result.err());
let mentor = create_full_mentor_schema(&id, &id, &email, "Mentor User");
let create_res = repo.query_create_mentor(mentor.clone()).await;
assert!(create_res.is_ok(), "Failed to create mentor: {:?}", create_res.err());
let thing_id = Thing::from((ResourceEnum::Mentors.to_string().as_str(), id.as_str()));
let mentor_fetched = repo.query_mentor_by_id(&thing_id, false).await;
assert!(mentor_fetched.is_ok(), "Failed to fetch mentor after create: {:?}", mentor_fetched.err());
let mentor_fetched = mentor_fetched.unwrap();
assert_eq!(mentor_fetched.legal_name, "Mentor User");
assert_eq!(mentor_fetched.status, "verified");
assert_eq!(mentor_fetched.user_id, Thing::from(("app_users", id.as_str())));
Ok(())
}
#[tokio::test]
async fn test_get_all_mentors() -> Result<()> {
cleanup_db().await;
let app_state = setup_all_test_environment().await;
let repo = MentorsRepository::new(&app_state);
let id = Uuid::new_v4().to_string();
let email = generate_unique_email("test_get_all_mentors");
let user_repo = UsersRepository::new(&app_state);
let mut user = create_test_user(&email, "Test Mentor Get All User", true, &get_role_id(&app_state).await);
user.id = Thing::from(("app_users", id.as_str()));
user.email = email.to_string();
user.mentor_id = Some(Thing::from(("app_mentors", id.as_str())));
let create_user_res = user_repo.query_create_user(user.clone()).await;
assert!(create_user_res.is_ok(), "Failed to create user for get_all test: {:?}", create_user_res.err());
let mentor = create_full_mentor_schema(&id, &id, &email, "Test Mentor Get All");
let create_res = repo.query_create_mentor(mentor.clone()).await;
assert!(create_res.is_ok(), "Failed to create mentor: {:?}", create_res.err());
let mut meta = imphnen_libs::MetaRequestDto::default();
meta.search = Some("Test Mentor Get All".to_string());
let mentors_res = repo.query_mentor_list(meta).await;
assert!(mentors_res.is_ok(), "Failed to get all mentors: {:?}", mentors_res.err());
let mentors = mentors_res.unwrap().data;
assert!(!mentors.is_empty(), "Mentors list should not be empty");
Ok(())
}
#[tokio::test]
async fn test_get_mentor_by_id() -> Result<()> {
cleanup_db().await;
let app_state = setup_all_test_environment().await;
let repo = MentorsRepository::new(&app_state);
let id = Uuid::new_v4().to_string();
let email = generate_unique_email("test_get_mentor_by_id");
let user_repo = UsersRepository::new(&app_state);
let mut user = create_test_user(&email, "Test Mentor Get By Id User", true, &get_role_id(&app_state).await);
user.id = Thing::from(("app_users", id.as_str()));
user.email = email.to_string();
user.mentor_id = Some(Thing::from(("app_mentors", id.as_str())));
let create_user_res = user_repo.query_create_user(user.clone()).await;
assert!(create_user_res.is_ok(), "Failed to create user for get_by_id test: {:?}", create_user_res.err());
let mentor = create_full_mentor_schema(&id, &id, &email, "Test Mentor Get By Id");
let create_res = repo.query_create_mentor(mentor.clone()).await;
assert!(create_res.is_ok(), "Failed to create mentor: {:?}", create_res.err());
let mentor_res = repo
.query_mentor_by_id(&Thing::from((ResourceEnum::Mentors.to_string().as_str(), id.as_str())), false)
.await;
assert!(mentor_res.is_ok(), "Failed to get mentor by id: {:?}", mentor_res.err());
let mentor = mentor_res.unwrap();
assert_eq!(mentor.legal_name, "Test Mentor Get By Id");
Ok(())
}
#[tokio::test]
async fn test_update_mentor() -> Result<()> {
cleanup_db().await;
let app_state = setup_all_test_environment().await;
let repo = MentorsRepository::new(&app_state);
let id = Uuid::new_v4().to_string();
let email = generate_unique_email("test_update_mentor");
let user_repo = UsersRepository::new(&app_state);
let mut user = create_test_user(&email, "Test Mentor Update User", true, &get_role_id(&app_state).await);
user.id = Thing::from(("app_users", id.as_str()));
user.email = email.to_string();
user.mentor_id = Some(Thing::from(("app_mentors", id.as_str())));
let create_user_res = user_repo.query_create_user(user.clone()).await;
assert!(create_user_res.is_ok(), "Failed to create user for update test: {:?}", create_user_res.err());
let mentor = create_full_mentor_schema(&id, &id, &email, "Test Mentor Update");
let create_res = repo.query_create_mentor(mentor.clone()).await;
assert!(create_res.is_ok(), "Failed to create mentor: {:?}", create_res.err());
let mut updated_mentor = mentor.clone();
updated_mentor.legal_name = "Test Mentor Updated".to_string();
updated_mentor.gender = Some("Perempuan".to_string());
updated_mentor.domicile = Some("Bandung".to_string());
updated_mentor.last_education = Some("S2".to_string());
updated_mentor.portfolio_url = Some("http://example.com/updated_portfolio".to_string());
updated_mentor.mentoring_rate.amount = 200_000;
let update_res = repo.query_update_mentor(updated_mentor.clone()).await;
assert!(update_res.is_ok(), "Failed to update mentor: {:?}", update_res.err());
let mentor_fetched = repo
.query_mentor_by_id(&Thing::from((ResourceEnum::Mentors.to_string().as_str(), id.as_str())), false)
.await;
assert!(mentor_fetched.is_ok(), "Failed to fetch mentor after update: {:?}", mentor_fetched.err());
let mentor_fetched = mentor_fetched.unwrap();
assert_eq!(mentor_fetched.legal_name, "Test Mentor Updated");
assert_eq!(mentor_fetched.gender, Some("Perempuan".to_string()));
assert_eq!(mentor_fetched.domicile, Some("Bandung".to_string()));
assert_eq!(mentor_fetched.last_education, Some("S2".to_string()));
assert_eq!(mentor_fetched.portfolio_url, Some("http://example.com/updated_portfolio".to_string()));
assert_eq!(mentor_fetched.mentoring_rate.amount, 200_000);
Ok(())
}
#[tokio::test]
async fn test_delete_mentor() -> Result<()> {
cleanup_db().await;
let app_state = setup_all_test_environment().await;
let repo = MentorsRepository::new(&app_state);
let id = Uuid::new_v4().to_string();
let email = generate_unique_email("test_delete_mentor");
let user_repo = UsersRepository::new(&app_state);
let mut user = create_test_user(&email, "Test Mentor Delete User", true, &get_role_id(&app_state).await);
user.id = Thing::from(("app_users", id.as_str()));
user.email = email.to_string();
user.mentor_id = Some(Thing::from(("app_mentors", id.as_str())));
let create_user_res = user_repo.query_create_user(user.clone()).await;
assert!(create_user_res.is_ok(), "Failed to create user for delete test: {:?}", create_user_res.err());
let mentor = create_full_mentor_schema(&id, &id, &email, "Test Mentor Delete");
let create_res = repo.query_create_mentor(mentor.clone()).await;
assert!(create_res.is_ok(), "Failed to create mentor: {:?}", create_res.err());
let delete_res = repo.query_delete_mentor(id.clone()).await;
assert!(delete_res.is_ok(), "Failed to delete mentor: {:?}", delete_res.err());
let thing_id = Thing::from((ResourceEnum::Mentors.to_string().as_str(), id.as_str()));
let mentor_fetched = repo.query_mentor_by_id(&thing_id, false).await;
assert!(mentor_fetched.is_err(), "Mentor should not be found after soft delete");
if let Some(err) = mentor_fetched.err() {
assert!(
err.to_string().contains("Mentor not found in database")
|| err.to_string().contains("Mentor has been deleted"),
"Expected 'Mentor not found in database' or 'Mentor has been deleted' error, got: {}",
err
);
}
let delete_again_res = repo.query_delete_mentor(id.clone()).await;
assert!(delete_again_res.is_err(), "Should not be able to soft delete an already deleted mentor");
if let Some(err) = delete_again_res.err() {
assert!(
err.to_string().contains("already soft deleted"),
"Expected 'already soft deleted' error, got: {}",
err
);
}
Ok(())
}
#[tokio::test]
async fn test_get_by_user_email() -> Result<()> {
cleanup_db().await;
let app_state = setup_all_test_environment().await;
let repo = MentorsRepository::new(&app_state);
let id = Uuid::new_v4().to_string();
let user_email = generate_unique_email("test_mentor_by_email");
let user_repo = UsersRepository::new(&app_state);
let mut user = create_test_user(&user_email, "Test User By Email", true, &get_role_id(&app_state).await);
user.id = Thing::from(("app_users", id.as_str()));
user.email = user_email.clone();
user.mentor_id = Some(Thing::from(("app_mentors", id.as_str())));
let create_user_res = user_repo.query_create_user(user.clone()).await;
assert!(create_user_res.is_ok(), "Failed to create user for get_by_user_email test: {:?}", create_user_res.err());
let mentor = create_full_mentor_schema(&id, &id, &user_email, "Test Mentor By Email");
let create_res = repo.query_create_mentor(mentor.clone()).await;
assert!(create_res.is_ok(), "Failed to create mentor: {:?}", create_res.err());
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let mentor_detail = repo
.query_mentor_by_email(user_email.to_string(), false)
.await;
if mentor_detail.is_err() {
let mentor_by_id = repo
.query_mentor_by_id(&Thing::from(("app_mentors", id.as_str())), false)
.await;
assert!(
mentor_by_id.is_ok(),
"Failed to get mentor by user_id fallback: {:?}",
mentor_by_id.err()
);
let mentor_by_id = mentor_by_id.unwrap();
assert_eq!(mentor_by_id.legal_name, "Test Mentor By Email");
return Ok(());
}
assert!(
mentor_detail.is_ok(),
"Failed to get mentor by user email: {:?}",
mentor_detail.err()
);
let mentor_detail = mentor_detail.unwrap();
assert_eq!(mentor_detail.legal_name, "Test Mentor By Email");
Ok(())
}
#[tokio::test]
async fn test_create_mentor_with_duplicate_user_id() -> Result<()> {
cleanup_db().await;
let app_state = setup_all_test_environment().await;
let repo = MentorsRepository::new(&app_state);
let id = Uuid::new_v4().to_string();
let email = generate_unique_email("test_duplicate_user_id");
let user_repo = UsersRepository::new(&app_state);
let mut user = create_test_user(&email, "Duplicate User", true, &get_role_id(&app_state).await);
user.id = Thing::from(("app_users", id.as_str()));
user.email = email.to_string();
user.mentor_id = Some(Thing::from(("app_mentors", id.as_str())));
let _ = user_repo.query_create_user(user.clone()).await;
let mentor1 = create_full_mentor_schema(&id, &id, &email, "Mentor 1");
let create_res1 = repo.query_create_mentor(mentor1.clone()).await;
assert!(create_res1.is_ok(), "Failed to create first mentor: {:?}", create_res1.err());
// Attempt to create another mentor with the same user_id
let duplicate_mentor_id = Uuid::new_v4().to_string();
let mentor2 = create_full_mentor_schema(&duplicate_mentor_id, &id, &generate_unique_email("test_duplicate_user_id_2"), "Mentor 2");
let create_res2 = repo.query_create_mentor(mentor2.clone()).await;
assert!(create_res2.is_err(), "Should not be able to create mentor with duplicate user_id");
if let Some(err) = create_res2.err() {
assert!(err.to_string().contains("already has a mentor profile"), "Expected 'already has a mentor profile' error, got: {}", err);
}
Ok(())
}
#[tokio::test]
async fn test_update_non_existent_mentor() -> Result<()> {
cleanup_db().await;
let app_state = setup_all_test_environment().await;
let repo = MentorsRepository::new(&app_state);
let non_existent_id = Uuid::new_v4().to_string();
let non_existent_email = generate_unique_email("test_non_existent_mentor");
let non_existent_user_id = Uuid::new_v4().to_string();
let mentor_to_update = create_full_mentor_schema(&non_existent_id, &non_existent_user_id, &non_existent_email, "Non Existent Mentor");
let update_res = repo.query_update_mentor(mentor_to_update).await;
assert!(update_res.is_err(), "Should not be able to update a non-existent mentor");
if let Some(err) = update_res.err() {
assert!(err.to_string().contains("Mentor not found"), "Expected 'Mentor not found' error, got: {}", err);
}
Ok(())
}
#[tokio::test]
async fn test_delete_non_existent_mentor() -> Result<()> {
cleanup_db().await;
let app_state = setup_all_test_environment().await;
let repo = MentorsRepository::new(&app_state);
let non_existent_id = Uuid::new_v4().to_string();
let delete_res = repo.query_delete_mentor(non_existent_id.clone()).await;
assert!(delete_res.is_err(), "Should not be able to delete a non-existent mentor");
if let Some(err) = delete_res.err() {
assert!(err.to_string().contains("Mentor not found"), "Expected 'Mentor not found' error, got: {}", err);
}
Ok(())
}
+4
View File
@@ -0,0 +1,4 @@
#[cfg(test)]
pub mod mentor_repository_test;
#[cfg(test)]
pub mod mentor_registration_tests;
+1
View File
@@ -0,0 +1 @@
pub mod mentor;
+379
View File
@@ -0,0 +1,379 @@
#[cfg(test)]
mod auth_login_tests {
use crate::generate_unique_email;
use crate::hash_password;
use crate::mock_test::setup_all_test_environment;
use axum::http::StatusCode;
use imphnen_iam::{
v1::auth::{AuthLoginRequestDto, AuthService},
AppState, UsersRepository, UsersSchema,
};
use serde_json::Value; // Import the new setup function
async fn setup_test_environment() -> AppState {
setup_all_test_environment().await
}
async fn create_test_user_with_role(
state: &AppState,
email: &str,
password: &str,
role_name: &str,
is_active: bool,
) -> UsersSchema {
let role_repo = imphnen_iam::RolesRepository::new(state);
let role = match role_repo.query_role_by_name(role_name.to_string()).await {
Ok(role) => role,
Err(_) => {
let _ = role_repo
.query_create_role(imphnen_iam::RolesRequestCreateDto {
name: role_name.to_string(),
permissions: vec![],
})
.await
.unwrap();
role_repo
.query_role_by_name(role_name.to_string())
.await
.unwrap_or_else(|_| {
panic!("Failed to create {role_name} role");
})
}
};
let user = UsersSchema {
id: crate::make_thing("app_users", &uuid::Uuid::new_v4().to_string()),
email: email.to_string(),
fullname: "Test User".to_string(),
password: hash_password(password).unwrap(),
is_deleted: false,
avatar: None,
phone_number: "081234567890".to_string(),
is_active,
gender: None,
birthdate: None,
role: crate::make_thing("app_roles", &role.id),
mentor_id: None,
created_at: imphnen_utils::get_iso_date(),
updated_at: imphnen_utils::get_iso_date(),
};
let user_repo = UsersRepository::new(state);
user_repo
.query_create_user(user.clone())
.await
.expect("Failed to create test user");
user
}
#[tokio::test]
async fn test_successful_login_with_valid_credentials() {
let state = setup_test_environment().await;
let email = generate_unique_email("test_login_success");
let password = "TestPass123!";
create_test_user_with_role(&state, &email, password, "User", true).await;
let login_dto = AuthLoginRequestDto {
email: email.clone(),
password: password.to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::OK);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert!(response_json.get("data").is_some());
assert!(response_json["data"].get("token").is_some());
assert!(response_json["data"]["token"].get("access_token").is_some());
assert!(response_json["data"]["token"]
.get("refresh_token")
.is_some());
assert!(response_json["data"].get("user").is_some());
assert_eq!(response_json["data"]["user"]["email"], email);
}
#[tokio::test]
async fn test_login_with_invalid_email_format() {
let state = setup_test_environment().await;
let login_dto = AuthLoginRequestDto {
email: "invalid-email".to_string(),
password: "TestPass123!".to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::BAD_REQUEST);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(response_json["message"], "Email not valid");
}
#[tokio::test]
async fn test_login_with_empty_email() {
let state = setup_test_environment().await;
let login_dto = AuthLoginRequestDto {
email: "".to_string(),
password: "TestPass123!".to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::BAD_REQUEST);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
let message = response_json["message"].as_str().unwrap();
assert!(message.contains("Email cannot be empty"));
assert!(message.contains("Email not valid"));
}
#[tokio::test]
async fn test_login_with_empty_password() {
let state = setup_test_environment().await;
let login_dto = AuthLoginRequestDto {
email: generate_unique_email("test_empty_pass"),
password: "".to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::BAD_REQUEST);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(response_json["message"], "Password cannot be empty");
}
#[tokio::test]
async fn test_login_with_wrong_password() {
let state = setup_test_environment().await;
let email = generate_unique_email("test_wrong_pass");
let correct_password = "TestPass123!";
create_test_user_with_role(&state, &email, correct_password, "User", true).await;
let login_dto = AuthLoginRequestDto {
email: email.clone(),
password: "WrongPassword123!".to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::BAD_REQUEST);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(response_json["message"], "Email or password not correct");
}
#[tokio::test]
async fn test_login_with_nonexistent_user() {
let state = setup_test_environment().await;
let login_dto = AuthLoginRequestDto {
email: generate_unique_email("nonexistent"),
password: "TestPass123!".to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::UNAUTHORIZED);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert!(response_json["message"]
.to_string()
.contains("User not found"));
}
#[tokio::test]
async fn test_login_with_inactive_user() {
let state = setup_test_environment().await;
let email = generate_unique_email("test_inactive");
let password = "TestPass123!";
create_test_user_with_role(&state, &email, password, "User", false).await;
let login_dto = AuthLoginRequestDto {
email: email.clone(),
password: password.to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::BAD_REQUEST);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(
response_json["message"],
"Account not active, please verify your email"
);
}
#[tokio::test]
async fn test_successful_mentor_login() {
let state = setup_test_environment().await;
let email = generate_unique_email("test_mentor_login");
let password = "TestPass123!";
create_test_user_with_role(&state, &email, password, "Mentor", true).await;
let login_dto = AuthLoginRequestDto {
email: email.clone(),
password: password.to_string(),
};
let response = AuthService::mutation_mentor_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::OK);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert!(response_json.get("data").is_some());
assert_eq!(response_json["data"]["user"]["role"]["name"], "Mentor");
}
#[tokio::test]
async fn test_mentor_login_with_non_mentor_user() {
let state = setup_test_environment().await;
let email = generate_unique_email("test_user_not_mentor");
let password = "TestPass123!";
create_test_user_with_role(&state, &email, password, "User", true).await;
let login_dto = AuthLoginRequestDto {
email: email.clone(),
password: password.to_string(),
};
let response = AuthService::mutation_mentor_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::FORBIDDEN);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(
response_json["message"],
"User does not have mentor privileges"
);
}
#[tokio::test]
async fn test_mentor_login_with_inactive_mentor() {
let state = setup_test_environment().await;
let email = generate_unique_email("test_inactive_mentor");
let password = "TestPass123!";
create_test_user_with_role(&state, &email, password, "Mentor", false).await;
let login_dto = AuthLoginRequestDto {
email: email.clone(),
password: password.to_string(),
};
let response = AuthService::mutation_mentor_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::BAD_REQUEST);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(
response_json["message"],
"Account not active, please verify your email"
);
}
#[tokio::test]
async fn test_login_creates_user_cache() {
let state = setup_test_environment().await;
let email = generate_unique_email("test_cache");
let password = "TestPass123!";
create_test_user_with_role(&state, &email, password, "User", true).await;
let login_dto = AuthLoginRequestDto {
email: email.clone(),
password: password.to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, _) = response.into_parts();
assert_eq!(parts.status, StatusCode::OK);
// Verify user was cached
let auth_repo = imphnen_iam::AuthRepository::new(&state);
let cached_user = auth_repo.query_get_stored_user(email.clone()).await;
assert!(cached_user.is_ok());
assert_eq!(cached_user.unwrap().email, email);
}
#[tokio::test]
async fn test_login_with_special_characters_in_email() {
let state = setup_test_environment().await;
let email = generate_unique_email("test+special");
let password = "TestPass123!";
create_test_user_with_role(&state, &email, password, "User", true).await;
let login_dto = AuthLoginRequestDto {
email: email.clone(),
password: password.to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, _) = response.into_parts();
assert_eq!(parts.status, StatusCode::OK);
}
#[tokio::test]
async fn test_login_with_case_sensitive_email() {
let state = setup_test_environment().await;
let email = generate_unique_email("test_case");
let password = "TestPass123!";
create_test_user_with_role(&state, &email, password, "User", true).await;
let login_dto = AuthLoginRequestDto {
email: email.to_uppercase(),
password: password.to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
// Email should be case-sensitive
assert_eq!(parts.status, StatusCode::UNAUTHORIZED);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert!(response_json["message"]
.to_string()
.contains("User not found"));
}
}
+93 -53
View File
@@ -1,9 +1,16 @@
#[cfg(test)]
mod auth_repository_test {
use crate::{
AuthOtpSchema, AuthRepository, ResourceEnum, UsersRepository, UsersSchema,
create_mock_app_state, generate_unique_email, get_iso_date, get_role_id,
generate_unique_email,
get_iso_date,
get_role_id,
make_thing,
setup_all_test_environment, // Import the new setup function
AuthOtpSchema,
AuthRepository,
ResourceEnum,
UsersRepository,
UsersSchema,
};
use chrono::{Duration, Utc};
use imphnen_iam::{AppState, RolesDetailQueryDto, UsersDetailQueryDto};
@@ -22,6 +29,7 @@ mod auth_repository_test {
gender: None,
birthdate: None,
role: make_thing("app_roles", &get_role_id(state).await),
mentor_id: None,
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
@@ -29,27 +37,35 @@ mod auth_repository_test {
#[tokio::test]
async fn test_store_and_get_user() {
let app_state = create_mock_app_state().await;
let app_state = setup_all_test_environment().await; // Use the new setup function
let repo = AuthRepository::new(&app_state);
let email = generate_unique_email("forgot");
let user = create_mock_user(&app_state, &email).await;
let mut user = create_mock_user(&app_state, &email).await;
user.role = make_thing("app_roles", &get_role_id(&app_state).await);
let user_repo = UsersRepository::new(&app_state);
let create_user = user_repo.query_create_user(user.clone()).await;
assert!(create_user.is_ok());
let user_data = user_repo
.query_user_by_email(email.to_string())
.await
.unwrap();
let user_data = user_repo.query_user_by_email(email).await;
assert!(
user_data.is_ok(),
"Failed to get user by email: {:?}",
user_data.err()
);
let user_data = user_data.unwrap();
let store = repo.query_store_user(user_data.clone()).await;
assert!(store.is_ok());
assert!(store.is_ok(), "Failed to store user: {:?}", store.err());
let fetched = repo.query_get_stored_user(user.email.clone()).await;
assert!(fetched.is_ok());
assert!(
fetched.is_ok(),
"Failed to fetch stored user: {:?}",
fetched.err()
);
assert_eq!(fetched.unwrap().email, user.email);
}
#[tokio::test]
async fn test_delete_stored_user() {
let state = create_mock_app_state().await;
let state = setup_all_test_environment().await; // Use the new setup function
let auth_repo = AuthRepository::new(&state);
let email = "delete_me@example.com".to_string();
let mock_user = UsersDetailQueryDto {
@@ -71,15 +87,20 @@ mod auth_repository_test {
},
is_deleted: false,
password: "".into(),
mentor_id: None,
created_at: get_iso_date(),
updated_at: get_iso_date(),
};
let _: Option<UsersDetailQueryDto> = state
let created: Result<Option<UsersDetailQueryDto>, surrealdb::Error> = state
.surrealdb_mem
.create((ResourceEnum::UsersCache.to_string(), email.clone()))
.content(mock_user)
.await
.unwrap();
.await;
assert!(
created.is_ok(),
"Failed to create mock user: {:?}",
created.err()
);
let result = auth_repo.query_delete_stored_user(email.clone()).await;
assert!(
result.is_ok(),
@@ -91,101 +112,120 @@ mod auth_repository_test {
#[tokio::test]
async fn test_store_and_get_otp() {
let app_state = create_mock_app_state().await;
let app_state = setup_all_test_environment().await; // Use the new setup function
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());
assert!(stored.is_ok(), "Failed to store OTP: {:?}", stored.err());
let fetched = repo.query_get_stored_otp(email.clone()).await;
assert!(fetched.is_ok());
assert!(fetched.is_ok(), "Failed to fetch OTP: {:?}", fetched.err());
assert_eq!(fetched.unwrap(), otp);
}
#[tokio::test]
async fn test_delete_stored_otp() {
let app_state = create_mock_app_state().await;
let app_state = setup_all_test_environment().await; // Use the new setup function
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 store_res = repo.query_store_otp(email.clone(), otp).await;
assert!(
store_res.is_ok(),
"Failed to store OTP: {:?}",
store_res.err()
);
let deleted = repo.query_delete_stored_otp(email.clone()).await;
assert!(deleted.is_ok());
assert!(deleted.is_ok(), "Failed to delete OTP: {:?}", deleted.err());
let fetched = repo.query_get_stored_otp(email.clone()).await;
assert!(fetched.is_err());
assert!(
fetched.is_err(),
"OTP should be deleted, but got: {fetched:?}"
);
}
#[tokio::test]
async fn test_expired_otp() {
let app_state = create_mock_app_state().await;
let app_state = setup_all_test_environment().await; // Use the new setup function
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
let created: Result<Option<AuthOtpSchema>, surrealdb::Error> = repo
.state
.surrealdb_mem
.create((table.clone(), email.as_str()))
.content(AuthOtpSchema { otp, expires_at })
.await
.unwrap();
.await;
assert!(
created.is_ok(),
"Failed to create expired OTP: {:?}",
created.err()
);
let result = repo.query_get_stored_otp(email.clone()).await;
assert!(result.is_err());
assert!(
result.is_err(),
"Expired OTP should not be retrievable, got: {result:?}"
);
if let Some(err) = result.err() {
assert!(
err.to_string().contains("OTP expired"),
"Expected 'OTP expired' error, got: {err}"
);
}
}
#[tokio::test]
async fn test_get_non_existent_stored_user_should_fail() {
let app_state = create_mock_app_state().await;
let app_state = setup_all_test_environment().await; // Use the new setup function
let repo = AuthRepository::new(&app_state);
let result = repo
.query_get_stored_user("not_found@example.com".into())
.await;
assert!(result.is_err());
if let Some(err) = result.err() {
assert!(
err.to_string().contains("No stored user data found"),
"Expected 'No stored user data found' error, got: {err}"
);
}
}
#[tokio::test]
async fn test_delete_non_existent_user_should_fail() {
let app_state = create_mock_app_state().await;
let app_state = setup_all_test_environment().await; // Use the new setup function
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());
if let Some(err) = result.err() {
assert!(
err.to_string().contains("Failed delete stored user"),
"Expected 'Failed delete stored user' error, got: {err}"
);
}
}
#[tokio::test]
async fn test_store_and_get_valid_otp() {
let app_state = create_mock_app_state().await;
let app_state = setup_all_test_environment().await; // Use the new setup function
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());
assert!(
store_result.is_ok(),
"Failed to store valid OTP: {:?}",
store_result.err()
);
let get_result = repo.query_get_stored_otp(email.into()).await;
assert!(
get_result.is_ok(),
"Failed to get valid OTP: {:?}",
get_result.err()
);
assert_eq!(get_result.unwrap(), otp);
}
}
+2
View File
@@ -1,2 +1,4 @@
#[cfg(test)]
pub mod auth_login_tests;
#[cfg(test)]
pub mod auth_repository_test;
@@ -1,19 +1,22 @@
use crate::{
create_mock_app_state,
get_iso_date, // Import the new setup function and get_iso_date
permissions::{PermissionsRepository, PermissionsSchema},
setup_all_test_environment,
};
use chrono::Utc;
fn create_dummy_permission(name: &str) -> PermissionsSchema {
PermissionsSchema {
name: name.to_string(),
created_at: Some(get_iso_date()), // Ensure created_at is always set
updated_at: Some(get_iso_date()), // Ensure updated_at is always set
..Default::default()
}
}
#[tokio::test]
async fn test_create_permission_should_succeed() {
let state = create_mock_app_state().await;
let state = setup_all_test_environment().await; // Use the new setup function
let repo = PermissionsRepository::new(&state);
let permission = create_dummy_permission("Test Permission");
let result = repo.query_create_permission(permission).await;
@@ -22,7 +25,7 @@ async fn test_create_permission_should_succeed() {
#[tokio::test]
async fn test_query_permission_list_should_return_data() {
let state = create_mock_app_state().await;
let state = setup_all_test_environment().await; // Use the new setup function
let repo = PermissionsRepository::new(&state);
let _ = repo
@@ -45,7 +48,7 @@ async fn test_query_permission_list_should_return_data() {
#[tokio::test]
async fn test_query_permission_by_id_should_succeed() {
let state = create_mock_app_state().await;
let state = setup_all_test_environment().await; // Use the new setup function
let repo = PermissionsRepository::new(&state);
let permission = create_dummy_permission("Detail");
let _ = repo.query_create_permission(permission.clone()).await;
@@ -56,7 +59,7 @@ async fn test_query_permission_by_id_should_succeed() {
#[tokio::test]
async fn test_update_permission_should_succeed() {
let state = create_mock_app_state().await;
let state = setup_all_test_environment().await; // Use the new setup function
let repo = PermissionsRepository::new(&state);
let mut permission = create_dummy_permission("Update This");
let _ = repo.query_create_permission(permission.clone()).await;
@@ -68,7 +71,7 @@ async fn test_update_permission_should_succeed() {
#[tokio::test]
async fn test_delete_permission_should_succeed() {
let state = create_mock_app_state().await;
let state = setup_all_test_environment().await; // Use the new setup function
let repo = PermissionsRepository::new(&state);
let permission = create_dummy_permission("To Be Deleted");
let _ = repo.query_create_permission(permission.clone()).await;
@@ -79,33 +82,64 @@ async fn test_delete_permission_should_succeed() {
#[tokio::test]
async fn test_delete_permission_should_fail_if_already_deleted() {
let state = create_mock_app_state().await;
let state = setup_all_test_environment().await; // Use the new setup function
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");
let delete_result = repo.query_delete_permission(id.clone()).await;
assert!(
delete_result.is_ok(),
"Initial delete failed: {:?}",
delete_result.err()
);
let second_delete_result = repo.query_delete_permission(id).await;
assert!(
second_delete_result.is_err(),
"Should fail on second delete"
);
if let Some(err) = second_delete_result.err() {
assert!(
err.to_string().contains("Permission not found"),
"Expected 'Permission not found' error, got: {err}"
);
}
}
#[tokio::test]
async fn test_update_permission_should_fail_if_deleted() {
let state = create_mock_app_state().await;
let state = setup_all_test_environment().await; // Use the new setup function
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;
let delete_result = repo.query_delete_permission(id.clone()).await;
assert!(
delete_result.is_ok(),
"Initial delete failed: {:?}",
delete_result.err()
);
permission.name = "Try Update".into();
let result = repo.query_update_permission(permission).await;
assert!(result.is_err(), "Update on deleted should fail");
if let Some(err) = result.err() {
assert!(
err.to_string().contains("Permission not found"),
"Expected 'Permission not found' error, got: {err}"
);
}
}
#[tokio::test]
async fn test_query_permission_by_id_should_fail_if_not_found() {
let state = create_mock_app_state().await;
let state = setup_all_test_environment().await; // Use the new setup function
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");
if let Some(err) = result.err() {
assert!(
err.to_string().contains("Permission not found"),
"Expected 'Permission not found' error, got: {err}"
);
}
}
+173 -55
View File
@@ -1,5 +1,5 @@
use crate::{
ResourceEnum, create_mock_app_state, get_iso_date, make_thing,
get_iso_date, make_thing,
permissions::{
permissions_repository::PermissionsRepository,
permissions_schema::PermissionsSchema,
@@ -8,6 +8,7 @@ use crate::{
roles_dto::{RolesRequestCreateDto, RolesRequestUpdateDto},
roles_repository::RolesRepository,
},
setup_all_test_environment, ResourceEnum,
};
use surrealdb::Uuid;
@@ -17,58 +18,92 @@ fn generate_unique_name(prefix: &str) -> String {
#[tokio::test]
async fn test_query_create_role_should_succeed() {
let state = create_mock_app_state().await;
let state = setup_all_test_environment().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: make_thing(&ResourceEnum::Permissions.to_string(), &perm_id.clone()),
id: make_thing(&ResourceEnum::Permissions.to_string(), &perm_id),
name: generate_unique_name("read_quiz"),
is_deleted: false,
created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()),
};
perm_repo.query_create_permission(permission).await.unwrap();
let perm_res = perm_repo.query_create_permission(permission).await;
assert!(
perm_res.is_ok(),
"Failed to create permission: {:?}",
perm_res.err()
);
let payload = RolesRequestCreateDto {
name: generate_unique_name("user"),
permissions: vec![perm_id.clone()],
};
let result = role_repo.query_create_role(payload).await;
assert!(result.is_ok());
assert!(result.is_ok(), "Failed to create role: {:?}", result.err());
}
#[tokio::test]
async fn test_query_role_by_name_should_return_data() {
let state = create_mock_app_state().await;
let state = setup_all_test_environment().await;
let role_repo = RolesRepository::new(&state);
let name = generate_unique_name("viewer");
let payload = RolesRequestCreateDto {
name: name.clone(),
permissions: vec![],
};
role_repo.query_create_role(payload.clone()).await.unwrap();
let role = role_repo.query_role_by_name(name.clone()).await.unwrap();
let create_res = role_repo.query_create_role(payload.clone()).await;
assert!(
create_res.is_ok(),
"Failed to create role: {:?}",
create_res.err()
);
let role = role_repo.query_role_by_name(name.clone()).await;
assert!(role.is_ok(), "Failed to get role by name: {:?}", role.err());
let role = role.unwrap();
assert_eq!(role.name, name.clone());
}
#[tokio::test]
async fn test_query_role_by_id_should_return_data() {
let state = create_mock_app_state().await;
let state = setup_all_test_environment().await;
let role_repo = RolesRepository::new(&state);
let name = generate_unique_name("tester");
let payload = RolesRequestCreateDto {
name: name.clone(),
permissions: vec![],
};
role_repo.query_create_role(payload.clone()).await.unwrap();
let role = role_repo.query_role_by_name(name.clone()).await.unwrap();
let result = role_repo.query_role_by_id(role.id).await.unwrap();
assert_eq!(result.name, name.clone());
let create_res = role_repo.query_create_role(payload.clone()).await;
assert!(
create_res.is_ok(),
"Failed to create role: {:?}",
create_res.err()
);
let role = role_repo.query_role_by_name(name.clone()).await;
assert!(role.is_ok(), "Failed to get role by name: {:?}", role.err());
let role = role.unwrap();
let result = role_repo.query_role_by_id(role.id.clone()).await;
assert!(
result.is_ok(),
"Failed to get role by id: {:?}",
result.err()
);
let result_role = result.unwrap();
assert_eq!(result_role.name, name.clone());
}
#[tokio::test]
async fn test_query_update_role_should_update_name_and_permissions() {
let state = create_mock_app_state().await;
let state = setup_all_test_environment().await;
let repo = RolesRepository::new(&state);
let perm_repo = PermissionsRepository::new(&state);
let original_perm_id = Uuid::new_v4().to_string();
@@ -77,22 +112,32 @@ async fn test_query_update_role_should_update_name_and_permissions() {
name: generate_unique_name("original_permission"),
is_deleted: false,
created_at: Some(crate::get_iso_date()),
updated_at: None,
updated_at: Some(crate::get_iso_date()),
};
perm_repo
.query_create_permission(original_perm)
.await
.unwrap();
let perm_res = perm_repo.query_create_permission(original_perm).await;
assert!(
perm_res.is_ok(),
"Failed to create original permission: {:?}",
perm_res.err()
);
let role_upadate_name = generate_unique_name("role_for_update");
let create_payload = RolesRequestCreateDto {
name: role_upadate_name.clone(),
permissions: vec![original_perm_id.clone()],
};
repo.query_create_role(create_payload).await.unwrap();
let existing_role = repo
.query_role_by_name(role_upadate_name.clone())
.await
.unwrap();
let create_res = repo.query_create_role(create_payload).await;
assert!(
create_res.is_ok(),
"Failed to create role: {:?}",
create_res.err()
);
let existing_role = repo.query_role_by_name(role_upadate_name.clone()).await;
assert!(
existing_role.is_ok(),
"Failed to get role by name: {:?}",
existing_role.err()
);
let existing_role = existing_role.unwrap();
let existing_role_id = existing_role.id.clone();
let new_perm_id = Uuid::new_v4().to_string();
let new_perm = PermissionsSchema {
@@ -100,10 +145,15 @@ async fn test_query_update_role_should_update_name_and_permissions() {
name: "New Permission".into(),
is_deleted: false,
created_at: Some(crate::get_iso_date()),
updated_at: None,
updated_at: Some(crate::get_iso_date()),
};
let new_role_name = generate_unique_name("updated_role_name");
perm_repo.query_create_permission(new_perm).await.unwrap();
let perm_res = perm_repo.query_create_permission(new_perm).await;
assert!(
perm_res.is_ok(),
"Failed to create new permission: {:?}",
perm_res.err()
);
let update_payload = RolesRequestUpdateDto {
name: Some(new_role_name.clone()),
permissions: Some(vec![new_perm_id.clone()]),
@@ -112,34 +162,57 @@ async fn test_query_update_role_should_update_name_and_permissions() {
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, new_role_name.clone());
assert!(
update_result.is_ok(),
"Failed to update role: {:?}",
update_result.err()
);
let updated = repo.query_role_by_id(existing_role_id.clone()).await;
assert!(
updated.is_ok(),
"Failed to get updated role: {:?}",
updated.err()
);
assert_eq!(updated.unwrap().name, new_role_name.clone());
}
#[tokio::test]
async fn test_query_delete_role_should_soft_delete() {
let state = create_mock_app_state().await;
let state = setup_all_test_environment().await;
let role_repo = RolesRepository::new(&state);
let name = generate_unique_name("temporary");
let payload = RolesRequestCreateDto {
name: name.clone(),
permissions: vec![],
};
role_repo.query_create_role(payload.clone()).await.unwrap();
let role = role_repo.query_role_by_name(name.clone()).await.unwrap();
let create_res = role_repo.query_create_role(payload.clone()).await;
assert!(
create_res.is_ok(),
"Failed to create role: {:?}",
create_res.err()
);
let role = role_repo.query_role_by_name(name.clone()).await;
assert!(role.is_ok(), "Failed to get role by name: {:?}", role.err());
let role = role.unwrap();
let result = role_repo.query_delete_role(role.id.clone()).await;
assert!(result.is_ok());
assert!(result.is_ok(), "Failed to delete role: {:?}", result.err());
let deleted = role_repo.query_role_by_id(role.id).await;
assert!(deleted.is_err());
assert!(
deleted.is_err(),
"Role should be deleted, but got: {deleted:?}"
);
if let Some(err) = deleted.err() {
assert!(
err.to_string().contains("Role not found"),
"Expected 'Role not found' error, got: {err}"
);
}
}
#[tokio::test]
async fn test_query_update_role_should_fallback_to_existing_permissions_if_none_provided()
{
let state = create_mock_app_state().await;
async fn test_query_update_role_should_fallback_to_existing_permissions_if_none_provided(
) {
let state = setup_all_test_environment().await;
let repo = RolesRepository::new(&state);
let perm_repo = PermissionsRepository::new(&state);
let perm_id = Uuid::new_v4().to_string();
@@ -148,18 +221,31 @@ async fn test_query_update_role_should_fallback_to_existing_permissions_if_none_
name: "Permission for Fallback".into(),
is_deleted: false,
created_at: Some(crate::get_iso_date()),
updated_at: None,
updated_at: Some(crate::get_iso_date()),
};
perm_repo.query_create_permission(permission).await.unwrap();
let perm_res = perm_repo.query_create_permission(permission).await;
assert!(
perm_res.is_ok(),
"Failed to create permission: {:?}",
perm_res.err()
);
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 create_res = repo.query_create_role(create_payload).await;
assert!(
create_res.is_ok(),
"Failed to create role: {:?}",
create_res.err()
);
let existing = repo.query_role_by_name("Role With Permission".into()).await;
assert!(
existing.is_ok(),
"Failed to get role by name: {:?}",
existing.err()
);
let existing = existing.unwrap();
let existing_id = existing.id.clone();
let update_payload = RolesRequestUpdateDto {
name: Some("Updated Role Name".into()),
@@ -169,29 +255,61 @@ async fn test_query_update_role_should_fallback_to_existing_permissions_if_none_
let update_res = repo
.query_update_role(existing_id.clone(), update_payload)
.await;
assert!(update_res.is_ok());
assert!(
update_res.is_ok(),
"Failed to update role (fallback): {:?}",
update_res.err()
);
}
#[tokio::test]
async fn test_query_role_by_name_should_fail_if_not_found() {
let state = create_mock_app_state().await;
let state = setup_all_test_environment().await;
let role_repo = RolesRepository::new(&state);
let result = role_repo.query_role_by_name("ghost-role".into()).await;
assert!(result.is_err());
if let Some(err) = result.err() {
assert!(
err.to_string().contains("Role not found"),
"Expected 'Role not found' error, got: {err}"
);
}
}
#[tokio::test]
async fn test_query_delete_role_should_fail_if_already_deleted() {
let state = create_mock_app_state().await;
let state = setup_all_test_environment().await;
let role_repo = RolesRepository::new(&state);
let name = generate_unique_name("soft_delete_test");
let payload = RolesRequestCreateDto {
name: name.clone(),
permissions: vec![],
};
role_repo.query_create_role(payload.clone()).await.unwrap();
let role = role_repo.query_role_by_name(name.clone()).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());
let create_res = role_repo.query_create_role(payload.clone()).await;
assert!(
create_res.is_ok(),
"Failed to create role: {:?}",
create_res.err()
);
let role = role_repo.query_role_by_name(name.clone()).await;
assert!(role.is_ok(), "Failed to get role by name: {:?}", role.err());
let role = role.unwrap();
let del_res = role_repo.query_delete_role(role.id.clone()).await;
assert!(
del_res.is_ok(),
"Failed to delete role: {:?}",
del_res.err()
);
let result_fut = role_repo.query_delete_role(role.id);
let result_val = result_fut.await;
assert!(
result_val.is_err(),
"Role should already be deleted, but got: {result_val:?}"
);
if let Some(err) = result_val.err() {
assert!(
err.to_string().contains("Role not found"),
"Expected 'Role not found' error, got: {err}"
);
}
}

Some files were not shown because too many files have changed in this diff Show More