raisfast 0.2.19

The last backend you'll ever need. Rust-powered headless CMS with built-in blog, ecommerce, wallet, payment and 4 plugin engines.
//! Category service.

use std::sync::Arc;

use async_trait::async_trait;
use raisfast_derive::aspect_service;

use crate::aspects::engine::AspectEngine;
use crate::aspects::slug_aspect;
use crate::commands::{CreateCategoryCmd, UpdateCategoryCmd};
use crate::dto::{CreateCategoryRequest, UpdateCategoryRequest};
use crate::errors::app_error::AppResult;
use crate::middleware::auth::AuthUser;
use crate::models::category::Category;
use crate::types::snowflake_id::SnowflakeId;

/// Category business logic trait.
#[async_trait]
pub trait CategoryService: Send + Sync {
    async fn create(&self, auth: &AuthUser, req: CreateCategoryRequest) -> AppResult<Category>;
    async fn update(
        &self,
        auth: &AuthUser,
        id: SnowflakeId,
        req: UpdateCategoryRequest,
    ) -> AppResult<Category>;
    async fn delete(&self, id: SnowflakeId, auth: &AuthUser) -> AppResult<()>;
    async fn get(&self, id: SnowflakeId, auth: &AuthUser) -> AppResult<Category>;
    async fn list(&self, auth: &AuthUser) -> AppResult<Vec<Category>>;
    async fn list_paginated(
        &self,
        auth: &AuthUser,
        page: i64,
        page_size: i64,
    ) -> AppResult<(Vec<Category>, i64)>;
}

#[aspect_service(entity = "categories", model = Category)]
pub struct CategoryServiceImpl {
    #[engine]
    aspect_engine: Arc<AspectEngine>,
    pool: Arc<crate::db::Pool>,
}

#[async_trait]
impl CategoryService for CategoryServiceImpl {
    async fn create(&self, auth: &AuthUser, req: CreateCategoryRequest) -> AppResult<Category> {
        let (req, _d) = self.before_create(auth, req).await?;
        let slug = slug_aspect::generate_slug(&req.name);
        let parent_id = if let Some(ref raw_id) = req.parent_id {
            if raw_id.parse::<i64>().is_ok() {
                raw_id.parse::<i64>().ok()
            } else {
                let pid = crate::types::snowflake_id::parse_id(raw_id)?;
                let parent =
                    crate::models::category::find_by_id(&self.pool, pid, auth.tenant_id()).await?;
                Some(*parent.id)
            }
        } else {
            None
        };
        let cmd = CreateCategoryCmd {
            name: req.name,
            slug,
            description: req.description,
            parent_id,
            sort_order: req.sort_order.unwrap_or(0),
        };
        let cat =
            crate::models::category::create(&self.pool, &cmd, auth.tenant_id(), auth.user_id())
                .await?;
        self.after_created(&cat);
        Ok(cat)
    }

    async fn update(
        &self,
        auth: &AuthUser,
        id: SnowflakeId,
        req: UpdateCategoryRequest,
    ) -> AppResult<Category> {
        let existing =
            crate::models::category::find_by_id(&self.pool, id, auth.tenant_id()).await?;
        let (req, _d) = self.before_update(auth, &existing, req).await?;
        let new_slug = req
            .name
            .as_ref()
            .map(|n| slug_aspect::generate_slug(n))
            .unwrap_or(existing.slug);

        let parent_id = if let Some(ref raw_id) = req.parent_id {
            if raw_id.parse::<i64>().is_ok() {
                raw_id.parse::<i64>().ok()
            } else {
                let pid = crate::types::snowflake_id::parse_id(raw_id)?;
                let parent =
                    crate::models::category::find_by_id(&self.pool, pid, auth.tenant_id()).await?;
                Some(*parent.id)
            }
        } else {
            None
        };
        let cmd = UpdateCategoryCmd {
            id: existing.id,
            name: req.name,
            slug: Some(new_slug),
            description: req.description,
            parent_id,
            sort_order: req.sort_order,
        };
        let updated =
            crate::models::category::update(&self.pool, &cmd, auth.tenant_id(), auth.user_id())
                .await?;
        self.after_updated(&updated);
        Ok(updated)
    }

