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
+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))
}