chore: add test coverage

This commit is contained in:
Maulana Sodiqin
2025-03-23 20:42:38 +07:00
parent 0ae1e26ba8
commit 5be737ad70
26 changed files with 1057 additions and 132 deletions
+16 -16
View File
@@ -6,15 +6,13 @@ use super::{
use crate::{
common_response, decode_refresh_token, encode_access_token, encode_refresh_token,
encode_reset_password_token, extract_email_token, generate_otp, get_iso_date,
hash_password, send_email, success_response, validate_request, verify_password,
AppState, Env, ResourceEnum, ResponseSuccessDto, UsersActiveInactiveSchema,
UsersItemDto, UsersRepository, UsersSchema, UsersSetNewPasswordSchema,
hash_password, make_thing, send_email, success_response, validate_request,
verify_password, AppState, Env, ResourceEnum, ResponseSuccessDto, RolesEnum,
RolesRepository, UsersActiveInactiveSchema, UsersItemDto, UsersRepository,
UsersSchema, UsersSetNewPasswordSchema,
};
use axum::{http::StatusCode, response::Response};
use surrealdb::{
sql::{Id, Thing},
Uuid,
};
use surrealdb::Uuid;
pub struct AuthService;
@@ -114,6 +112,12 @@ impl AuthService {
let user_repo = UsersRepository::new(state);
let auth_repo = AuthRepository::new(state);
let role_repo = RolesRepository::new(state);
let role = role_repo
.query_role_by_name(RolesEnum::Student.to_string())
.await
.unwrap();
if user_repo
.query_user_by_email(payload.email.clone())
@@ -163,15 +167,11 @@ impl AuthService {
}
}
let role_thing = Thing::from((
ResourceEnum::Roles.to_string(),
Id::String(Uuid::new_v4().to_string()),
));
let user_thing = Thing::from((
ResourceEnum::Users.to_string(),
Id::String(Uuid::new_v4().to_string()),
));
let role_thing = make_thing(&ResourceEnum::Roles.to_string(), &role.id);
let user_thing = make_thing(
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
);
match user_repo
.query_create_user(UsersSchema {
+4 -2
View File
@@ -1,18 +1,20 @@
use axum::{middleware::from_fn, Router};
pub mod auth;
pub mod docs;
pub mod permissions;
pub mod roles;
pub mod users;
pub use auth::*;
pub use docs::*;
pub use permissions::*;
pub use roles::*;
pub use users::*;
pub async fn routes() -> Router {
let public_routes = Router::new().nest("/auth", auth_router());
let protected_routes = Router::new()
.nest("/users", users_router())
.layer(from_fn(auth::auth_middleware::auth_middleware));
Router::new().merge(public_routes).merge(protected_routes)
}
View File
+12
View File
@@ -0,0 +1,12 @@
pub mod permissions_dto;
pub mod permissions_enum;
pub mod permissions_repository;
pub mod permissions_schema;
#[cfg(test)]
pub mod permissions_repository_test;
pub use permissions_dto::*;
pub use permissions_enum::*;
pub use permissions_repository::*;
pub use permissions_schema::*;
@@ -0,0 +1,15 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct PermissionsRequestDto {
pub name: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct PermissionsItemDto {
pub id: String,
pub name: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
@@ -0,0 +1,63 @@
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PermissionsEnum {
ReadListUsers,
ReadDetailUsers,
CreateUsers,
DeleteUsers,
UpdateUsers,
ReadListRoles,
ReadDetailRoles,
CreateRoles,
DeleteRoles,
UpdateRoles,
ReadListPermissions,
ReadDetailPermissions,
CreatePermissions,
DeletePermissions,
UpdatePermissions,
ReadListSessions,
ReadDetailSessions,
CreateSessions,
UpdateSessions,
DeleteSessions,
ReadListTests,
ReadDetailTests,
CreateTests,
UpdateTests,
DeleteTests,
}
impl fmt::Display for PermissionsEnum {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let permission_str = match self {
PermissionsEnum::ReadListUsers => "Read List Users",
PermissionsEnum::ReadDetailUsers => "Read Detail Users",
PermissionsEnum::CreateUsers => "Create Users",
PermissionsEnum::DeleteUsers => "Delete Users",
PermissionsEnum::UpdateUsers => "Update Users",
PermissionsEnum::ReadListRoles => "Read List Roles",
PermissionsEnum::ReadDetailRoles => "Read Detail Roles",
PermissionsEnum::CreateRoles => "Create Roles",
PermissionsEnum::DeleteRoles => "Delete Roles",
PermissionsEnum::UpdateRoles => "Update Roles",
PermissionsEnum::ReadListPermissions => "Read List Permissions",
PermissionsEnum::ReadDetailPermissions => "Read Detail Permissions",
PermissionsEnum::CreatePermissions => "Create Permissions",
PermissionsEnum::DeletePermissions => "Delete Permissions",
PermissionsEnum::UpdatePermissions => "Update Permissions",
PermissionsEnum::ReadListSessions => "Read List Sessions",
PermissionsEnum::ReadDetailSessions => "Read Detail Sessions",
PermissionsEnum::CreateSessions => "Create Sessions",
PermissionsEnum::UpdateSessions => "Update Sessions",
PermissionsEnum::DeleteSessions => "Delete Sessions",
PermissionsEnum::ReadListTests => "Read List Tests",
PermissionsEnum::ReadDetailTests => "Read Detail Tests",
PermissionsEnum::CreateTests => "Create Tests",
PermissionsEnum::UpdateTests => "Update Tests",
PermissionsEnum::DeleteTests => "Delete Tests",
};
write!(f, "{}", permission_str)
}
}
@@ -0,0 +1,108 @@
use super::PermissionsSchema;
use crate::{
get_id, make_thing, query_list_with_meta, AppState, MetaRequestDto, ResourceEnum,
ResponseListSuccessDto,
};
use anyhow::{bail, Result};
pub struct PermissionsRepository<'a> {
state: &'a AppState,
}
impl<'a> PermissionsRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
pub async fn query_permission_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<PermissionsSchema>>> {
let mut conditions = vec!["is_deleted = false".into()];
if meta.search.is_some() {
conditions.push("string::contains(name, $search)".into());
}
if meta.filter_by.is_some() && meta.filter.is_some() {
let filter_by = meta.filter_by.as_ref().unwrap();
conditions.push(format!("{} = $filter", filter_by));
}
query_list_with_meta(
&self.state.surrealdb_ws,
&ResourceEnum::Permissions.to_string(),
&meta,
conditions,
)
.await
}
pub async fn query_permission_by_id(
&self,
id: String,
) -> Result<PermissionsSchema> {
let db = &self.state.surrealdb_ws;
let result: Option<PermissionsSchema> = db
.select((ResourceEnum::Permissions.to_string(), id.clone()))
.await?;
match result {
Some(permission) if !permission.is_deleted => Ok(permission),
_ => bail!("Permission not found"),
}
}
pub async fn query_create_permission(
&self,
data: PermissionsSchema,
) -> Result<String> {
let db = &self.state.surrealdb_ws;
let record: Option<PermissionsSchema> = db
.create(ResourceEnum::Permissions.to_string())
.content(data)
.await?;
match record {
Some(_) => Ok("Success create permission".into()),
None => bail!("Failed to create permission"),
}
}
pub async fn query_update_permission(
&self,
data: PermissionsSchema,
) -> Result<String> {
let db = &self.state.surrealdb_ws;
let record_key = get_id(&data.id)?;
let existing = self.query_permission_by_id(data.id.id.to_raw()).await?;
if existing.is_deleted {
bail!("Permission already deleted");
}
let merged = PermissionsSchema {
created_at: existing.created_at,
..data.clone()
};
let record: Option<PermissionsSchema> =
db.update(record_key).merge(merged).await?;
match record {
Some(_) => Ok("Success update permission".into()),
None => bail!("Failed to update permission"),
}
}
pub async fn query_delete_permission(&self, id: String) -> Result<String> {
let db = &self.state.surrealdb_ws;
let permission_id = make_thing(&ResourceEnum::Permissions.to_string(), &id);
let permission = self
.query_permission_by_id(permission_id.id.to_raw())
.await?;
if permission.is_deleted {
bail!("Permission already deleted");
}
let record_key = get_id(&permission.id)?;
let record: Option<PermissionsSchema> = db
.update(record_key)
.merge(serde_json::json!({ "is_deleted": true }))
.await?;
match record {
Some(_) => Ok("Success delete permission".into()),
None => bail!("Failed to delete permission"),
}
}
}
@@ -0,0 +1,111 @@
use crate::{
apps::v1::permissions::{PermissionsRepository, PermissionsSchema},
create_mock_app_state,
};
use chrono::Utc;
fn create_dummy_permission(name: &str) -> PermissionsSchema {
PermissionsSchema {
name: name.to_string(),
..Default::default()
}
}
#[tokio::test]
async fn test_create_permission_should_succeed() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let permission = create_dummy_permission("Test Permission");
let result = repo.query_create_permission(permission).await;
assert!(result.is_ok(), "Create failed: {:?}", result.err());
}
#[tokio::test]
async fn test_query_permission_list_should_return_data() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let _ = repo
.query_create_permission(create_dummy_permission("View"))
.await;
let meta = crate::MetaRequestDto {
page: Some(1),
per_page: Some(10),
search: None,
sort_by: None,
order: None,
filter: None,
filter_by: None,
};
let result = repo.query_permission_list(meta).await;
assert!(result.is_ok(), "List failed: {:?}", result.err());
assert!(!result.unwrap().data.is_empty(), "Data should not be empty");
}
#[tokio::test]
async fn test_query_permission_by_id_should_succeed() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let permission = create_dummy_permission("Detail");
let _ = repo.query_create_permission(permission.clone()).await;
let id = permission.id.id.to_raw();
let result = repo.query_permission_by_id(id).await;
assert!(result.is_ok(), "Get by id failed: {:?}", result.err());
}
#[tokio::test]
async fn test_update_permission_should_succeed() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let mut permission = create_dummy_permission("Update This");
let _ = repo.query_create_permission(permission.clone()).await;
permission.name = "Updated Name".into();
permission.updated_at = Some(Utc::now().to_rfc3339());
let result = repo.query_update_permission(permission).await;
assert!(result.is_ok(), "Update failed: {:?}", result.err());
}
#[tokio::test]
async fn test_delete_permission_should_succeed() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let permission = create_dummy_permission("To Be Deleted");
let _ = repo.query_create_permission(permission.clone()).await;
let id = permission.id.id.to_raw();
let result = repo.query_delete_permission(id).await;
assert!(result.is_ok(), "Delete failed: {:?}", result.err());
}
#[tokio::test]
async fn test_delete_permission_should_fail_if_already_deleted() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let permission = create_dummy_permission("Delete Twice");
let _ = repo.query_create_permission(permission.clone()).await;
let id = permission.id.id.to_raw();
let _ = repo.query_delete_permission(id.clone()).await;
let second = repo.query_delete_permission(id).await;
assert!(second.is_err(), "Should fail on second delete");
}
#[tokio::test]
async fn test_update_permission_should_fail_if_deleted() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let mut permission = create_dummy_permission("To Be Updated Then Deleted");
let _ = repo.query_create_permission(permission.clone()).await;
let id = permission.id.id.to_raw();
let _ = repo.query_delete_permission(id.clone()).await;
permission.name = "Try Update".into();
let result = repo.query_update_permission(permission).await;
assert!(result.is_err(), "Update on deleted should fail");
}
#[tokio::test]
async fn test_query_permission_by_id_should_fail_if_not_found() {
let state = create_mock_app_state().await;
let repo = PermissionsRepository::new(&state);
let result = repo.query_permission_by_id("non-existent-id".into()).await;
assert!(result.is_err(), "Expected error for not found id");
}
@@ -0,0 +1,30 @@
use crate::ResourceEnum;
use serde::{Deserialize, Serialize};
use surrealdb::{
sql::{Id, Thing},
Uuid,
};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PermissionsSchema {
pub id: Thing,
pub name: String,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
impl Default for PermissionsSchema {
fn default() -> Self {
PermissionsSchema {
id: Thing::from((
ResourceEnum::Permissions.to_string(),
Id::String(Uuid::new_v4().to_string()),
)),
name: String::new(),
is_deleted: false,
created_at: None,
updated_at: None,
}
}
}
View File
+12
View File
@@ -0,0 +1,12 @@
pub mod roles_dto;
pub mod roles_enum;
pub mod roles_repository;
pub mod roles_schema;
#[cfg(test)]
pub mod roles_repository_test;
pub use roles_dto::*;
pub use roles_enum::*;
pub use roles_repository::*;
pub use roles_schema::*;
+33
View File
@@ -0,0 +1,33 @@
use crate::PermissionsItemDto;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct RolesRequestUpdateDto {
pub name: Option<String>,
pub permissions: Option<Vec<String>>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct RolesRequestCreateDto {
pub name: String,
pub permissions: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct RolesItemListDto {
pub id: String,
pub name: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct RolesItemDto {
pub id: String,
pub name: String,
pub is_deleted: bool,
pub permissions: Vec<PermissionsItemDto>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
+19
View File
@@ -0,0 +1,19 @@
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RolesEnum {
Admin,
Student,
Staf,
}
impl fmt::Display for RolesEnum {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let roles_str = match self {
RolesEnum::Admin => "Admin",
RolesEnum::Student => "Student",
RolesEnum::Staf => "Staf",
};
write!(f, "{}", roles_str)
}
}
+203
View File
@@ -0,0 +1,203 @@
use super::{
RolesItemDto, RolesRequestCreateDto, RolesRequestUpdateDto, RolesSchema,
};
use crate::{
get_id, make_thing, query_list_with_meta, AppState, MetaRequestDto,
PermissionsItemDto, ResourceEnum, ResponseListSuccessDto,
};
use anyhow::{bail, Result};
use surrealdb::sql::Thing;
use surrealdb::Uuid;
pub struct RolesRepository<'a> {
state: &'a AppState,
}
impl<'a> RolesRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
pub async fn query_raw_role_by_id(&self, id: &str) -> Result<RolesSchema> {
let db = &self.state.surrealdb_ws;
let role: Option<RolesSchema> =
db.select((ResourceEnum::Roles.to_string(), id)).await?;
match role {
Some(r) if !r.is_deleted => Ok(r),
_ => bail!("Role not found"),
}
}
pub async fn query_role_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<RolesSchema>>> {
let mut conditions = vec!["is_deleted = false".into()];
if meta.search.is_some() {
conditions.push("string::contains(name, $search)".into());
}
if meta.filter_by.is_some() && meta.filter.is_some() {
let filter_by = meta.filter_by.as_ref().unwrap();
conditions.push(format!("{} = $filter", filter_by));
}
query_list_with_meta(
&self.state.surrealdb_ws,
&ResourceEnum::Roles.to_string(),
&meta,
conditions,
)
.await
}
pub async fn query_role_by_name(&self, name: String) -> Result<RolesItemDto> {
let db = &self.state.surrealdb_ws;
let sql = format!(
"SELECT * FROM {} WHERE name = $name AND is_deleted = false",
ResourceEnum::Roles.to_string()
);
let mut response: Vec<RolesSchema> =
db.query(sql).bind(("name", name.clone())).await?.take(0)?;
let role = match response.pop() {
Some(r) => r,
None => bail!("Role not found"),
};
let mut permissions: Vec<PermissionsItemDto> = Vec::new();
for permission_thing in &role.permissions {
let result: Option<PermissionsItemDto> = db
.select((permission_thing.tb.clone(), permission_thing.id.to_string()))
.await?;
if let Some(p) = result {
permissions.push(p);
}
}
Ok(RolesItemDto {
id: role.id.id.to_raw(),
name: role.name,
is_deleted: role.is_deleted,
permissions,
created_at: role.created_at,
updated_at: role.updated_at,
})
}
pub async fn query_role_by_id(&self, id: String) -> Result<RolesItemDto> {
let db = &self.state.surrealdb_ws;
let role: Option<RolesSchema> = db
.select((ResourceEnum::Roles.to_string(), id.clone()))
.await?;
let role = match role {
Some(r) if !r.is_deleted => r,
_ => bail!("Role not found"),
};
let mut permissions: Vec<PermissionsItemDto> = Vec::new();
for permission_thing in &role.permissions {
let result: Option<PermissionsItemDto> = db
.select((permission_thing.tb.clone(), permission_thing.id.to_string()))
.await?;
if let Some(p) = result {
permissions.push(p);
}
}
Ok(RolesItemDto {
id: role.id.id.to_raw(),
name: role.name,
is_deleted: role.is_deleted,
permissions,
created_at: role.created_at,
updated_at: role.updated_at,
})
}
pub async fn query_create_role(
&self,
payload: RolesRequestCreateDto,
) -> Result<String> {
let db = &self.state.surrealdb_ws;
let role_id = Uuid::new_v4().to_string();
let permission_things: Vec<Thing> = payload
.permissions
.iter()
.map(|id| make_thing(&ResourceEnum::Permissions.to_string(), id))
.collect();
let role = RolesSchema {
id: make_thing(&ResourceEnum::Roles.to_string(), &role_id),
name: payload.name,
is_deleted: false,
permissions: permission_things,
created_at: Some(crate::get_iso_date()),
updated_at: Some(crate::get_iso_date()),
};
let _: Option<RolesSchema> = db
.create((&ResourceEnum::Roles.to_string(), role_id))
.content(role)
.await?;
Ok("Role with permissions created successfully".into())
}
pub async fn query_update_role(
&self,
id: String,
data: RolesRequestUpdateDto,
) -> Result<String> {
let db = &self.state.surrealdb_ws;
let thing_id = make_thing(&ResourceEnum::Roles.to_string(), &id);
let existing = self.query_raw_role_by_id(&id).await?;
if existing.is_deleted {
bail!("Role already deleted");
}
let permissions: Vec<Thing> = if let Some(permission_ids) = &data.permissions {
permission_ids
.iter()
.map(|id| make_thing(&ResourceEnum::Permissions.to_string(), id))
.collect()
} else {
existing
.permissions
.iter()
.map(|p| make_thing(&ResourceEnum::Permissions.to_string(), &p.id.to_raw()))
.collect()
};
let merged = RolesSchema {
id: thing_id,
name: data.name.unwrap_or(existing.name),
permissions,
is_deleted: existing.is_deleted,
created_at: existing.created_at,
updated_at: Some(crate::get_iso_date()),
};
let record: Option<RolesSchema> =
db.update(get_id(&merged.id)?).content(merged).await?;
match record {
Some(_) => Ok("Success update role".into()),
None => bail!("Failed to update role"),
}
}
pub async fn query_delete_role(&self, id: String) -> Result<String> {
let db = &self.state.surrealdb_ws;
let role_id = make_thing(&ResourceEnum::Roles.to_string(), &id);
let role = self.query_role_by_id(role_id.id.to_raw()).await?;
if role.is_deleted {
bail!("Role already deleted");
}
let record_key = get_id(&role_id)?;
let record: Option<RolesSchema> = db
.update(record_key)
.merge(serde_json::json!({ "is_deleted": true }))
.await?;
match record {
Some(_) => Ok("Success delete role".into()),
None => bail!("Failed to delete role"),
}
}
}
+194
View File
@@ -0,0 +1,194 @@
use crate::{
apps::v1::{
permissions::{
permissions_repository::PermissionsRepository,
permissions_schema::PermissionsSchema,
},
roles::{
roles_dto::{RolesRequestCreateDto, RolesRequestUpdateDto},
roles_repository::RolesRepository,
},
},
create_mock_app_state, get_iso_date, make_thing, ResourceEnum,
};
use surrealdb::sql::Thing;
use surrealdb::Uuid;
#[tokio::test]
async fn test_query_create_role_should_succeed() {
let state = create_mock_app_state().await;
let perm_repo = PermissionsRepository::new(&state);
let role_repo = RolesRepository::new(&state);
let perm_id = Uuid::new_v4().to_string();
let permission = PermissionsSchema {
id: Thing::from((ResourceEnum::Permissions.to_string(), perm_id.clone())),
name: "Read Quiz".into(),
is_deleted: false,
created_at: Some(get_iso_date()),
updated_at: None,
};
perm_repo.query_create_permission(permission).await.unwrap();
let payload = RolesRequestCreateDto {
name: "Student".into(),
permissions: vec![perm_id.clone()],
};
let result = role_repo.query_create_role(payload).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_query_role_by_name_should_return_data() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let payload = RolesRequestCreateDto {
name: "Viewer".into(),
permissions: vec![],
};
role_repo.query_create_role(payload.clone()).await.unwrap();
let role = role_repo.query_role_by_name("Viewer".into()).await.unwrap();
assert_eq!(role.name, "Viewer");
}
#[tokio::test]
async fn test_query_role_by_id_should_return_data() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let payload = RolesRequestCreateDto {
name: "Tester".into(),
permissions: vec![],
};
role_repo.query_create_role(payload.clone()).await.unwrap();
let role = role_repo.query_role_by_name("Tester".into()).await.unwrap();
let result = role_repo.query_role_by_id(role.id).await.unwrap();
assert_eq!(result.name, "Tester");
}
#[tokio::test]
async fn test_query_update_role_should_update_name_and_permissions() {
let state = create_mock_app_state().await;
let repo = RolesRepository::new(&state);
let perm_repo = PermissionsRepository::new(&state);
let original_perm_id = Uuid::new_v4().to_string();
let original_perm = PermissionsSchema {
id: make_thing(&ResourceEnum::Permissions.to_string(), &original_perm_id),
name: "Original Permission".into(),
is_deleted: false,
created_at: Some(crate::get_iso_date()),
updated_at: None,
};
perm_repo
.query_create_permission(original_perm)
.await
.unwrap();
let create_payload = RolesRequestCreateDto {
name: "Role For Update".into(),
permissions: vec![original_perm_id.clone()],
};
repo.query_create_role(create_payload).await.unwrap();
let existing_role = repo
.query_role_by_name("Role For Update".into())
.await
.unwrap();
let existing_role_id = existing_role.id.clone();
let new_perm_id = Uuid::new_v4().to_string();
let new_perm = PermissionsSchema {
id: make_thing(&ResourceEnum::Permissions.to_string(), &new_perm_id),
name: "New Permission".into(),
is_deleted: false,
created_at: Some(crate::get_iso_date()),
updated_at: None,
};
perm_repo.query_create_permission(new_perm).await.unwrap();
let update_payload = RolesRequestUpdateDto {
name: Some("Updated Role Name".into()),
permissions: Some(vec![new_perm_id.clone()]),
};
let update_result = repo
.query_update_role(existing_role_id.clone(), update_payload)
.await;
assert!(update_result.is_ok());
let updated = repo
.query_role_by_id(existing_role_id.clone())
.await
.unwrap();
assert_eq!(updated.name, "Updated Role Name");
}
#[tokio::test]
async fn test_query_delete_role_should_soft_delete() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let payload = RolesRequestCreateDto {
name: "Temporary".into(),
permissions: vec![],
};
role_repo.query_create_role(payload.clone()).await.unwrap();
let role = role_repo
.query_role_by_name("Temporary".into())
.await
.unwrap();
let result = role_repo.query_delete_role(role.id.clone()).await;
assert!(result.is_ok());
let deleted = role_repo.query_role_by_id(role.id).await;
assert!(deleted.is_err());
}
#[tokio::test]
async fn test_query_update_role_should_fallback_to_existing_permissions_if_none_provided(
) {
let state = create_mock_app_state().await;
let repo = RolesRepository::new(&state);
let perm_repo = PermissionsRepository::new(&state);
let perm_id = Uuid::new_v4().to_string();
let permission = PermissionsSchema {
id: make_thing(&ResourceEnum::Permissions.to_string(), &perm_id),
name: "Permission for Fallback".into(),
is_deleted: false,
created_at: Some(crate::get_iso_date()),
updated_at: None,
};
perm_repo.query_create_permission(permission).await.unwrap();
let create_payload = RolesRequestCreateDto {
name: "Role With Permission".into(),
permissions: vec![perm_id.clone()],
};
repo.query_create_role(create_payload).await.unwrap();
let existing = repo
.query_role_by_name("Role With Permission".into())
.await
.unwrap();
let existing_id = existing.id.clone();
let update_payload = RolesRequestUpdateDto {
name: Some("Updated Role Name".into()),
permissions: None,
};
let update_res = repo
.query_update_role(existing_id.clone(), update_payload)
.await;
assert!(update_res.is_ok());
}
#[tokio::test]
async fn test_query_role_by_name_should_fail_if_not_found() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let result = role_repo.query_role_by_name("ghost-role".into()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_query_delete_role_should_fail_if_already_deleted() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let payload = RolesRequestCreateDto {
name: "SoftDeleteTest".into(),
permissions: vec![],
};
role_repo.query_create_role(payload.clone()).await.unwrap();
let role = role_repo
.query_role_by_name("SoftDeleteTest".into())
.await
.unwrap();
role_repo.query_delete_role(role.id.clone()).await.unwrap();
let result = role_repo.query_delete_role(role.id);
assert!(result.await.is_err());
}
+36
View File
@@ -0,0 +1,36 @@
use serde::{Deserialize, Serialize};
use surrealdb::{
sql::{Id, Thing},
Uuid,
};
use crate::ResourceEnum;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RolesSchema {
pub id: Thing,
pub name: String,
pub is_deleted: bool,
pub permissions: Vec<Thing>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
impl Default for RolesSchema {
fn default() -> Self {
RolesSchema {
id: Thing::from((
ResourceEnum::Roles.to_string(),
Id::String(Uuid::new_v4().to_string()),
)),
permissions: vec![Thing::from((
ResourceEnum::Permissions.to_string(),
Id::String(Uuid::new_v4().to_string()),
))],
name: String::new(),
is_deleted: false,
created_at: None,
updated_at: None,
}
}
}
+83 -17
View File
@@ -1,6 +1,7 @@
use crate::{
apps::v1::users_router, create_mock_app_state, UsersActiveInactiveRequestDto,
UsersCreateRequestDto, UsersRepository, UsersUpdateRequestDto,
apps::v1::users_router, create_mock_app_state, RolesEnum, RolesRepository,
UsersActiveInactiveRequestDto, UsersCreateRequestDto, UsersRepository,
UsersUpdateRequestDto,
};
use axum::{http::StatusCode, Extension};
use axum_test::TestServer;
@@ -20,6 +21,12 @@ async fn test_get_user_list_should_return_200() {
#[tokio::test]
async fn test_create_user_should_return_201() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::Student.to_string())
.await
.unwrap();
let role_id = role.id;
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state));
@@ -27,6 +34,7 @@ async fn test_create_user_should_return_201() {
let payload = UsersCreateRequestDto {
fullname: "Create Data #1".into(),
email: "test@create.com".into(),
role_id,
password: "Password1!".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
@@ -41,6 +49,12 @@ async fn test_create_user_should_return_201() {
#[tokio::test]
async fn test_delete_user_should_return_200() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::Student.to_string())
.await
.unwrap();
let role_id = role.id;
let repo = UsersRepository::new(&state);
let app = axum::Router::new()
.nest("/v1/users", users_router())
@@ -51,6 +65,7 @@ async fn test_delete_user_should_return_200() {
email: "test@create.com".into(),
password: "Password1!".into(),
student_type: "general".into(),
role_id,
phone_number: "081234567890".into(),
is_active: true,
referral_code: None,
@@ -69,6 +84,12 @@ async fn test_delete_user_should_return_200() {
#[tokio::test]
async fn test_get_user_detail_should_return_200() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::Student.to_string())
.await
.unwrap();
let role_id = role.id;
let repo = UsersRepository::new(&state);
let app = axum::Router::new()
.nest("/v1/users", users_router())
@@ -77,6 +98,7 @@ async fn test_get_user_detail_should_return_200() {
let payload = UsersCreateRequestDto {
fullname: "Detail User".into(),
email: "detail@test.com".into(),
role_id,
password: "Password1!".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
@@ -94,6 +116,12 @@ async fn test_get_user_detail_should_return_200() {
#[tokio::test]
async fn test_activate_user_should_return_200() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::Student.to_string())
.await
.unwrap();
let role_id = role.id;
let repo = UsersRepository::new(&state);
let app = axum::Router::new()
.nest("/v1/users", users_router())
@@ -103,6 +131,7 @@ async fn test_activate_user_should_return_200() {
fullname: "Inactive User".into(),
email: "inactive@test.com".into(),
password: "Password1!".into(),
role_id,
student_type: "general".into(),
phone_number: "081234567890".into(),
is_active: false,
@@ -122,6 +151,12 @@ async fn test_activate_user_should_return_200() {
#[tokio::test]
async fn test_update_user_should_return_200() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::Student.to_string())
.await
.unwrap();
let role_id = role.id.clone();
let repo = UsersRepository::new(&state);
let app = axum::Router::new()
.nest("/v1/users", users_router())
@@ -131,6 +166,7 @@ async fn test_update_user_should_return_200() {
fullname: "Old Name".into(),
email: "update@test.com".into(),
password: "Password1!".into(),
role_id: role_id.clone(),
student_type: "general".into(),
phone_number: "081234567890".into(),
is_active: true,
@@ -145,6 +181,7 @@ async fn test_update_user_should_return_200() {
email: "update@test.com".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
role_id: role_id.clone(),
is_active: true,
referral_code: None,
referred_by: None,
@@ -165,6 +202,12 @@ async fn test_update_user_should_return_200() {
#[tokio::test]
async fn test_create_user_should_fail_if_email_taken() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::Student.to_string())
.await
.unwrap();
let role_id = role.id.clone();
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state.clone()));
@@ -174,6 +217,7 @@ async fn test_create_user_should_fail_if_email_taken() {
email: "taken@example.com".into(),
password: "Password1!".into(),
student_type: "general".into(),
role_id,
phone_number: "081234567890".into(),
is_active: true,
referral_code: None,
@@ -203,6 +247,12 @@ async fn test_get_user_detail_should_fail_if_not_found() {
#[tokio::test]
async fn test_delete_user_should_fail_if_already_deleted() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::Student.to_string())
.await
.unwrap();
let role_id = role.id.clone();
let repo = UsersRepository::new(&state);
let app = axum::Router::new()
.nest("/v1/users", users_router())
@@ -211,6 +261,7 @@ async fn test_delete_user_should_fail_if_already_deleted() {
let payload = UsersCreateRequestDto {
fullname: "To Be Deleted".into(),
email: "delete-me@example.com".into(),
role_id,
password: "Password1!".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
@@ -232,6 +283,12 @@ async fn test_delete_user_should_fail_if_already_deleted() {
#[tokio::test]
async fn test_update_user_should_fail_if_user_not_found() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::Student.to_string())
.await
.unwrap();
let role_id = role.id.clone();
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state));
@@ -241,6 +298,7 @@ async fn test_update_user_should_fail_if_user_not_found() {
email: "nonexistent@test.com".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
role_id,
is_active: true,
referral_code: None,
referred_by: None,
@@ -330,21 +388,6 @@ async fn test_list_users_should_fallback_on_invalid_order() {
assert_eq!(res.status_code(), StatusCode::OK);
}
#[tokio::test]
async fn test_list_users_with_search_should_return_empty_if_no_match() {
let state = create_mock_app_state().await;
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state));
let server = TestServer::new(app).unwrap();
let res = server
.get("/v1/users?page=1&per_page=10&search=no-user-should-match-this")
.await;
assert_eq!(res.status_code(), StatusCode::OK);
let body: serde_json::Value = res.json();
assert_eq!(body["data"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn test_list_users_with_invalid_filter_by_should_return_empty() {
let state = create_mock_app_state().await;
@@ -389,12 +432,19 @@ async fn test_user_detail_should_fail_if_user_not_found() {
#[tokio::test]
async fn test_user_detail_should_fail_if_user_is_soft_deleted() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::Student.to_string())
.await
.unwrap();
let role_id = role.id.clone();
let repo = UsersRepository::new(&state);
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state.clone()));
let server = TestServer::new(app).unwrap();
let payload = UsersCreateRequestDto {
role_id,
fullname: "Soft Deleted".into(),
email: "softdeleted@test.com".into(),
password: "Password1!".into(),
@@ -417,12 +467,19 @@ async fn test_user_detail_should_fail_if_user_is_soft_deleted() {
#[tokio::test]
async fn test_update_user_should_fail_if_user_is_deleted() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::Student.to_string())
.await
.unwrap();
let role_id = role.id.clone();
let repo = UsersRepository::new(&state);
let app = axum::Router::new()
.nest("/v1/users", users_router())
.layer(Extension(state.clone()));
let server = TestServer::new(app).unwrap();
let payload = UsersCreateRequestDto {
role_id: role_id.clone(),
fullname: "To Be Deleted".into(),
email: "deleteduser@test.com".into(),
password: "Password1!".into(),
@@ -437,6 +494,7 @@ async fn test_update_user_should_fail_if_user_is_deleted() {
let user_id = user.id.id.to_raw();
let _ = repo.query_delete_user(user_id.clone()).await.unwrap();
let update_payload = UsersUpdateRequestDto {
role_id: role_id.clone(),
fullname: "Should Fail".into(),
email: "deleteduser@test.com".into(),
student_type: "general".into(),
@@ -462,6 +520,12 @@ async fn test_update_user_should_fail_if_user_is_deleted() {
#[tokio::test]
async fn test_update_user_should_fail_if_payload_invalid() {
let state = create_mock_app_state().await;
let role_repo = RolesRepository::new(&state);
let role = role_repo
.query_role_by_name(RolesEnum::Student.to_string())
.await
.unwrap();
let role_id = role.id.clone();
let repo = UsersRepository::new(&state);
let app = axum::Router::new()
.nest("/v1/users", users_router())
@@ -470,6 +534,7 @@ async fn test_update_user_should_fail_if_payload_invalid() {
let payload = UsersCreateRequestDto {
fullname: "Invalid Payload".into(),
email: "invalid@test.com".into(),
role_id: role_id.clone(),
password: "Password1!".into(),
student_type: "general".into(),
phone_number: "081234567890".into(),
@@ -487,6 +552,7 @@ async fn test_update_user_should_fail_if_payload_invalid() {
phone_number: "081234567890".into(),
is_active: true,
referral_code: None,
role_id: role_id.clone(),
referred_by: None,
identity_number: Some("1234567890123456".into()),
religion: Some("Islam".into()),
+2
View File
@@ -40,6 +40,7 @@ pub struct UsersCreateRequestDto {
pub referral_code: Option<String>,
pub referred_by: Option<String>,
pub is_active: bool,
pub role_id: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
@@ -75,6 +76,7 @@ pub struct UsersUpdateRequestDto {
pub gender: Option<String>,
pub birthdate: Option<String>,
pub avatar: Option<String>,
pub role_id: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
+4 -91
View File
@@ -1,7 +1,7 @@
use super::{UsersActiveInactiveSchema, UsersSchema, UsersSetNewPasswordSchema};
use crate::{
get_id, make_thing, AppState, CountResult, MetaRequestDto, MetaResponseDto,
ResourceEnum, ResponseListSuccessDto,
get_id, make_thing, query_list_with_meta, AppState, MetaRequestDto, ResourceEnum,
ResponseListSuccessDto,
};
use anyhow::{bail, Result};
@@ -20,102 +20,15 @@ impl<'a> UsersRepository<'a> {
) -> Result<ResponseListSuccessDto<Vec<UsersSchema>>> {
let db = &self.state.surrealdb_ws;
let table = ResourceEnum::Users.to_string();
let page = meta.page.unwrap_or(1);
let per_page = meta.per_page.unwrap_or(10);
if page < 1 || per_page < 1 {
bail!("Invalid pagination: page and per_page must be greater than 0");
}
let start = (page - 1) * per_page;
let mut sql = format!("SELECT * FROM {}", table);
let mut conditions = vec!["is_deleted = false".to_string()];
if meta.search.is_some() {
conditions.push("string::contains(fullname, $search)".to_string());
conditions.push("string::contains(name, $search)".into());
}
if meta.filter_by.is_some() && meta.filter.is_some() {
let filter_by = meta.filter_by.as_ref().unwrap();
conditions.push(format!("{} = $filter", filter_by));
}
if !conditions.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&conditions.join(" AND "));
}
if let Some(sort_by) = &meta.sort_by {
let order = match meta
.order
.clone()
.unwrap_or_else(|| "ASC".to_string())
.to_uppercase()
.as_str()
{
"ASC" => "ASC",
"DESC" => "DESC",
_ => "ASC", // fallback kalau order invalid
};
sql.push_str(&format!(" ORDER BY {} {}", sort_by, order));
}
sql.push_str(" LIMIT $per_page START $start");
let mut query = db.query(sql);
if let Some(search) = meta.search.clone() {
query = query.bind(("search", search));
}
if let Some(filter_val) = meta.filter.clone() {
if let Ok(b) = filter_val.parse::<bool>() {
query = query.bind(("filter", b));
} else if let Ok(i) = filter_val.parse::<i64>() {
query = query.bind(("filter", i));
} else {
query = query.bind(("filter", filter_val));
}
}
query = query.bind(("per_page", per_page)).bind(("start", start));
let users: Vec<UsersSchema> = query.await?.take(0)?;
let mut count_sql =
format!("SELECT count() FROM {} WHERE is_deleted = false", table);
if meta.filter_by.is_some() && meta.filter.is_some() {
let filter_by = meta.filter_by.as_ref().unwrap();
count_sql.push_str(&format!(" AND {} = $filter", filter_by));
}
let mut count_query = db.query(count_sql);
if let Some(filter_val) = meta.filter.clone() {
if let Ok(b) = filter_val.parse::<bool>() {
count_query = count_query.bind(("filter", b));
} else if let Ok(i) = filter_val.parse::<i64>() {
count_query = count_query.bind(("filter", i));
} else {
count_query = count_query.bind(("filter", filter_val));
}
}
let count_result: Vec<CountResult> = count_query.await?.take(0)?;
let total = count_result.first().map(|c| c.count);
let meta_res = MetaResponseDto {
page: Some(page),
per_page: Some(per_page),
total,
};
Ok(ResponseListSuccessDto {
data: users,
meta: Some(meta_res),
})
query_list_with_meta::<UsersSchema>(db, &table, &meta, conditions).await
}
pub async fn query_user_by_email(&self, email: String) -> Result<UsersSchema> {
+2 -2
View File
@@ -1,4 +1,4 @@
use crate::create_mock_app_state;
use crate::{create_mock_app_state, ResourceEnum};
use crate::{
make_thing, MetaRequestDto, UsersActiveInactiveSchema, UsersRepository,
UsersSchema, UsersSetNewPasswordSchema,
@@ -23,7 +23,7 @@ fn create_test_user(email: &str, fullname: &str, is_active: bool) -> UsersSchema
gender: None,
birthdate: None,
is_profile_completed: false,
role: make_thing("roles", "user"),
role: make_thing(&ResourceEnum::Roles.to_string(), "user"),
created_at: None,
updated_at: None,
}
+2 -1
View File
@@ -53,7 +53,6 @@ impl UsersService {
return common_response(status, &message);
}
let repo = UsersRepository::new(state);
if repo
.query_user_by_email(new_user.email.clone())
.await
@@ -61,6 +60,7 @@ impl UsersService {
{
return common_response(StatusCode::BAD_REQUEST, "User already exists");
}
let role_thing = make_thing(&ResourceEnum::Roles.to_string(), &new_user.role_id);
match repo
.query_create_user(UsersSchema {
email: new_user.email.clone(),
@@ -71,6 +71,7 @@ impl UsersService {
referred_by: new_user.referred_by.clone(),
student_type: new_user.student_type.clone(),
is_active: new_user.is_active.clone(),
role: role_thing,
is_profile_completed: false,
..Default::default()
})
+14
View File
@@ -22,6 +22,20 @@ pub struct MetaRequestDto {
pub filter_by: Option<String>,
}
impl Default for MetaRequestDto {
fn default() -> Self {
MetaRequestDto {
page: Some(1),
per_page: Some(10),
search: None,
sort_by: None,
order: None,
filter: None,
filter_by: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoParams)]
pub struct MetaResponseDto {
pub page: Option<u64>,
+13
View File
@@ -1 +1,14 @@
use surrealdb::{engine::remote::ws::Client, method::Query};
pub fn bind_filter_value(
query: Query<'_, Client>,
val: String,
) -> Query<'_, Client> {
if let Ok(b) = val.parse::<bool>() {
query.bind(("filter", b))
} else if let Ok(i) = val.parse::<i64>() {
query.bind(("filter", i))
} else {
query.bind(("filter", val))
}
}
+4 -3
View File
@@ -1,4 +1,5 @@
use crate::{hash_password, AppState, UsersSchema};
use crate::{hash_password, AppState, ResourceEnum, UsersSchema};
use rand::Rng;
use surrealdb::{
engine::{local::Mem, remote::ws::Ws},
opt::auth::Root,
@@ -60,7 +61,7 @@ pub fn create_test_user(
UsersSchema {
id: make_thing("app_users", &Uuid::new_v4().to_string()),
email: email.to_string(),
fullname: fullname.to_string(),
fullname: format!("Randomize {} {}", fullname, rand::rng().random::<u32>()),
password: hash_password("secret").unwrap(),
is_deleted: false,
avatar: None,
@@ -74,7 +75,7 @@ pub fn create_test_user(
gender: None,
birthdate: None,
is_profile_completed: false,
role: make_thing("roles", "user"),
role: make_thing(&ResourceEnum::Roles.to_string(), "user"),
created_at: None,
updated_at: None,
}
+3
View File
@@ -5,14 +5,17 @@ pub mod generate_otp;
pub mod get_id;
pub mod make_thing;
pub mod mock_test;
pub mod query_list;
pub mod response_format;
pub mod validator;
pub use bind_filter::*;
pub use extract_email::*;
pub use generate_date::*;
pub use generate_otp::*;
pub use get_id::*;
pub use make_thing::*;
pub use mock_test::*;
pub use query_list::*;
pub use response_format::*;
pub use validator::*;
+74
View File
@@ -0,0 +1,74 @@
use crate::{CountResult, MetaRequestDto, MetaResponseDto, ResponseListSuccessDto};
use anyhow::{bail, Result};
use serde::{de::DeserializeOwned, Serialize};
use surrealdb::{engine::remote::ws::Client, Surreal};
use super::bind_filter_value;
pub async fn query_list_with_meta<T>(
db: &Surreal<Client>,
table: &str,
meta: &MetaRequestDto,
conditions: Vec<String>,
) -> Result<ResponseListSuccessDto<Vec<T>>>
where
T: DeserializeOwned + Serialize,
{
let page = meta.page.unwrap_or(1);
let per_page = meta.per_page.unwrap_or(10);
if page < 1 || per_page < 1 {
bail!("Invalid pagination: page and per_page must be greater than 0");
}
let start = (page - 1) * per_page;
let mut sql = format!("SELECT * FROM {}", table);
if !conditions.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&conditions.join(" AND "));
}
if let Some(sort_by) = &meta.sort_by {
let order = match meta
.order
.clone()
.unwrap_or_else(|| "ASC".into())
.to_uppercase()
.as_str()
{
"ASC" => "ASC",
"DESC" => "DESC",
_ => "ASC",
};
sql.push_str(&format!(" ORDER BY {} {}", sort_by, order));
}
sql.push_str(" LIMIT $per_page START $start");
let mut query = db.query(sql);
if let Some(search) = &meta.search {
if !search.is_empty() {
query = query.bind(("search", search.clone()));
}
}
if let Some(filter_val) = meta.filter.clone() {
query = bind_filter_value(query, filter_val);
}
query = query.bind(("per_page", per_page)).bind(("start", start));
let items: Vec<T> = query.await?.take(0)?;
let mut count_sql = format!("SELECT count() FROM {}", table);
if !conditions.is_empty() {
count_sql.push_str(" WHERE ");
count_sql.push_str(&conditions.join(" AND "));
}
let mut count_query = db.query(count_sql);
if let Some(filter_val) = meta.filter.clone() {
count_query = bind_filter_value(count_query, filter_val);
}
let count_result: Vec<CountResult> = count_query.await?.take(0)?;
let total = count_result.first().map(|c| c.count);
let meta = MetaResponseDto {
page: Some(page),
per_page: Some(per_page),
total,
};
Ok(ResponseListSuccessDto {
data: items,
meta: Some(meta),
})
}