    async fn delete(&self, id: SnowflakeId, auth: &AuthUser) -> AppResult<()> {
        let existing =
            crate::models::category::find_by_id(&self.pool, id, auth.tenant_id()).await?;
        self.before_delete(auth, &existing).await?;
        crate::models::category::delete(&self.pool, existing.id, auth.tenant_id()).await?;
        self.after_deleted(&existing);
        Ok(())
    }

    async fn get(&self, id: SnowflakeId, auth: &AuthUser) -> AppResult<Category> {
        crate::models::category::find_by_id(&self.pool, id, auth.tenant_id()).await
    }

    async fn list(&self, auth: &AuthUser) -> AppResult<Vec<Category>> {
        crate::models::category::find_all(&self.pool, auth.tenant_id()).await
    }

    async fn list_paginated(
        &self,
        auth: &AuthUser,
        page: i64,
        page_size: i64,
    ) -> AppResult<(Vec<Category>, i64)> {
        crate::models::category::find_paginated(&self.pool, auth.tenant_id(), page, page_size).await
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::dto::CreateCategoryRequest;

    async fn setup_pool() -> crate::db::Pool {
        crate::test_pool!()
    }

    fn auth(tid: Option<&str>) -> AuthUser {
        AuthUser::from_parts(
            Some(1),
            crate::models::user::UserRole::Admin,
            tid.map(|s| s.to_string()),
        )
    }

    fn make_service(pool: crate::db::Pool) -> Arc<dyn CategoryService> {
        Arc::new(CategoryServiceImpl::new(
            Arc::new(AspectEngine::new()),
            Arc::new(pool),
        ))
    }

    #[tokio::test]
    async fn create_category_basic() {
        let pool = setup_pool().await;
        let svc = make_service(pool.clone());
        let a = auth(None);
        let cat = svc
            .create(
                &a,
                CreateCategoryRequest {
                    name: "Tech".into(),
                    description: Some("Technology".into()),
                    parent_id: None,
                    sort_order: None,
                },
            )
            .await
            .unwrap();
        assert_eq!(cat.name, "Tech");
        assert_eq!(cat.slug, "tech");
    }

    #[tokio::test]
    async fn list_categories_empty() {
        let pool = setup_pool().await;
        let svc = make_service(pool.clone());
        let a = auth(None);
        let cats = svc.list(&a).await.unwrap();
        assert!(cats.is_empty());
    }

    #[tokio::test]
    async fn update_category_renames() {
        let pool = setup_pool().await;
        let svc = make_service(pool.clone());
        let a = auth(None);
        let cat = svc
            .create(
                &a,
                CreateCategoryRequest {
                    name: "Old".into(),
                    description: None,
                    parent_id: None,
                    sort_order: None,
                },
            )
            .await
            .unwrap();
        let updated = svc
            .update(
                &a,
                cat.id,
                crate::dto::UpdateCategoryRequest {
                    name: Some("New".into()),
                    description: None,
                    parent_id: None,
                    sort_order: None,
                },
            )
            .await
            .unwrap();
        assert_eq!(updated.name, "New");
        assert_eq!(updated.slug, "new");
    }

    #[tokio::test]
    async fn delete_category() {
        let pool = setup_pool().await;
        let svc = make_service(pool.clone());
        let a = auth(None);
        let cat = svc
            .create(
                &a,
                CreateCategoryRequest {
                    name: "Del".into(),
                    description: None,
                    parent_id: None,
                    sort_order: None,
                },
            )
            .await
            .unwrap();
        svc.delete(cat.id, &a).await.unwrap();
        let cats = svc.list(&a).await.unwrap();
        assert!(cats.is_empty());
    }

    #[tokio::test]
    async fn delete_category_not_found() {
        let pool = setup_pool().await;
        let svc = make_service(pool.clone());
        let a = auth(None);
        assert!(svc.delete(SnowflakeId(999999), &a).await.is_err());
    }

    #[tokio::test]
    async fn list_categories_paginated() {
        let pool = setup_pool().await;
        let svc = make_service(pool.clone());
        let a = auth(None);
        for i in 0..5 {
            svc.create(
                &a,
                CreateCategoryRequest {
                    name: format!("Cat{i}"),
                    description: None,
                    parent_id: None,
                    sort_order: None,
                },
            )
            .await
            .unwrap();
        }
        let (cats, total) = svc.list_paginated(&a, 1, 3).await.unwrap();
        assert_eq!(total, 5);
        assert_eq!(cats.len(), 3);
    }
}