From 3da80826ab195c19ec747eed22f9dbf61140b2ca Mon Sep 17 00:00:00 2001 From: zhongqin Date: Sat, 9 May 2026 00:30:34 +0800 Subject: [PATCH] improve testing code --- src/cache.rs | 95 +++++++++++++ src/content_type/handler.rs | 28 ++-- src/content_type/migration.rs | 5 +- src/content_type/repository.rs | 4 +- src/content_type/resolver.rs | 4 +- src/dto.rs | 75 +++++++++++ src/dto/category.rs | 38 ++++++ src/dto/comment.rs | 43 ++++++ src/dto/tag.rs | 29 ++++ src/dto/user.rs | 120 +++++++++++++++++ src/errors/app_error.rs | 43 ++++++ src/handlers.rs | 2 +- src/handlers/auth.rs | 6 +- src/handlers/category.rs | 2 +- src/handlers/comment.rs | 2 +- src/handlers/media.rs | 4 +- src/handlers/page.rs | 13 +- src/handlers/post.rs | 2 +- src/handlers/tag.rs | 2 +- src/handlers/user.rs | 4 +- src/models.rs | 2 +- src/models/category.rs | 107 +++++++++++++++ src/models/comment.rs | 176 ++++++++++++++++++++++++ src/models/email_verification.rs | 85 ++++++++++++ src/models/media.rs | 110 +++++++++++++++ src/models/oauth.rs | 210 +++++++++++++++++++++++++++++ src/models/options.rs | 113 ++++++++++++++++ src/models/page.rs | 140 +++++++++++++++++++ src/models/password_reset.rs | 103 ++++++++++++++ src/models/plugin_storage.rs | 79 +++++++++++ src/models/rbac.rs | 129 ++++++++++++++++++ src/models/refresh_token.rs | 67 +++++++++ src/models/reusable_block.rs | 115 ++++++++++++++++ src/models/sms_code.rs | 122 +++++++++++++++++ src/models/tag.rs | 86 ++++++++++++ src/models/tenant.rs | 128 ++++++++++++++++++ src/models/user.rs | 135 +++++++++++++++++++ src/models/workflow.rs | 200 +++++++++++++++++++++++++++ src/protocols/ownable.rs | 3 +- src/repositories/sqlx_media.rs | 2 +- src/services/aspect_dispatch.rs | 32 +++++ src/services/auth.rs | 12 +- src/services/category.rs | 2 +- src/services/media.rs | 225 +++++++++++++++++++++++++++++++ src/services/oauth.rs | 51 ++++++- src/services/page.rs | 56 ++++++++ src/services/post.rs | 31 ++++- src/services/rbac.rs | 81 +++++++++++ src/services/reusable_block.rs | 28 ++++ src/services/sms.rs | 2 +- src/services/tag.rs | 2 +- src/services/user.rs | 2 +- src/services/workflow.rs | 108 +++++++++++++++ src/utils/crypto.rs | 37 +++++ src/utils/id.rs | 46 +++++++ tests/api.rs | 35 ++++- tests/api/auth.rs | 88 ++++++++++++ tests/api/comment.rs | 9 +- tests/api/page.rs | 176 ++++++++++++++++++++++++ tests/api/post.rs | 139 +++++++++++++++++++ tests/api/reusable_block.rs | 165 +++++++++++++++++++++++ tests/api/user.rs | 79 +++++++++++ 62 files changed, 3972 insertions(+), 67 deletions(-) create mode 100644 tests/api/page.rs create mode 100644 tests/api/reusable_block.rs diff --git a/src/cache.rs b/src/cache.rs index 786c6adc..e6408cf7 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -127,3 +127,98 @@ where let _ = cache.set(key, &value, Some(ttl)).await; Ok(value) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn set_and_get() { + let cache = MemoryCache::new(); + cache.set("k", "v", None).await.unwrap(); + assert_eq!(cache.get("k").await.unwrap(), "v"); + } + + #[tokio::test] + async fn get_missing_returns_none() { + let cache = MemoryCache::new(); + assert!(cache.get("missing").await.is_none()); + } + + #[tokio::test] + async fn delete_removes_entry() { + let cache = MemoryCache::new(); + cache.set("k", "v", None).await.unwrap(); + cache.delete("k").await.unwrap(); + assert!(cache.get("k").await.is_none()); + } + + #[tokio::test] + async fn set_overwrites() { + let cache = MemoryCache::new(); + cache.set("k", "v1", None).await.unwrap(); + cache.set("k", "v2", None).await.unwrap(); + assert_eq!(cache.get("k").await.unwrap(), "v2"); + } + + #[tokio::test] + async fn delete_prefix_removes_matching() { + let cache = MemoryCache::new(); + cache.set("posts:1", "a", None).await.unwrap(); + cache.set("posts:2", "b", None).await.unwrap(); + cache.set("tags:1", "c", None).await.unwrap(); + let count = cache.delete_prefix("posts:").await.unwrap(); + assert_eq!(count, 2); + assert!(cache.get("posts:1").await.is_none()); + assert!(cache.get("posts:2").await.is_none()); + assert_eq!(cache.get("tags:1").await.unwrap(), "c"); + } + + #[tokio::test] + async fn expiry_with_ttl() { + let cache = MemoryCache::new(); + cache + .set("k", "v", Some(Duration::from_millis(1))) + .await + .unwrap(); + std::thread::sleep(Duration::from_millis(10)); + assert!(cache.get("k").await.is_none()); + } + + #[tokio::test] + async fn get_or_cache_miss() { + let cache: Arc = Arc::new(MemoryCache::new()); + let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count_clone = call_count.clone(); + let result = get_or(&cache, "key", Duration::from_secs(60), || { + let c = count_clone.clone(); + async move { + c.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok("computed".to_string()) + } + }) + .await + .unwrap(); + assert_eq!(result, "computed"); + assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn get_or_cache_hit() { + let cache: Arc = Arc::new(MemoryCache::new()); + cache.set("key", "cached", None).await.unwrap(); + let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count_clone = call_count.clone(); + let result = get_or(&cache, "key", Duration::from_secs(60), || { + let c = count_clone.clone(); + async move { + c.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok("computed".to_string()) + } + }) + .await + .unwrap(); + assert_eq!(result, "cached"); + assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 0); + } +} diff --git a/src/content_type/handler.rs b/src/content_type/handler.rs index d18f24b2..61c9f4b2 100644 --- a/src/content_type/handler.rs +++ b/src/content_type/handler.rs @@ -13,7 +13,9 @@ use serde_json::{Value, json}; use std::collections::HashMap; use std::sync::Arc; -use super::repository::{ContentQuery, ContentRepository, SaveContext, resolve_document_id_to_int_id}; +use super::repository::{ + ContentQuery, ContentRepository, SaveContext, resolve_document_id_to_int_id, +}; use super::rule_engine::compile_rule_sql; use super::schema::{ContentKind, ContentTypeSchema, FieldType, RelationType, check_api_access}; use crate::AppState; @@ -410,13 +412,11 @@ pub async fn do_list( .foreign_key .clone() .unwrap_or_else(|| format!("{}_id", field.name)); - let int_id = resolve_document_id_to_int_id( - &state.pool, &rel.target, v, - ) - .await - .ok() - .flatten() - .unwrap_or(-1); + let int_id = resolve_document_id_to_int_id(&state.pool, &rel.target, v) + .await + .ok() + .flatten() + .unwrap_or(-1); filters.insert(fk_col, json!(int_id)); } _ => {} @@ -876,13 +876,11 @@ async fn do_admin_list( .foreign_key .clone() .unwrap_or_else(|| format!("{}_id", field.name)); - let int_id = resolve_document_id_to_int_id( - &state.pool, &rel.target, v, - ) - .await - .ok() - .flatten() - .unwrap_or(-1); + let int_id = resolve_document_id_to_int_id(&state.pool, &rel.target, v) + .await + .ok() + .flatten() + .unwrap_or(-1); filters.insert(fk_col, json!(int_id)); } _ => {} diff --git a/src/content_type/migration.rs b/src/content_type/migration.rs index ba5378e5..f9bd0e9b 100644 --- a/src/content_type/migration.rs +++ b/src/content_type/migration.rs @@ -109,7 +109,10 @@ pub fn generate_junction_tables(ct: &ContentTypeSchema) -> Vec { let is_safe = crate::db::dialect::is_safe_identifier; for field in &ct.fields { if let Some(ref rel) = field.relation - && matches!(rel.relation_type, RelationType::ManyToMany | RelationType::ManyWay) + && matches!( + rel.relation_type, + RelationType::ManyToMany | RelationType::ManyWay + ) { let through = rel .through diff --git a/src/content_type/repository.rs b/src/content_type/repository.rs index 0ac18544..82ef8c00 100644 --- a/src/content_type/repository.rs +++ b/src/content_type/repository.rs @@ -790,9 +790,7 @@ impl ContentRepository { .bind(source_int_id) .execute(&mut *tx) .await - .map_err(|e| { - AppError::Internal(anyhow::anyhow!("junction delete failed: {e}")) - })?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("junction delete failed: {e}")))?; let doc_ids = extract_document_ids(val); if doc_ids.is_empty() { diff --git a/src/content_type/resolver.rs b/src/content_type/resolver.rs index bf59765a..f4c5e8fb 100644 --- a/src/content_type/resolver.rs +++ b/src/content_type/resolver.rs @@ -47,9 +47,7 @@ pub async fn resolve_relations( }; match rel.relation_type { - RelationType::ManyToOne - | RelationType::OneToOne - | RelationType::OneWay => { + RelationType::ManyToOne | RelationType::OneToOne | RelationType::OneWay => { resolve_many_to_one_batch(pool, ct, field, rel, items).await?; } RelationType::OneToMany => { diff --git a/src/dto.rs b/src/dto.rs index af421353..72da2cc7 100644 --- a/src/dto.rs +++ b/src/dto.rs @@ -65,3 +65,78 @@ fn validate_uuid_vec(ids: &[String]) -> Result<(), validator::ValidationError> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_password_valid() { + assert!(validate_password("abc123").is_ok()); + assert!(validate_password("Password1").is_ok()); + } + + #[test] + fn validate_password_no_digit() { + assert!(validate_password("abcdef").is_err()); + } + + #[test] + fn validate_password_no_letter() { + assert!(validate_password("123456").is_err()); + } + + #[test] + fn validate_post_status_valid() { + assert!(validate_post_status("draft").is_ok()); + assert!(validate_post_status("published").is_ok()); + } + + #[test] + fn validate_post_status_invalid() { + assert!(validate_post_status("archived").is_err()); + } + + #[test] + fn validate_comment_status_valid() { + assert!(validate_comment_status("approved").is_ok()); + assert!(validate_comment_status("pending").is_ok()); + assert!(validate_comment_status("spam").is_ok()); + } + + #[test] + fn validate_comment_status_invalid() { + assert!(validate_comment_status("deleted").is_err()); + } + + #[test] + fn validate_optional_uuid_valid_uuid() { + assert!(validate_optional_uuid("01901234-5678-7000-8000-000000000000").is_ok()); + } + + #[test] + fn validate_optional_uuid_valid_i64() { + assert!(validate_optional_uuid("42").is_ok()); + } + + #[test] + fn validate_optional_uuid_invalid() { + assert!(validate_optional_uuid("not-a-uuid").is_err()); + } + + #[test] + fn validate_uuid_vec_valid() { + assert!( + validate_uuid_vec(&[ + "01901234-5678-7000-8000-000000000000".to_string(), + "1".to_string() + ]) + .is_ok() + ); + } + + #[test] + fn validate_uuid_vec_invalid() { + assert!(validate_uuid_vec(&["bad-id".to_string()]).is_err()); + } +} diff --git a/src/dto/category.rs b/src/dto/category.rs index f2a679dc..7d349243 100644 --- a/src/dto/category.rs +++ b/src/dto/category.rs @@ -25,3 +25,41 @@ pub struct UpdateCategoryRequest { pub parent_id: Option, pub sort_order: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_category_valid() { + let req = CreateCategoryRequest { + name: "Tech".to_string(), + description: None, + parent_id: None, + sort_order: None, + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_category_empty_name_fails() { + let req = CreateCategoryRequest { + name: "".to_string(), + description: None, + parent_id: None, + sort_order: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn update_category_valid() { + let req = UpdateCategoryRequest { + name: Some("New".to_string()), + description: None, + parent_id: None, + sort_order: None, + }; + assert!(req.validate().is_ok()); + } +} diff --git a/src/dto/comment.rs b/src/dto/comment.rs index fdc57af6..bf609015 100644 --- a/src/dto/comment.rs +++ b/src/dto/comment.rs @@ -23,3 +23,46 @@ pub struct UpdateCommentStatusRequest { #[validate(custom(function = "validate_comment_status"))] pub status: String, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_comment_valid() { + let req = CreateCommentRequest { + content: "Nice post!".to_string(), + parent_id: None, + nickname: Some("Guest".to_string()), + email: None, + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_comment_empty_content_fails() { + let req = CreateCommentRequest { + content: "".to_string(), + parent_id: None, + nickname: None, + email: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn update_status_valid() { + let req = UpdateCommentStatusRequest { + status: "approved".to_string(), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn update_status_invalid() { + let req = UpdateCommentStatusRequest { + status: "deleted".to_string(), + }; + assert!(req.validate().is_err()); + } +} diff --git a/src/dto/tag.rs b/src/dto/tag.rs index f63f77e1..6715f55e 100644 --- a/src/dto/tag.rs +++ b/src/dto/tag.rs @@ -15,3 +15,32 @@ pub struct UpdateTagRequest { #[validate(length(min = 1, max = 50))] pub name: String, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_tag_valid() { + let req = CreateTagRequest { + name: "rust".to_string(), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_tag_empty_name_fails() { + let req = CreateTagRequest { + name: "".to_string(), + }; + assert!(req.validate().is_err()); + } + + #[test] + fn update_tag_valid() { + let req = UpdateTagRequest { + name: "updated".to_string(), + }; + assert!(req.validate().is_ok()); + } +} diff --git a/src/dto/user.rs b/src/dto/user.rs index 5669a76e..7961b39b 100644 --- a/src/dto/user.rs +++ b/src/dto/user.rs @@ -181,3 +181,123 @@ pub struct LoginResponse { pub expires_in: u64, pub user: UserResponse, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn register_request_valid() { + let req = RegisterRequest { + email: "test@example.com".to_string(), + username: "testuser".to_string(), + password: "Password1".to_string(), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn register_request_bad_email() { + let req = RegisterRequest { + email: "not-an-email".to_string(), + username: "testuser".to_string(), + password: "Password1".to_string(), + }; + assert!(req.validate().is_err()); + } + + #[test] + fn register_request_short_username() { + let req = RegisterRequest { + email: "test@example.com".to_string(), + username: "a".to_string(), + password: "Password1".to_string(), + }; + assert!(req.validate().is_err()); + } + + #[test] + fn register_request_short_password() { + let req = RegisterRequest { + email: "test@example.com".to_string(), + username: "testuser".to_string(), + password: "short".to_string(), + }; + assert!(req.validate().is_err()); + } + + #[test] + fn register_request_password_no_digit() { + let req = RegisterRequest { + email: "test@example.com".to_string(), + username: "testuser".to_string(), + password: "passwordonly".to_string(), + }; + assert!(req.validate().is_err()); + } + + #[test] + fn login_request_valid() { + let req = LoginRequest { + email: "test@example.com".to_string(), + password: "anypassword".to_string(), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn login_request_empty_password() { + let req = LoginRequest { + email: "test@example.com".to_string(), + password: "".to_string(), + }; + assert!(req.validate().is_err()); + } + + #[test] + fn update_user_request_valid() { + let req = UpdateUserRequest { + username: Some("newname".to_string()), + bio: None, + website: None, + avatar: None, + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn update_password_request_valid() { + let req = UpdatePasswordRequest { + old_password: "OldPass1".to_string(), + new_password: "NewPass2".to_string(), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn update_password_request_weak_new_password() { + let req = UpdatePasswordRequest { + old_password: "OldPass1".to_string(), + new_password: "abcdefgh".to_string(), + }; + assert!(req.validate().is_err()); + } + + #[test] + fn user_response_from_user_serializes() { + let resp = UserResponse { + id: "doc-123".to_string(), + email: "a@b.com".to_string(), + username: "test".to_string(), + role: "reader".to_string(), + phone: None, + avatar: None, + bio: None, + website: None, + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(json.contains("\"id\":\"doc-123\"")); + } +} diff --git a/src/errors/app_error.rs b/src/errors/app_error.rs index 95ca9334..5c2d59ee 100644 --- a/src/errors/app_error.rs +++ b/src/errors/app_error.rs @@ -267,3 +267,46 @@ impl From for AppError { /// 所有 handler 和 service 函数的返回类型统一使用 `AppResult`, /// 等价于 `Result`,简化函数签名并保证错误处理的一致性。 pub type AppResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn not_found_convenience() { + let err = AppError::not_found("post"); + match err { + AppError::NotFound(r) => assert_eq!(r, "post"), + _ => panic!("expected NotFound"), + } + } + + #[test] + fn expect_affected_zero_rows() { + let result = sqlx::sqlite::SqliteQueryResult::default(); + let outcome = AppError::expect_affected(&result, "user"); + assert!(outcome.is_err()); + match outcome.unwrap_err() { + AppError::NotFound(r) => assert_eq!(r, "user"), + _ => panic!("expected NotFound"), + } + } + + #[test] + fn from_sqlx_row_not_found() { + let err: AppError = sqlx::Error::RowNotFound.into(); + match err { + AppError::NotFound(_) => {} + _ => panic!("expected NotFound"), + } + } + + #[test] + fn from_anyhow() { + let err = AppError::Internal(anyhow::anyhow!("boom")); + match err { + AppError::Internal(e) => assert_eq!(e.to_string(), "boom"), + _ => panic!("expected Internal"), + } + } +} diff --git a/src/handlers.rs b/src/handlers.rs index b8e98d82..7135fe13 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -28,9 +28,9 @@ pub mod oauth; pub mod options; pub mod page; pub mod plugin; -pub mod reusable_block; pub mod post; pub mod rbac; +pub mod reusable_block; pub mod rss; pub mod sse; pub mod stats; diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index cf1f4496..8a50e0b6 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -6,14 +6,14 @@ use axum::Json; use axum::extract::State; -use crate::errors::app_error::AppResult; -use crate::errors::response::ApiResponse; -use crate::errors::validation; use crate::dto::{ AuthConfigResponse, BindPhoneRequest, ForgotPasswordRequest, LoginRequest, RefreshRequest, RegisterRequest, ResendVerificationRequest, ResetPasswordRequest, SendSmsCodeRequest, SetPasswordRequest, VerifyEmailRequest, VerifySmsRequest, }; +use crate::errors::app_error::AppResult; +use crate::errors::response::ApiResponse; +use crate::errors::validation; use crate::middleware::auth::AuthUser; use crate::services::{auth, email_verification, password_reset, sms}; diff --git a/src/handlers/category.rs b/src/handlers/category.rs index 3108ee0d..f320a3d9 100644 --- a/src/handlers/category.rs +++ b/src/handlers/category.rs @@ -3,10 +3,10 @@ use axum::Json; use axum::extract::{Path, Query, State}; +use crate::dto::{CreateCategoryRequest, UpdateCategoryRequest}; use crate::errors::app_error::AppResult; use crate::errors::response::ApiResponse; use crate::errors::validation; -use crate::dto::{CreateCategoryRequest, UpdateCategoryRequest}; use crate::middleware::auth::AuthUser; use crate::services::category; use crate::utils::pagination::PaginationParams; diff --git a/src/handlers/comment.rs b/src/handlers/comment.rs index 4baa5307..fbc14549 100644 --- a/src/handlers/comment.rs +++ b/src/handlers/comment.rs @@ -6,10 +6,10 @@ use axum::Json; use axum::extract::{Path, State}; +use crate::dto::{CreateCommentRequest, UpdateCommentStatusRequest}; use crate::errors::app_error::{AppError, AppResult}; use crate::errors::response::{ApiResponse, PaginatedData}; use crate::errors::validation; -use crate::dto::{CreateCommentRequest, UpdateCommentStatusRequest}; use crate::middleware::auth::AuthUser; use crate::services::comment as comment_service; use crate::utils::pagination::PaginationParams; diff --git a/src/handlers/media.rs b/src/handlers/media.rs index c37ea27a..5274adfa 100644 --- a/src/handlers/media.rs +++ b/src/handlers/media.rs @@ -101,7 +101,5 @@ pub async fn stats( State(state): State, ) -> AppResult> { let s = media_service::stats(state.media_repo.as_ref(), &state.pool, &auth).await?; - Ok(ApiResponse::success( - crate::dto::stats_to_response(&s), - )) + Ok(ApiResponse::success(crate::dto::stats_to_response(&s))) } diff --git a/src/handlers/page.rs b/src/handlers/page.rs index 094ad265..b926b15d 100644 --- a/src/handlers/page.rs +++ b/src/handlers/page.rs @@ -21,7 +21,9 @@ async fn resolve_page_parent_id( pool: &crate::db::Pool, parent_id: Option, ) -> AppResult> { - let Some(doc_id) = parent_id else { return Ok(None) }; + let Some(doc_id) = parent_id else { + return Ok(None); + }; resolve_doc_id_to_int(pool, "pages", &doc_id, None).await } @@ -180,8 +182,7 @@ pub async fn create( let template = req.template.unwrap_or_else(|| "default".to_string()); let status = req.status.unwrap_or_else(|| "draft".to_string()); - let resolved_parent_id = - resolve_page_parent_id(&state.pool, req.parent_id).await?; + let resolved_parent_id = resolve_page_parent_id(&state.pool, req.parent_id).await?; let cmd = CreatePageCmd { title: req.title, slug, @@ -212,11 +213,7 @@ pub async fn update( auth.ensure_author()?; validation::validate(&req)?; - let resolved_parent_id = resolve_page_parent_id( - &state.pool, - req.parent_id.flatten(), - ) - .await?; + let resolved_parent_id = resolve_page_parent_id(&state.pool, req.parent_id.flatten()).await?; let cmd = UpdatePageCmd { id: 0, title: req.title, diff --git a/src/handlers/post.rs b/src/handlers/post.rs index e9500134..15e66191 100644 --- a/src/handlers/post.rs +++ b/src/handlers/post.rs @@ -7,10 +7,10 @@ use axum::Json; use axum::extract::{Path, Query, State}; use serde::Deserialize; +use crate::dto::{CreatePostRequest, PostResponse, UpdatePostRequest}; use crate::errors::app_error::AppResult; use crate::errors::response::{ApiResponse, PaginatedData}; use crate::errors::validation; -use crate::dto::{CreatePostRequest, PostResponse, UpdatePostRequest}; use crate::middleware::auth::AuthUser; use crate::services::post as post_service; use crate::utils::pagination::PaginationParams; diff --git a/src/handlers/tag.rs b/src/handlers/tag.rs index 52f49b16..ac635d78 100644 --- a/src/handlers/tag.rs +++ b/src/handlers/tag.rs @@ -3,10 +3,10 @@ use axum::Json; use axum::extract::{Path, Query, State}; +use crate::dto::{CreateTagRequest, UpdateTagRequest}; use crate::errors::app_error::AppResult; use crate::errors::response::ApiResponse; use crate::errors::validation; -use crate::dto::{CreateTagRequest, UpdateTagRequest}; use crate::middleware::auth::AuthUser; use crate::services::tag; use crate::utils::pagination::PaginationParams; diff --git a/src/handlers/user.rs b/src/handlers/user.rs index a9fce2a9..403152e0 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -5,12 +5,10 @@ use axum::Json; use axum::extract::{Path, Query, State}; +use crate::dto::{UpdatePasswordRequest, UpdateRoleRequest, UpdateUserRequest, UserResponse}; use crate::errors::app_error::AppResult; use crate::errors::response::{ApiResponse, PaginatedData}; use crate::errors::validation; -use crate::dto::{ - UpdatePasswordRequest, UpdateRoleRequest, UpdateUserRequest, UserResponse, -}; use crate::middleware::auth::AuthUser; use crate::services::{auth, user}; use crate::utils::pagination::PaginationParams; diff --git a/src/models.rs b/src/models.rs index ec7fe1a4..74315aa3 100644 --- a/src/models.rs +++ b/src/models.rs @@ -27,11 +27,11 @@ pub mod oauth; pub mod options; pub mod page; pub mod password_reset; -pub mod reusable_block; pub mod plugin_storage; pub mod post; pub mod rbac; pub mod refresh_token; +pub mod reusable_block; pub mod sms_code; pub mod tag; pub mod tenant; diff --git a/src/models/category.rs b/src/models/category.rs index 482ef4e7..b38721fe 100644 --- a/src/models/category.rs +++ b/src/models/category.rs @@ -248,3 +248,110 @@ pub async fn delete(pool: &crate::db::Pool, id: i64, tenant_id: Option<&str>) -> AppError::expect_affected(&result, "category") } + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::category::CreateCategoryCmd; + use crate::commands::category::UpdateCategoryCmd; + + async fn setup_pool() -> crate::db::Pool { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + pool + } + + fn make_cmd(name: &str) -> CreateCategoryCmd { + CreateCategoryCmd { + name: name.to_string(), + slug: name.to_lowercase(), + description: None, + parent_id: None, + sort_order: 0, + } + } + + #[tokio::test] + async fn create_and_find_by_id() { + let pool = setup_pool().await; + let cat = create(&pool, &make_cmd("Tech"), None, None).await.unwrap(); + let found = find_by_id(&pool, cat.id, None).await.unwrap(); + assert_eq!(found.id, cat.id); + assert_eq!(found.name, "Tech"); + } + + #[tokio::test] + async fn find_by_document_id() { + let pool = setup_pool().await; + let cat = create(&pool, &make_cmd("Science"), None, None) + .await + .unwrap(); + let found = super::find_by_document_id(&pool, &cat.document_id, None) + .await + .unwrap() + .unwrap(); + assert_eq!(found.id, cat.id); + } + + #[tokio::test] + async fn find_all_returns_all() { + let pool = setup_pool().await; + create(&pool, &make_cmd("A"), None, None).await.unwrap(); + create(&pool, &make_cmd("B"), None, None).await.unwrap(); + create(&pool, &make_cmd("C"), None, None).await.unwrap(); + let all = find_all(&pool, None).await.unwrap(); + assert_eq!(all.len(), 3); + } + + #[tokio::test] + async fn find_paginated() { + let pool = setup_pool().await; + for name in ["A", "B", "C", "D", "E"] { + create(&pool, &make_cmd(name), None, None).await.unwrap(); + } + let (items, total) = super::find_paginated(&pool, None, 1, 3).await.unwrap(); + assert_eq!(total, 5); + assert_eq!(items.len(), 3); + } + + #[tokio::test] + async fn update_changes_name() { + let pool = setup_pool().await; + let cat = create(&pool, &make_cmd("Old"), None, None).await.unwrap(); + let updated = update( + &pool, + &UpdateCategoryCmd { + id: cat.id, + name: Some("New".to_string()), + slug: None, + description: None, + parent_id: None, + sort_order: None, + }, + None, + None, + ) + .await + .unwrap(); + assert_eq!(updated.name, "New"); + } + + #[tokio::test] + async fn delete_removes_category() { + let pool = setup_pool().await; + let cat = create(&pool, &make_cmd("Gone"), None, None).await.unwrap(); + delete(&pool, cat.id, None).await.unwrap(); + let result = find_by_id(&pool, cat.id, None).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn find_by_id_not_found() { + let pool = setup_pool().await; + let result = find_by_id(&pool, 99999, None).await; + assert!(result.is_err()); + } +} diff --git a/src/models/comment.rs b/src/models/comment.rs index 49ae205e..9c6dfced 100644 --- a/src/models/comment.rs +++ b/src/models/comment.rs @@ -458,4 +458,180 @@ mod tests { let comments = vec![make_comment(1, 10, None)]; assert!(validate_depth(&comments, 999).is_err()); } + + mod integration { + use super::*; + use crate::commands::CreateCommentCmd; + + async fn setup_pool() -> crate::db::Pool { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + pool + } + + async fn insert_user(pool: &crate::db::Pool) -> i64 { + let doc_id = crate::utils::id::new_document_id(); + sqlx::query( + "INSERT INTO users (document_id, username, email, password_hash, role) VALUES (?, 'testuser', 'test@test.com', 'hash', 'author')", + ) + .bind(&doc_id) + .execute(pool) + .await + .unwrap(); + + let (id,): (i64,) = sqlx::query_as("SELECT id FROM users WHERE document_id = ?") + .bind(&doc_id) + .fetch_one(pool) + .await + .unwrap(); + id + } + + async fn insert_post(pool: &crate::db::Pool, user_id: i64) -> i64 { + let doc_id = crate::utils::id::new_document_id(); + let slug = format!("slug-{doc_id}"); + sqlx::query( + "INSERT INTO posts (document_id, title, slug, content, status, created_by, updated_by) VALUES (?, 'Test', ?, 'content', 'published', ?, ?)", + ) + .bind(&doc_id) + .bind(&slug) + .bind(user_id) + .bind(user_id) + .execute(pool) + .await + .unwrap(); + + let (id,): (i64,) = sqlx::query_as("SELECT id FROM posts WHERE document_id = ?") + .bind(&doc_id) + .fetch_one(pool) + .await + .unwrap(); + id + } + + fn make_cmd(post_id: i64) -> CreateCommentCmd { + CreateCommentCmd { + post_id, + created_by: None, + nickname: Some("Alice".into()), + email: Some("alice@test.com".into()), + content: "hello".into(), + parent_id: None, + } + } + + #[tokio::test] + async fn create_and_find_by_id() { + let pool = setup_pool().await; + let uid = insert_user(&pool).await; + let pid = insert_post(&pool, uid).await; + let c = create(&pool, &make_cmd(pid), None).await.unwrap(); + assert_eq!(c.post_id, pid); + assert_eq!(c.content, "hello"); + let found = super::find_by_id(&pool, c.id, None).await.unwrap().unwrap(); + assert_eq!(found.id, c.id); + assert_eq!(found.document_id, c.document_id); + } + + #[tokio::test] + async fn find_by_document_id_test() { + let pool = setup_pool().await; + let uid = insert_user(&pool).await; + let pid = insert_post(&pool, uid).await; + let c = create(&pool, &make_cmd(pid), None).await.unwrap(); + let found = super::find_by_document_id(&pool, &c.document_id, None) + .await + .unwrap() + .unwrap(); + assert_eq!(found.id, c.id); + assert_eq!(found.content, "hello"); + } + + #[tokio::test] + async fn find_approved_by_post_returns_only_approved() { + let pool = setup_pool().await; + let uid = insert_user(&pool).await; + let pid = insert_post(&pool, uid).await; + let c1 = create(&pool, &make_cmd(pid), None).await.unwrap(); + let _c2 = create(&pool, &make_cmd(pid), None).await.unwrap(); + update_status(&pool, c1.id, "approved", None).await.unwrap(); + + let approved = super::find_approved_by_post(&pool, pid, None) + .await + .unwrap(); + assert_eq!(approved.len(), 1); + assert_eq!(approved[0].id, c1.id); + } + + #[tokio::test] + async fn find_approved_by_post_paginated_test() { + let pool = setup_pool().await; + let uid = insert_user(&pool).await; + let pid = insert_post(&pool, uid).await; + let mut ids = Vec::new(); + for i in 0..5 { + let mut cmd = make_cmd(pid); + cmd.content = format!("comment {i}"); + let c = create(&pool, &cmd, None).await.unwrap(); + update_status(&pool, c.id, "approved", None).await.unwrap(); + ids.push(c.id); + } + + let (page1, total) = super::find_approved_by_post_paginated(&pool, pid, 1, 2, None) + .await + .unwrap(); + assert_eq!(total, 5); + assert_eq!(page1.len(), 2); + + let (page3, _) = super::find_approved_by_post_paginated(&pool, pid, 3, 2, None) + .await + .unwrap(); + assert_eq!(page3.len(), 1); + } + + #[tokio::test] + async fn update_status_changes_status() { + let pool = setup_pool().await; + let uid = insert_user(&pool).await; + let pid = insert_post(&pool, uid).await; + let c = create(&pool, &make_cmd(pid), None).await.unwrap(); + assert_eq!(c.status, "pending"); + update_status(&pool, c.id, "approved", None).await.unwrap(); + let found = super::find_by_id(&pool, c.id, None).await.unwrap().unwrap(); + assert_eq!(found.status, "approved"); + } + + #[tokio::test] + async fn delete_removes_comment() { + let pool = setup_pool().await; + let uid = insert_user(&pool).await; + let pid = insert_post(&pool, uid).await; + let c = create(&pool, &make_cmd(pid), None).await.unwrap(); + super::delete(&pool, c.id, None).await.unwrap(); + let found = super::find_by_id(&pool, c.id, None).await.unwrap(); + assert!(found.is_none()); + } + + #[tokio::test] + async fn find_all_paginated_test() { + let pool = setup_pool().await; + let uid = insert_user(&pool).await; + let pid = insert_post(&pool, uid).await; + for i in 0..5 { + let mut cmd = make_cmd(pid); + cmd.content = format!("comment {i}"); + create(&pool, &cmd, None).await.unwrap(); + } + + let (page1, total) = super::find_all_paginated(&pool, 1, 2, None).await.unwrap(); + assert_eq!(total, 5); + assert_eq!(page1.len(), 2); + + let (page3, _) = super::find_all_paginated(&pool, 3, 2, None).await.unwrap(); + assert_eq!(page3.len(), 1); + } + } } diff --git a/src/models/email_verification.rs b/src/models/email_verification.rs index 8acf3fce..6524b9ea 100644 --- a/src/models/email_verification.rs +++ b/src/models/email_verification.rs @@ -114,3 +114,88 @@ pub async fn cleanup_expired(pool: &crate::db::Pool) -> AppResult { let result = sqlx::query(&sql).bind(now).execute(pool).await?; Ok(result.rows_affected()) } + +#[cfg(test)] +mod tests { + use super::*; + + async fn setup_pool() -> crate::db::Pool { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + pool + } + + async fn insert_user(pool: &crate::db::Pool, document_id: &str) -> i64 { + let hash = "$argon2id$v=19$m=19456,t=2,p=1$test$test".to_string(); + let sql = format!( + "INSERT INTO users (document_id, email, username, password_hash, role) VALUES ({}, {}, {}, {}, 'admin') RETURNING id", + ph(1), + ph(2), + ph(3), + ph(4) + ); + let (id,): (i64,) = sqlx::query_as(&sql) + .bind(document_id) + .bind("ev-test@test.com") + .bind("evtestuser") + .bind(&hash) + .fetch_one(pool) + .await + .unwrap(); + id + } + + #[tokio::test] + async fn create_and_find_by_token() { + let pool = setup_pool().await; + let user_id = insert_user(&pool, "ev-user-1-doc").await; + let row = create(&pool, user_id, "ev1@test.com", 3600).await.unwrap(); + let found = find_by_token(&pool, &row.token).await.unwrap().unwrap(); + assert_eq!(found.id, row.id); + assert_eq!(found.token, row.token); + assert_eq!(found.email, "ev1@test.com"); + assert!(found.verified_at.is_none()); + } + + #[tokio::test] + async fn mark_verified() { + let pool = setup_pool().await; + let user_id = insert_user(&pool, "ev-user-2-doc").await; + let row = create(&pool, user_id, "ev2@test.com", 3600).await.unwrap(); + assert!(row.verified_at.is_none()); + super::mark_verified(&pool, row.id).await.unwrap(); + let found = find_by_token(&pool, &row.token).await.unwrap(); + assert!(found.is_none()); + } + + #[tokio::test] + async fn delete_unused_by_user() { + let pool = setup_pool().await; + let user_id = insert_user(&pool, "ev-user-3-doc").await; + create(&pool, user_id, "ev3a@test.com", 3600).await.unwrap(); + create(&pool, user_id, "ev3b@test.com", 3600).await.unwrap(); + super::delete_unused_by_user(&pool, user_id).await.unwrap(); + let sql = format!( + "SELECT COUNT(*) FROM email_verification_tokens WHERE user_id = {}", + ph(1), + ); + let (count,): (i64,) = sqlx::query_as(&sql) + .bind(user_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0); + } + + #[tokio::test] + async fn cleanup_expired() { + let pool = setup_pool().await; + let user_id = insert_user(&pool, "ev-user-4-doc").await; + create(&pool, user_id, "ev4@test.com", -1).await.unwrap(); + let removed = super::cleanup_expired(&pool).await.unwrap(); + assert_eq!(removed, 1); + } +} diff --git a/src/models/media.rs b/src/models/media.rs index 282a6040..1cd4d7a3 100644 --- a/src/models/media.rs +++ b/src/models/media.rs @@ -235,3 +235,113 @@ pub async fn delete(pool: &crate::db::Pool, id: i64, tenant_id: Option<&str>) -> AppError::expect_affected(&result, "media") } + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::media::CreateMediaCmd; + + async fn setup_pool() -> crate::db::Pool { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + pool + } + + async fn insert_user(pool: &crate::db::Pool) -> i64 { + let doc_id = crate::utils::id::new_document_id(); + let sql = format!( + "INSERT INTO users (document_id, email, username, password_hash, role) VALUES ({}, {}, {}, {}, 'admin') RETURNING id", + crate::db::dialect::ph(1), + crate::db::dialect::ph(2), + crate::db::dialect::ph(3), + crate::db::dialect::ph(4) + ); + let (id,): (i64,) = sqlx::query_as(&sql) + .bind(&doc_id) + .bind("media-test@test.com") + .bind("mediauser") + .bind("$argon2id$v=19$m=19456,t=2,p=1$test$test") + .fetch_one(pool) + .await + .unwrap(); + id + } + + fn make_cmd(user_id: i64, filename: &str) -> CreateMediaCmd { + CreateMediaCmd { + user_id, + filename: filename.to_string(), + filepath: format!("/uploads/{filename}"), + mimetype: "image/png".to_string(), + size: 1024, + width: Some(100), + height: Some(100), + } + } + + #[tokio::test] + async fn create_and_find_by_id() { + let pool = setup_pool().await; + let uid = insert_user(&pool).await; + let media = create(&pool, &make_cmd(uid, "photo.png"), None) + .await + .unwrap(); + let found = find_by_id(&pool, media.id, None).await.unwrap().unwrap(); + assert_eq!(found.id, media.id); + assert_eq!(found.filename, "photo.png"); + assert_eq!(found.user_id, uid); + } + + #[tokio::test] + async fn find_all_paginated() { + let pool = setup_pool().await; + let uid = insert_user(&pool).await; + for i in 0..5 { + create(&pool, &make_cmd(uid, &format!("file{i}.png")), None) + .await + .unwrap(); + } + let (items, total) = find_all(&pool, uid, 1, 3, None).await.unwrap(); + assert_eq!(total, 5); + assert_eq!(items.len(), 3); + } + + #[tokio::test] + async fn stats_returns_counts() { + let pool = setup_pool().await; + let uid = insert_user(&pool).await; + for i in 0..3 { + create(&pool, &make_cmd(uid, &format!("img{i}.png")), None) + .await + .unwrap(); + } + let s = stats(&pool, uid, None).await.unwrap(); + assert_eq!(s.total_files, 3); + assert_eq!(s.total_size, 3 * 1024); + assert_eq!(s.by_type.len(), 1); + assert_eq!(s.by_type[0].mimetype, "image/png"); + assert_eq!(s.by_type[0].count, 3); + } + + #[tokio::test] + async fn delete_removes_media() { + let pool = setup_pool().await; + let uid = insert_user(&pool).await; + let media = create(&pool, &make_cmd(uid, "gone.png"), None) + .await + .unwrap(); + delete(&pool, media.id, None).await.unwrap(); + let found = find_by_id(&pool, media.id, None).await.unwrap(); + assert!(found.is_none()); + } + + #[tokio::test] + async fn find_by_id_not_found() { + let pool = setup_pool().await; + let found = find_by_id(&pool, 99999, None).await.unwrap(); + assert!(found.is_none()); + } +} diff --git a/src/models/oauth.rs b/src/models/oauth.rs index 9475e9b8..7923fc54 100644 --- a/src/models/oauth.rs +++ b/src/models/oauth.rs @@ -278,3 +278,213 @@ pub async fn count_by_user(pool: &crate::db::Pool, user_id: i64) -> AppResult crate::db::Pool { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + pool + } + + async fn insert_user(pool: &crate::db::Pool) -> i64 { + let cmd = crate::commands::user::CreateUserCmd { + email: format!("{}@test.com", crate::utils::id::new_document_id()), + username: crate::utils::id::new_document_id(), + password_hash: "$argon2id$v=19$m=19456,t=2,p=1$test$test".to_string(), + }; + let user = crate::models::user::create(pool, &cmd, None).await.unwrap(); + user.id + } + + #[tokio::test] + async fn create_and_consume_state() { + let pool = setup_pool().await; + let user_id = insert_user(&pool).await; + let doc_id = create_state( + &pool, + "github", + "verifier123", + Some(user_id), + "2099-12-31T00:00:00Z", + ) + .await + .unwrap(); + let state = consume_state(&pool, &doc_id).await.unwrap().unwrap(); + assert_eq!(state.document_id, doc_id); + assert_eq!(state.provider, "github"); + assert_eq!(state.code_verifier, "verifier123"); + assert_eq!(state.user_id, Some(user_id)); + } + + #[tokio::test] + async fn consume_state_twice_returns_none() { + let pool = setup_pool().await; + let user_id = insert_user(&pool).await; + let doc_id = create_state( + &pool, + "github", + "verifier123", + Some(user_id), + "2099-12-31T00:00:00Z", + ) + .await + .unwrap(); + let first = consume_state(&pool, &doc_id).await.unwrap(); + assert!(first.is_some()); + let second = consume_state(&pool, &doc_id).await.unwrap(); + assert!(second.is_none()); + } + + #[tokio::test] + async fn create_and_find_account() { + let pool = setup_pool().await; + let user_id = insert_user(&pool).await; + let account = create_account( + &pool, + CreateOAuthAccountParams { + user_id, + provider: "github", + provider_user_id: "github-123", + email: Some("user@example.com"), + display_name: Some("Test User"), + avatar_url: None, + access_token: None, + refresh_token: None, + token_expires_at: None, + profile: None, + }, + ) + .await + .unwrap(); + let found = find_by_provider_user(&pool, "github", "github-123") + .await + .unwrap() + .unwrap(); + assert_eq!(found.id, account.id); + assert_eq!(found.provider, "github"); + assert_eq!(found.provider_user_id, "github-123"); + } + + #[tokio::test] + async fn find_by_user_id() { + let pool = setup_pool().await; + let user_id = insert_user(&pool).await; + create_account( + &pool, + CreateOAuthAccountParams { + user_id, + provider: "github", + provider_user_id: "github-123", + email: None, + display_name: None, + avatar_url: None, + access_token: None, + refresh_token: None, + token_expires_at: None, + profile: None, + }, + ) + .await + .unwrap(); + create_account( + &pool, + CreateOAuthAccountParams { + user_id, + provider: "google", + provider_user_id: "google-456", + email: None, + display_name: None, + avatar_url: None, + access_token: None, + refresh_token: None, + token_expires_at: None, + profile: None, + }, + ) + .await + .unwrap(); + let accounts = super::find_by_user_id(&pool, user_id).await.unwrap(); + assert_eq!(accounts.len(), 2); + } + + #[tokio::test] + async fn delete_account() { + let pool = setup_pool().await; + let user_id = insert_user(&pool).await; + create_account( + &pool, + CreateOAuthAccountParams { + user_id, + provider: "github", + provider_user_id: "github-123", + email: None, + display_name: None, + avatar_url: None, + access_token: None, + refresh_token: None, + token_expires_at: None, + profile: None, + }, + ) + .await + .unwrap(); + let deleted = super::delete_account(&pool, user_id, "github") + .await + .unwrap(); + assert!(deleted); + assert!( + find_by_provider_user(&pool, "github", "github-123") + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn count_by_user() { + let pool = setup_pool().await; + let user_id = insert_user(&pool).await; + create_account( + &pool, + CreateOAuthAccountParams { + user_id, + provider: "github", + provider_user_id: "github-123", + email: None, + display_name: None, + avatar_url: None, + access_token: None, + refresh_token: None, + token_expires_at: None, + profile: None, + }, + ) + .await + .unwrap(); + create_account( + &pool, + CreateOAuthAccountParams { + user_id, + provider: "google", + provider_user_id: "google-456", + email: None, + display_name: None, + avatar_url: None, + access_token: None, + refresh_token: None, + token_expires_at: None, + profile: None, + }, + ) + .await + .unwrap(); + let count = super::count_by_user(&pool, user_id).await.unwrap(); + assert_eq!(count, 2); + } +} diff --git a/src/models/options.rs b/src/models/options.rs index 093bd4fc..a600d885 100644 --- a/src/models/options.rs +++ b/src/models/options.rs @@ -221,3 +221,116 @@ pub async fn delete_by_key( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + async fn setup_pool() -> crate::db::Pool { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + pool + } + + async fn insert_test_option( + pool: &crate::db::Pool, + key: &str, + value: &str, + autoload: bool, + updated_at: &str, + ) { + sqlx::query( + "INSERT INTO options (document_id, option_key, value, type, group_name, label, autoload, sort_order, updated_at) \ + VALUES (?, ?, ?, 'text', 'test', 'test', ?, 0, ?)", + ) + .bind(crate::utils::id::new_document_id()) + .bind(key) + .bind(value) + .bind(autoload) + .bind(updated_at) + .execute(pool) + .await + .unwrap(); + } + + #[tokio::test] + async fn upsert_and_find_by_key() { + let pool = setup_pool().await; + let key = format!("test.{}", crate::utils::id::new_document_id()); + let now = chrono::Utc::now().to_rfc3339(); + + insert_test_option(&pool, &key, "initial", true, &now).await; + upsert_value(&pool, &key, "updated", None, &now) + .await + .unwrap(); + + let found = find_by_key(&pool, &key, None).await.unwrap(); + assert!(found.is_some()); + let row = found.unwrap(); + assert_eq!(row.option_key, key); + assert_eq!(row.value, "updated"); + } + + #[tokio::test] + async fn find_all_returns_all() { + let pool = setup_pool().await; + let now = chrono::Utc::now().to_rfc3339(); + + let k1 = format!("test.{}", crate::utils::id::new_document_id()); + let k2 = format!("test.{}", crate::utils::id::new_document_id()); + let k3 = format!("test.{}", crate::utils::id::new_document_id()); + insert_test_option(&pool, &k1, "v1", true, &now).await; + insert_test_option(&pool, &k2, "v2", true, &now).await; + insert_test_option(&pool, &k3, "v3", true, &now).await; + + let all = find_all(&pool, None).await.unwrap(); + assert!(all.len() >= 3); + } + + #[tokio::test] + async fn upsert_overwrites() { + let pool = setup_pool().await; + let key = format!("test.{}", crate::utils::id::new_document_id()); + let now = chrono::Utc::now().to_rfc3339(); + + insert_test_option(&pool, &key, "v1", true, &now).await; + upsert_value(&pool, &key, "v2", None, &now).await.unwrap(); + + let found = find_by_key(&pool, &key, None).await.unwrap().unwrap(); + assert_eq!(found.value, "v2"); + } + + #[tokio::test] + async fn delete_by_key_removes() { + let pool = setup_pool().await; + let key = format!("test.{}", crate::utils::id::new_document_id()); + let now = chrono::Utc::now().to_rfc3339(); + + insert_test_option(&pool, &key, "val", true, &now).await; + delete_by_key(&pool, &key, None).await.unwrap(); + + let found = find_by_key(&pool, &key, None).await.unwrap(); + assert!(found.is_none()); + } + + #[tokio::test] + async fn find_autoload_test() { + let pool = setup_pool().await; + let now = chrono::Utc::now().to_rfc3339(); + + let k1 = format!("test.{}", crate::utils::id::new_document_id()); + let k2 = format!("test.{}", crate::utils::id::new_document_id()); + let k3 = format!("test.{}", crate::utils::id::new_document_id()); + insert_test_option(&pool, &k1, "v1", true, &now).await; + insert_test_option(&pool, &k2, "v2", true, &now).await; + insert_test_option(&pool, &k3, "v3", false, &now).await; + + let autoloaded = find_autoload(&pool).await.unwrap(); + assert!(autoloaded.iter().any(|r| r.option_key == k1)); + assert!(autoloaded.iter().any(|r| r.option_key == k2)); + assert!(!autoloaded.iter().any(|r| r.option_key == k3)); + } +} diff --git a/src/models/page.rs b/src/models/page.rs index 95f3f7bf..d4e20a84 100644 --- a/src/models/page.rs +++ b/src/models/page.rs @@ -753,3 +753,143 @@ pub async fn list_sitemap( } Ok(q.fetch_all(pool).await?) } + +#[cfg(test)] +mod tests { + use super::*; + + async fn setup_pool() -> crate::db::Pool { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + pool + } + + async fn create_user(pool: &crate::db::Pool) -> i64 { + let uid = uuid::Uuid::now_v7().to_string(); + sqlx::query( + "INSERT INTO users (document_id, username, email, password_hash, role) VALUES (?, 'testuser', 'test@test.com', 'hash', 'author')", + ) + .bind(&uid) + .execute(pool) + .await + .unwrap(); + + let (id,): (i64,) = sqlx::query_as("SELECT id FROM users WHERE document_id = ?") + .bind(&uid) + .fetch_one(pool) + .await + .unwrap(); + id + } + + async fn create_test_page( + pool: &crate::db::Pool, + title: &str, + slug: &str, + status: &str, + created_by: i64, + ) -> Page { + create( + pool, title, slug, None, None, None, None, None, "default", None, 0, status, + created_by, None, None, + ) + .await + .unwrap() + } + + #[tokio::test] + async fn create_and_find_by_slug() { + let pool = setup_pool().await; + let uid = create_user(&pool).await; + let page = create_test_page(&pool, "About Us", "about", "published", uid).await; + + let found = find_by_slug(&pool, "about", None).await.unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().id, page.id); + } + + #[tokio::test] + async fn find_by_document_id_test() { + let pool = setup_pool().await; + let uid = create_user(&pool).await; + let page = create_test_page(&pool, "Contact", "contact", "draft", uid).await; + + let found = super::find_by_document_id(&pool, &page.document_id, None) + .await + .unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().slug, "contact"); + } + + #[tokio::test] + async fn list_published_excludes_drafts() { + let pool = setup_pool().await; + let uid = create_user(&pool).await; + create_test_page(&pool, "Published Page", "pub", "published", uid).await; + create_test_page(&pool, "Draft Page", "draft", "draft", uid).await; + + let (items, total) = list_published(&pool, 1, 10, None).await.unwrap(); + assert_eq!(total, 1); + assert_eq!(items.len(), 1); + assert_eq!(items[0].slug, "pub"); + } + + #[tokio::test] + async fn update_changes_title() { + let pool = setup_pool().await; + let uid = create_user(&pool).await; + let page = create_test_page(&pool, "Old Title", "old", "published", uid).await; + + let updated = update( + &pool, + page.id, + Some("New Title"), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ) + .await + .unwrap(); + + assert_eq!(updated.title, "New Title"); + } + + #[tokio::test] + async fn delete_removes_page() { + let pool = setup_pool().await; + let uid = create_user(&pool).await; + let page = create_test_page(&pool, "To Delete", "delete-me", "published", uid).await; + + delete(&pool, page.id, None).await.unwrap(); + let found = find_by_slug(&pool, "delete-me", None).await.unwrap(); + assert!(found.is_none()); + } + + #[tokio::test] + async fn update_status_changes() { + let pool = setup_pool().await; + let uid = create_user(&pool).await; + let page = create_test_page(&pool, "Status Test", "status-test", "draft", uid).await; + + assert_eq!(page.status, "draft"); + + let updated = update_status(&pool, page.id, "published", None, None) + .await + .unwrap(); + assert_eq!(updated.status, "published"); + assert!(updated.published_at.is_some()); + } +} diff --git a/src/models/password_reset.rs b/src/models/password_reset.rs index f814f6d2..43566245 100644 --- a/src/models/password_reset.rs +++ b/src/models/password_reset.rs @@ -115,3 +115,106 @@ pub async fn cleanup_expired(pool: &crate::db::Pool) -> AppResult { let result = sqlx::query(&sql).bind(now).execute(pool).await?; Ok(result.rows_affected()) } + +#[cfg(test)] +mod tests { + use super::*; + + async fn setup_pool() -> crate::db::Pool { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + pool + } + + async fn insert_user(pool: &crate::db::Pool) -> i64 { + let doc_id = crate::utils::id::new_document_id(); + let sql = format!( + "INSERT INTO users (document_id, email, username, password_hash, role) VALUES ({}, {}, {}, {}, 'admin') RETURNING id", + crate::db::dialect::ph(1), + crate::db::dialect::ph(2), + crate::db::dialect::ph(3), + crate::db::dialect::ph(4) + ); + let (id,): (i64,) = sqlx::query_as(&sql) + .bind(&doc_id) + .bind("pr-test@test.com") + .bind("pruser") + .bind("$argon2id$v=19$m=19456,t=2,p=1$test$test") + .fetch_one(pool) + .await + .unwrap(); + id + } + + #[tokio::test] + async fn create_and_find_by_token() { + let pool = setup_pool().await; + let user_id = insert_user(&pool).await; + let row = create(&pool, user_id, 3600).await.unwrap(); + assert!(row.id > 0); + assert_eq!(row.user_id, user_id); + assert!(!row.token.is_empty()); + assert!(row.used_at.is_none()); + + let found = find_by_token(&pool, &row.token).await.unwrap().unwrap(); + assert_eq!(found.id, row.id); + assert_eq!(found.token, row.token); + } + + #[tokio::test] + async fn test_mark_used() { + let pool = setup_pool().await; + let user_id = insert_user(&pool).await; + let row = create(&pool, user_id, 3600).await.unwrap(); + assert!(row.used_at.is_none()); + + super::mark_used(&pool, row.id).await.unwrap(); + + let found = find_by_token(&pool, &row.token).await.unwrap(); + assert!( + found.is_none(), + "used token should not be found by find_by_token" + ); + + let sql = format!( + "SELECT used_at FROM password_reset_tokens WHERE id = {}", + crate::db::dialect::ph(1), + ); + let (used_at,): (Option,) = sqlx::query_as(&sql) + .bind(row.id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(used_at.is_some(), "used_at should be set after mark_used"); + } + + #[tokio::test] + async fn test_delete_unused_by_user() { + let pool = setup_pool().await; + let user_id = insert_user(&pool).await; + let row1 = create(&pool, user_id, 3600).await.unwrap(); + let row2 = create(&pool, user_id, 3600).await.unwrap(); + + super::delete_unused_by_user(&pool, user_id).await.unwrap(); + + let found1 = find_by_token(&pool, &row1.token).await.unwrap(); + let found2 = find_by_token(&pool, &row2.token).await.unwrap(); + assert!(found1.is_none()); + assert!(found2.is_none()); + } + + #[tokio::test] + async fn test_cleanup_expired() { + let pool = setup_pool().await; + let user_id = insert_user(&pool).await; + let _row = create(&pool, user_id, 1).await.unwrap(); + + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + let removed = super::cleanup_expired(&pool).await.unwrap(); + assert_eq!(removed, 1); + } +} diff --git a/src/models/plugin_storage.rs b/src/models/plugin_storage.rs index f9463505..830b5d9f 100644 --- a/src/models/plugin_storage.rs +++ b/src/models/plugin_storage.rs @@ -120,3 +120,82 @@ pub async fn delete_all(pool: &Pool, plugin_id: &str) -> AppResult<()> { .await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + async fn setup_pool() -> crate::db::Pool { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + pool + } + + #[tokio::test] + async fn set_and_get() { + let pool = setup_pool().await; + set(&pool, "plugin_set_get", "key1", "hello", None) + .await + .unwrap(); + let val = get(&pool, "plugin_set_get", "key1").await.unwrap(); + assert_eq!(val, Some("hello".to_string())); + } + + #[tokio::test] + async fn get_missing_returns_none() { + let pool = setup_pool().await; + let val = get(&pool, "plugin_missing", "nope").await.unwrap(); + assert!(val.is_none()); + } + + #[tokio::test] + async fn set_overwrites() { + let pool = setup_pool().await; + set(&pool, "plugin_overwrite", "key1", "v1", None) + .await + .unwrap(); + set(&pool, "plugin_overwrite", "key1", "v2", None) + .await + .unwrap(); + let val = get(&pool, "plugin_overwrite", "key1").await.unwrap(); + assert_eq!(val, Some("v2".to_string())); + } + + #[tokio::test] + async fn delete_key() { + let pool = setup_pool().await; + set(&pool, "plugin_delete", "key1", "val", None) + .await + .unwrap(); + delete(&pool, "plugin_delete", "key1").await.unwrap(); + let val = get(&pool, "plugin_delete", "key1").await.unwrap(); + assert!(val.is_none()); + } + + #[tokio::test] + async fn delete_all_removes_all() { + let pool = setup_pool().await; + let pid = "plugin_delete_all"; + set(&pool, pid, "k1", "v1", None).await.unwrap(); + set(&pool, pid, "k2", "v2", None).await.unwrap(); + set(&pool, pid, "k3", "v3", None).await.unwrap(); + delete_all(&pool, pid).await.unwrap(); + assert!(get(&pool, pid, "k1").await.unwrap().is_none()); + assert!(get(&pool, pid, "k2").await.unwrap().is_none()); + assert!(get(&pool, pid, "k3").await.unwrap().is_none()); + } + + #[tokio::test] + async fn set_with_ttl() { + let pool = setup_pool().await; + set(&pool, "plugin_ttl", "key1", "expires", Some(1)) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + let val = get(&pool, "plugin_ttl", "key1").await.unwrap(); + assert!(val.is_none()); + } +} diff --git a/src/models/rbac.rs b/src/models/rbac.rs index 4f41e399..dc2cd323 100644 --- a/src/models/rbac.rs +++ b/src/models/rbac.rs @@ -206,3 +206,132 @@ pub async fn insert_permission( .await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + async fn setup_pool() -> crate::db::Pool { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + pool + } + + fn now() -> String { + chrono::Utc::now().to_rfc3339() + } + + #[sqlx::test] + async fn create_and_find_role_by_id() { + let pool = setup_pool().await; + let doc_id = crate::utils::id::new_document_id(); + let role = create_role(&pool, &doc_id, "admin_test", Some("desc"), &now()) + .await + .unwrap(); + assert_eq!(role.document_id, doc_id); + assert_eq!(role.name, "admin_test"); + + let found = find_role_by_id(&pool, &doc_id).await.unwrap().unwrap(); + assert_eq!(found.id, role.id); + } + + #[sqlx::test] + async fn list_roles_test() { + let pool = setup_pool().await; + for i in 0..3 { + let doc_id = crate::utils::id::new_document_id(); + create_role(&pool, &doc_id, &format!("role_{i}"), None, &now()) + .await + .unwrap(); + } + let roles = super::list_roles(&pool).await.unwrap(); + assert!(roles.len() >= 3); + } + + #[sqlx::test] + async fn update_role_changes_name() { + let pool = setup_pool().await; + let doc_id = crate::utils::id::new_document_id(); + create_role(&pool, &doc_id, "original", None, &now()) + .await + .unwrap(); + + let updated = update_role(&pool, &doc_id, Some("new_name"), None, &now()) + .await + .unwrap(); + assert_eq!(updated.name, "new_name"); + } + + #[sqlx::test] + async fn delete_role_test() { + let pool = setup_pool().await; + let doc_id = crate::utils::id::new_document_id(); + create_role(&pool, &doc_id, "to_delete", None, &now()) + .await + .unwrap(); + + super::delete_role(&pool, &doc_id).await.unwrap(); + let found = find_role_by_id(&pool, &doc_id).await.unwrap(); + assert!(found.is_none()); + } + + #[sqlx::test] + async fn permissions_crud() { + let pool = setup_pool().await; + let doc_id = crate::utils::id::new_document_id(); + let role = create_role(&pool, &doc_id, "perm_role", None, &now()) + .await + .unwrap(); + + let t = now(); + insert_permission( + &pool, + &crate::utils::id::new_document_id(), + role.id, + "read", + "posts", + None, + None, + &t, + ) + .await + .unwrap(); + insert_permission( + &pool, + &crate::utils::id::new_document_id(), + role.id, + "write", + "posts", + None, + None, + &t, + ) + .await + .unwrap(); + + let perms = find_permissions_by_role_id(&pool, role.id).await.unwrap(); + assert_eq!(perms.len(), 2); + + delete_permissions_by_role_id(&pool, role.id).await.unwrap(); + let perms = find_permissions_by_role_id(&pool, role.id).await.unwrap(); + assert!(perms.is_empty()); + } + + #[sqlx::test] + async fn find_role_id_by_name_test() { + let pool = setup_pool().await; + let doc_id = crate::utils::id::new_document_id(); + let role = create_role(&pool, &doc_id, "lookup_name", None, &now()) + .await + .unwrap(); + + let id = super::find_role_id_by_name(&pool, "lookup_name") + .await + .unwrap() + .unwrap(); + assert_eq!(id, role.id); + } +} diff --git a/src/models/refresh_token.rs b/src/models/refresh_token.rs index 305db49a..9059d614 100644 --- a/src/models/refresh_token.rs +++ b/src/models/refresh_token.rs @@ -91,3 +91,70 @@ pub async fn delete_by_user(pool: &crate::db::Pool, user_id: i64) -> AppResult<( .await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + async fn setup_pool() -> crate::db::Pool { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + pool + } + + async fn insert_user(pool: &crate::db::Pool) -> i64 { + let cmd = crate::commands::user::CreateUserCmd { + email: format!("{}@test.com", crate::utils::id::new_document_id()), + username: crate::utils::id::new_document_id(), + password_hash: "$argon2id$v=19$m=19456,t=2,p=1$test$test".to_string(), + }; + let user = crate::models::user::create(pool, &cmd, None).await.unwrap(); + user.id + } + + #[tokio::test] + async fn create_and_find_by_token() { + let pool = setup_pool().await; + let user_id = insert_user(&pool).await; + let token = crate::utils::id::new_document_id(); + create_token(&pool, user_id, &token, "2099-12-31T00:00:00Z") + .await + .unwrap(); + let found = find_by_token(&pool, &token).await.unwrap().unwrap(); + assert_eq!(found.token, token); + assert_eq!(found.user_id, user_id); + assert_eq!(found.expires_at, "2099-12-31T00:00:00Z"); + } + + #[tokio::test] + async fn delete_by_token() { + let pool = setup_pool().await; + let user_id = insert_user(&pool).await; + let token = crate::utils::id::new_document_id(); + create_token(&pool, user_id, &token, "2099-12-31T00:00:00Z") + .await + .unwrap(); + super::delete_by_token(&pool, &token).await.unwrap(); + assert!(find_by_token(&pool, &token).await.unwrap().is_none()); + } + + #[tokio::test] + async fn delete_by_user() { + let pool = setup_pool().await; + let user_id = insert_user(&pool).await; + let token1 = crate::utils::id::new_document_id(); + let token2 = crate::utils::id::new_document_id(); + create_token(&pool, user_id, &token1, "2099-12-31T00:00:00Z") + .await + .unwrap(); + create_token(&pool, user_id, &token2, "2099-12-31T00:00:00Z") + .await + .unwrap(); + super::delete_by_user(&pool, user_id).await.unwrap(); + assert!(find_by_token(&pool, &token1).await.unwrap().is_none()); + assert!(find_by_token(&pool, &token2).await.unwrap().is_none()); + } +} diff --git a/src/models/reusable_block.rs b/src/models/reusable_block.rs index 30637a81..f8e1e92b 100644 --- a/src/models/reusable_block.rs +++ b/src/models/reusable_block.rs @@ -217,3 +217,118 @@ pub async fn delete_reusable( let result = q.execute(pool).await?; AppError::expect_affected(&result, "reusable_block") } + +#[cfg(test)] +mod tests { + use super::*; + + async fn setup_pool() -> crate::db::Pool { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + pool + } + + async fn insert_user(pool: &crate::db::Pool) -> i64 { + let doc_id = crate::utils::id::new_document_id(); + let sql = format!( + "INSERT INTO users (document_id, email, username, password_hash, role) VALUES ({}, {}, {}, {}, 'admin') RETURNING id", + crate::db::dialect::ph(1), + crate::db::dialect::ph(2), + crate::db::dialect::ph(3), + crate::db::dialect::ph(4) + ); + let (id,): (i64,) = sqlx::query_as(&sql) + .bind(&doc_id) + .bind("block-test@test.com") + .bind("blockuser") + .bind("$argon2id$v=19$m=19456,t=2,p=1$test$test") + .fetch_one(pool) + .await + .unwrap(); + id + } + + #[sqlx::test] + async fn create_and_find_by_id() { + let pool = setup_pool().await; + let uid = insert_user(&pool).await; + let block = create_reusable(&pool, "Block", "text", "[]", None, Some(uid), None) + .await + .unwrap(); + let found = find_reusable_by_id(&pool, block.id, None).await.unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().name, "Block"); + } + + #[sqlx::test] + async fn find_by_document_id_test() { + let pool = setup_pool().await; + let uid = insert_user(&pool).await; + let block = create_reusable(&pool, "Block", "text", "[]", None, Some(uid), None) + .await + .unwrap(); + let found = super::find_reusable_by_document_id(&pool, &block.document_id, None) + .await + .unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().id, block.id); + } + + #[sqlx::test] + async fn list_reusable_test() { + let pool = setup_pool().await; + let uid = insert_user(&pool).await; + for i in 0..3 { + create_reusable( + &pool, + &format!("Block{i}"), + "text", + "[]", + None, + Some(uid), + None, + ) + .await + .unwrap(); + } + let list = super::list_reusable(&pool, None).await.unwrap(); + assert!(list.len() >= 3); + } + + #[sqlx::test] + async fn update_changes_name() { + let pool = setup_pool().await; + let uid = insert_user(&pool).await; + let block = create_reusable(&pool, "Block", "text", "[]", None, Some(uid), None) + .await + .unwrap(); + let updated = update_reusable( + &pool, + block.id, + Some("Updated"), + None, + None, + None, + Some(uid), + None, + ) + .await + .unwrap(); + assert_eq!(updated.name, "Updated"); + } + + #[sqlx::test] + async fn delete_removes_block() { + let pool = setup_pool().await; + let uid = insert_user(&pool).await; + let block = create_reusable(&pool, "Block", "text", "[]", None, Some(uid), None) + .await + .unwrap(); + delete_reusable(&pool, block.id, None).await.unwrap(); + let found = find_reusable_by_id(&pool, block.id, None).await.unwrap(); + assert!(found.is_none()); + } +} diff --git a/src/models/sms_code.rs b/src/models/sms_code.rs index bd9bafac..41ab5859 100644 --- a/src/models/sms_code.rs +++ b/src/models/sms_code.rs @@ -200,3 +200,125 @@ pub async fn cleanup_expired(pool: &crate::db::Pool) -> AppResult { let result = sqlx::query(&sql).bind(now).execute(pool).await?; Ok(result.rows_affected()) } + +#[cfg(test)] +mod tests { + async fn setup_pool() -> crate::db::Pool { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + pool + } + + fn unique_phone() -> String { + let id = crate::utils::id::new_document_id(); + let hash = id + .bytes() + .fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32)); + format!("1380000{:04}", hash % 10000) + } + + #[test] + fn generate_code_length() { + let code = super::generate_code(6); + assert_eq!(code.len(), 6); + assert!(code.chars().all(|c| c.is_ascii_digit())); + } + + #[tokio::test] + async fn create_and_find_by_id() { + let pool = setup_pool().await; + let phone = unique_phone(); + let code = super::generate_code(6); + + let sms = super::create(&pool, &phone, &code, "login", 300, Some("127.0.0.1")) + .await + .unwrap(); + + let found = super::find_by_id(&pool, sms.id).await.unwrap(); + assert!(found.is_some()); + let row = found.unwrap(); + assert_eq!(row.phone, phone); + assert_eq!(row.code, code); + assert_eq!(row.purpose, "login"); + } + + #[tokio::test] + async fn find_latest_unverified() { + let pool = setup_pool().await; + let phone = unique_phone(); + + let _first = super::create(&pool, &phone, "111111", "login", 300, None) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let second = super::create(&pool, &phone, "222222", "login", 300, None) + .await + .unwrap(); + + let latest = super::find_latest_unverified(&pool, &phone, "login") + .await + .unwrap(); + assert!(latest.is_some()); + assert_eq!(latest.unwrap().id, second.id); + } + + #[tokio::test] + async fn verify_code_correct() { + let pool = setup_pool().await; + let phone = unique_phone(); + let code = "654321"; + + let sms = super::create(&pool, &phone, code, "login", 300, None) + .await + .unwrap(); + + let result = super::verify_code(&pool, sms.id, code).await.unwrap(); + assert_eq!(result, super::VerifyResult::Verified); + } + + #[tokio::test] + async fn verify_code_wrong() { + let pool = setup_pool().await; + let phone = unique_phone(); + + let sms = super::create(&pool, &phone, "123456", "login", 300, None) + .await + .unwrap(); + + let result = super::verify_code(&pool, sms.id, "000000").await.unwrap(); + assert_eq!(result, super::VerifyResult::WrongCode); + } + + #[tokio::test] + async fn is_rate_limited() { + let pool = setup_pool().await; + let phone = unique_phone(); + + super::create(&pool, &phone, "111111", "login", 300, None) + .await + .unwrap(); + + let limited = super::is_rate_limited(&pool, &phone, "login", 60) + .await + .unwrap(); + assert!(limited); + } + + #[tokio::test] + async fn cleanup_expired() { + let pool = setup_pool().await; + let phone = unique_phone(); + + super::create(&pool, &phone, "111111", "login", 0, None) + .await + .unwrap(); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + let removed = super::cleanup_expired(&pool).await.unwrap(); + assert!(removed > 0); + } +} diff --git a/src/models/tag.rs b/src/models/tag.rs index 0a37ce7c..aa55d169 100644 --- a/src/models/tag.rs +++ b/src/models/tag.rs @@ -207,3 +207,89 @@ pub async fn update( AppError::expect_affected(&result, "tag")?; find_by_id(pool, id, tenant_id).await } + +#[cfg(test)] +mod tests { + use super::*; + + async fn setup_pool() -> crate::db::Pool { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + pool + } + + #[tokio::test] + async fn create_and_find_by_id() { + let pool = setup_pool().await; + let tag = create(&pool, "rust", "rust", None, None).await.unwrap(); + assert_eq!(tag.name, "rust"); + assert_eq!(tag.slug, "rust"); + + let found = find_by_id(&pool, tag.id, None).await.unwrap(); + assert_eq!(found.id, tag.id); + assert_eq!(found.name, "rust"); + } + + #[tokio::test] + async fn find_by_document_id() { + let pool = setup_pool().await; + let tag = create(&pool, "rust", "rust", None, None).await.unwrap(); + + let found = super::find_by_document_id(&pool, &tag.document_id, None) + .await + .unwrap(); + assert_eq!(found.id, tag.id); + assert_eq!(found.document_id, tag.document_id); + } + + #[tokio::test] + async fn find_all_returns_all() { + let pool = setup_pool().await; + create(&pool, "rust", "rust", None, None).await.unwrap(); + create(&pool, "axum", "axum", None, None).await.unwrap(); + create(&pool, "tokio", "tokio", None, None).await.unwrap(); + + let tags = find_all(&pool, None).await.unwrap(); + assert_eq!(tags.len(), 3); + } + + #[tokio::test] + async fn find_paginated() { + let pool = setup_pool().await; + create(&pool, "rust", "rust", None, None).await.unwrap(); + create(&pool, "axum", "axum", None, None).await.unwrap(); + create(&pool, "tokio", "tokio", None, None).await.unwrap(); + create(&pool, "serde", "serde", None, None).await.unwrap(); + create(&pool, "clap", "clap", None, None).await.unwrap(); + + let (items, total) = super::find_paginated(&pool, None, 1, 3).await.unwrap(); + assert_eq!(total, 5); + assert_eq!(items.len(), 3); + } + + #[tokio::test] + async fn update_changes_name() { + let pool = setup_pool().await; + let tag = create(&pool, "rust", "rust", None, None).await.unwrap(); + + let updated = update(&pool, tag.id, "Rust Lang", "rust-lang", None) + .await + .unwrap(); + assert_eq!(updated.name, "Rust Lang"); + assert_eq!(updated.slug, "rust-lang"); + assert_eq!(updated.id, tag.id); + } + + #[tokio::test] + async fn delete_removes_tag() { + let pool = setup_pool().await; + let tag = create(&pool, "rust", "rust", None, None).await.unwrap(); + + delete(&pool, tag.id, None).await.unwrap(); + let result = find_by_id(&pool, tag.id, None).await; + assert!(result.is_err()); + } +} diff --git a/src/models/tenant.rs b/src/models/tenant.rs index 9f2f0b31..2e38b22e 100644 --- a/src/models/tenant.rs +++ b/src/models/tenant.rs @@ -149,3 +149,131 @@ pub async fn delete(pool: &crate::db::Pool, document_id: &str) -> AppResult<()> sqlx::query(&sql).bind(document_id).execute(pool).await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + async fn setup_pool() -> crate::db::Pool { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + pool + } + + #[tokio::test] + async fn create_and_find_by_id() { + let pool = setup_pool().await; + let doc_id = "tenant-001"; + let now = chrono::Utc::now().to_rfc3339(); + let row = create( + &pool, + doc_id, + "Test Tenant", + Some("test.example.com"), + "{}", + &now, + ) + .await + .unwrap(); + assert_eq!(row.document_id, doc_id); + assert_eq!(row.name, "Test Tenant"); + assert_eq!(row.domain.unwrap(), "test.example.com"); + + let found = find_by_id(&pool, doc_id).await.unwrap().unwrap(); + assert_eq!(found.id, row.id); + assert_eq!(found.document_id, doc_id); + } + + #[tokio::test] + async fn find_by_domain_returns_match() { + let pool = setup_pool().await; + let now = chrono::Utc::now().to_rfc3339(); + create( + &pool, + "tenant-002", + "Dom Tenant", + Some("dom.example.com"), + "{}", + &now, + ) + .await + .unwrap(); + + let found = find_by_domain(&pool, "dom.example.com") + .await + .unwrap() + .unwrap(); + assert_eq!(found.document_id, "tenant-002"); + assert_eq!(found.name, "Dom Tenant"); + + let missing = find_by_domain(&pool, "no.such.domain").await.unwrap(); + assert!(missing.is_none()); + } + + #[tokio::test] + async fn find_all_returns_all() { + let pool = setup_pool().await; + let now = chrono::Utc::now().to_rfc3339(); + create(&pool, "tenant-a", "Alpha", None, "{}", &now) + .await + .unwrap(); + create(&pool, "tenant-b", "Bravo", None, "{}", &now) + .await + .unwrap(); + create(&pool, "tenant-c", "Charlie", None, "{}", &now) + .await + .unwrap(); + + let all = find_all(&pool).await.unwrap(); + assert!(all.len() >= 3); + } + + #[tokio::test] + async fn update_changes_name() { + let pool = setup_pool().await; + let doc_id = "tenant-003"; + let now = chrono::Utc::now().to_rfc3339(); + create( + &pool, + doc_id, + "Original", + Some("orig.example.com"), + "{}", + &now, + ) + .await + .unwrap(); + + let later = chrono::Utc::now().to_rfc3339(); + let updated = update( + &pool, + doc_id, + Some("Updated Name"), + None, + None, + None, + &later, + ) + .await + .unwrap(); + assert_eq!(updated.name, "Updated Name"); + assert_eq!(updated.domain.unwrap(), "orig.example.com"); + } + + #[tokio::test] + async fn delete_removes_tenant() { + let pool = setup_pool().await; + let doc_id = "tenant-004"; + let now = chrono::Utc::now().to_rfc3339(); + create(&pool, doc_id, "ToDelete", None, "{}", &now) + .await + .unwrap(); + + delete(&pool, doc_id).await.unwrap(); + let found = find_by_id(&pool, doc_id).await.unwrap(); + assert!(found.is_none()); + } +} diff --git a/src/models/user.rs b/src/models/user.rs index 6d476db1..9c9073fc 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -342,3 +342,138 @@ pub async fn update_role( .await? .ok_or_else(|| AppError::not_found("user")) } + +#[cfg(test)] +mod tests { + use super::*; + + async fn setup_pool() -> crate::db::Pool { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + pool + } + + fn new_cmd(email: &str, username: &str) -> crate::commands::user::CreateUserCmd { + crate::commands::user::CreateUserCmd { + email: email.to_string(), + username: username.to_string(), + password_hash: "$argon2id$v=19$m=19456,t=2,p=1$test$test".to_string(), + } + } + + #[tokio::test] + async fn create_and_find_by_email() { + let pool = setup_pool().await; + let user = create(&pool, &new_cmd("a@b.com", "alice"), None) + .await + .unwrap(); + let found = find_by_email(&pool, "a@b.com", None) + .await + .unwrap() + .unwrap(); + assert_eq!(found.id, user.id); + assert_eq!(found.email, "a@b.com"); + } + + #[tokio::test] + async fn find_by_id() { + let pool = setup_pool().await; + let user = create(&pool, &new_cmd("id@b.com", "iduser"), None) + .await + .unwrap(); + let found = super::find_by_id(&pool, &user.document_id, None) + .await + .unwrap() + .unwrap(); + assert_eq!(found.id, user.id); + assert_eq!(found.document_id, user.document_id); + } + + #[tokio::test] + async fn find_by_pk() { + let pool = setup_pool().await; + let user = create(&pool, &new_cmd("pk@b.com", "pkuser"), None) + .await + .unwrap(); + let found = super::find_by_pk(&pool, user.id, None) + .await + .unwrap() + .unwrap(); + assert_eq!(found.document_id, user.document_id); + assert_eq!(found.email, "pk@b.com"); + } + + #[tokio::test] + async fn update_profile() { + let pool = setup_pool().await; + let user = create(&pool, &new_cmd("prof@b.com", "profuser"), None) + .await + .unwrap(); + let updated = super::update_profile( + &pool, + &crate::commands::user::UpdateProfileCmd { + id: user.id, + username: Some("newname".to_string()), + bio: Some("hello world".to_string()), + website: None, + avatar: None, + }, + None, + ) + .await + .unwrap(); + assert_eq!(updated.username, "newname"); + assert_eq!(updated.bio.unwrap(), "hello world"); + } + + #[tokio::test] + async fn update_password() { + let pool = setup_pool().await; + let user = create(&pool, &new_cmd("pw@b.com", "pwuser"), None) + .await + .unwrap(); + let old_hash = user.password_hash.clone(); + super::update_password(&pool, &user.document_id, "$new_hash", None) + .await + .unwrap(); + let found = super::find_by_id(&pool, &user.document_id, None) + .await + .unwrap() + .unwrap(); + assert_ne!(found.password_hash, old_hash); + assert_eq!(found.password_hash, "$new_hash"); + } + + #[tokio::test] + async fn find_all_paginated() { + let pool = setup_pool().await; + for i in 0..5 { + create( + &pool, + &new_cmd(&format!("page{i}@b.com"), &format!("user{i}")), + None, + ) + .await + .unwrap(); + } + let (users, total) = find_all(&pool, 1, 3, None).await.unwrap(); + assert_eq!(users.len(), 3); + assert_eq!(total, 5); + } + + #[tokio::test] + async fn update_role() { + let pool = setup_pool().await; + let user = create(&pool, &new_cmd("role@b.com", "roleuser"), None) + .await + .unwrap(); + assert_eq!(user.role, "reader"); + let updated = super::update_role(&pool, &user.document_id, "author", None) + .await + .unwrap(); + assert_eq!(updated.role, "author"); + } +} diff --git a/src/models/workflow.rs b/src/models/workflow.rs index d734b9b6..eebeb70f 100644 --- a/src/models/workflow.rs +++ b/src/models/workflow.rs @@ -401,3 +401,203 @@ pub async fn list_step_logs(pool: &Pool, instance_id: i64) -> anyhow::Result crate::db::Pool { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + pool + } + + #[tokio::test] + async fn create_and_get_definition() { + let pool = setup_pool().await; + let doc_id = crate::utils::id::new_document_id(); + let steps = r#"[{"id":"s1","name":"Step 1","type":"task","config":{},"next":""}]"#; + let def = super::create_definition(&pool, &doc_id, "Test WF", Some("desc"), steps, "s1") + .await + .unwrap(); + assert_eq!(def.document_id, doc_id); + assert_eq!(def.name, "Test WF"); + + let got = super::get_definition(&pool, &doc_id) + .await + .unwrap() + .unwrap(); + assert_eq!(got.id, def.id); + } + + #[tokio::test] + async fn list_definitions() { + let pool = setup_pool().await; + let steps = r#"[{"id":"s1","name":"Step 1","type":"task","config":{},"next":""}]"#; + for i in 0..3 { + let doc_id = crate::utils::id::new_document_id(); + super::create_definition(&pool, &doc_id, &format!("WF {i}"), None, steps, "s1") + .await + .unwrap(); + } + let list = super::list_definitions(&pool).await.unwrap(); + assert_eq!(list.len(), 3); + } + + #[tokio::test] + async fn delete_definition() { + let pool = setup_pool().await; + let doc_id = crate::utils::id::new_document_id(); + let steps = r#"[{"id":"s1","name":"Step 1","type":"task","config":{},"next":""}]"#; + super::create_definition(&pool, &doc_id, "To delete", None, steps, "s1") + .await + .unwrap(); + + super::delete_definition(&pool, &doc_id).await.unwrap(); + assert!( + super::get_definition(&pool, &doc_id) + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn create_and_get_instance() { + let pool = setup_pool().await; + let doc_id = crate::utils::id::new_document_id(); + let steps = r#"[{"id":"s1","name":"Step 1","type":"task","config":{},"next":""}]"#; + let def = super::create_definition(&pool, &doc_id, "WF", None, steps, "s1") + .await + .unwrap(); + + let inst_doc_id = crate::utils::id::new_document_id(); + let ctx = serde_json::json!({"key": "value"}); + let inst = super::create_instance(&pool, &inst_doc_id, def.id, &ctx, None) + .await + .unwrap(); + assert_eq!(inst.document_id, inst_doc_id); + assert_eq!(inst.definition_id, def.id); + assert_eq!(inst.status, "running"); + + let got = super::get_instance(&pool, &inst_doc_id) + .await + .unwrap() + .unwrap(); + assert_eq!(got.id, inst.id); + } + + #[tokio::test] + async fn list_instances_test() { + let pool = setup_pool().await; + let doc_id = crate::utils::id::new_document_id(); + let steps = r#"[{"id":"s1","name":"Step 1","type":"task","config":{},"next":""}]"#; + let def = super::create_definition(&pool, &doc_id, "WF", None, steps, "s1") + .await + .unwrap(); + + for _ in 0..3 { + let inst_doc_id = crate::utils::id::new_document_id(); + let ctx = serde_json::json!({}); + super::create_instance(&pool, &inst_doc_id, def.id, &ctx, None) + .await + .unwrap(); + } + + let (rows, count) = super::list_instances(&pool, Some(def.id), None, 1, 10) + .await + .unwrap(); + assert_eq!(rows.len(), 3); + assert_eq!(count, 3); + } + + #[tokio::test] + async fn update_instance_step() { + let pool = setup_pool().await; + let doc_id = crate::utils::id::new_document_id(); + let steps = r#"[{"id":"s1","name":"Step 1","type":"task","config":{},"next":""}]"#; + let def = super::create_definition(&pool, &doc_id, "WF", None, steps, "s1") + .await + .unwrap(); + + let inst_doc_id = crate::utils::id::new_document_id(); + let ctx = serde_json::json!({}); + super::create_instance(&pool, &inst_doc_id, def.id, &ctx, None) + .await + .unwrap(); + + let new_ctx = serde_json::json!({"progress": 50}); + super::update_instance_step(&pool, &inst_doc_id, "paused", Some("s1"), &new_ctx) + .await + .unwrap(); + + let inst = super::get_instance(&pool, &inst_doc_id) + .await + .unwrap() + .unwrap(); + assert_eq!(inst.status, "paused"); + assert_eq!(inst.current_step, Some("s1".to_string())); + } + + #[tokio::test] + async fn step_log_lifecycle() { + let pool = setup_pool().await; + let doc_id = crate::utils::id::new_document_id(); + let steps = r#"[{"id":"s1","name":"Step 1","type":"task","config":{},"next":""}]"#; + let def = super::create_definition(&pool, &doc_id, "WF", None, steps, "s1") + .await + .unwrap(); + + let inst_doc_id = crate::utils::id::new_document_id(); + let ctx = serde_json::json!({}); + let inst = super::create_instance(&pool, &inst_doc_id, def.id, &ctx, None) + .await + .unwrap(); + + let log_doc_id = crate::utils::id::new_document_id(); + let input = serde_json::json!({"data": 1}); + let log = super::create_step_log(&pool, &log_doc_id, inst.id, "s1", "Step 1", Some(&input)) + .await + .unwrap(); + assert_eq!(log.status, "running"); + assert_eq!(log.step_id, "s1"); + + let output = serde_json::json!({"result": "ok"}); + super::complete_step_log(&pool, &log_doc_id, Some(&output)) + .await + .unwrap(); + + let logs = super::list_step_logs(&pool, inst.id).await.unwrap(); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].status, "completed"); + assert!(logs[0].completed_at.is_some()); + } + + #[tokio::test] + async fn parse_steps_valid() { + let pool = setup_pool().await; + let doc_id = crate::utils::id::new_document_id(); + let steps = r#"[{"id":"s1","name":"Step 1","type":"task","config":{},"next":""}]"#; + let def = super::create_definition(&pool, &doc_id, "WF", None, steps, "s1") + .await + .unwrap(); + + let parsed = def.parse_steps().unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].id, "s1"); + assert_eq!(parsed[0].step_type, super::StepType::Task); + } + + #[tokio::test] + async fn parse_steps_invalid_json() { + let pool = setup_pool().await; + let doc_id = crate::utils::id::new_document_id(); + let steps = "not valid json!!!"; + let def = super::create_definition(&pool, &doc_id, "WF", None, steps, "s1") + .await + .unwrap(); + + assert!(def.parse_steps().is_err()); + } +} diff --git a/src/protocols/ownable.rs b/src/protocols/ownable.rs index 481dfad0..3441536a 100644 --- a/src/protocols/ownable.rs +++ b/src/protocols/ownable.rs @@ -79,7 +79,8 @@ impl Aspect for OwnableAspect { .as_ref() .is_none_or(|s| s.is_protocol_column(COL_UPDATED_BY)) { - ctx.new_record.insert(COL_UPDATED_BY.into(), json!(user_int_id)); + ctx.new_record + .insert(COL_UPDATED_BY.into(), json!(user_int_id)); } Ok(Advice::Continue) } diff --git a/src/repositories/sqlx_media.rs b/src/repositories/sqlx_media.rs index be8737d3..98ce5287 100644 --- a/src/repositories/sqlx_media.rs +++ b/src/repositories/sqlx_media.rs @@ -1,8 +1,8 @@ //! 基于 sqlx 的 `MediaRepository` 实现 +use crate::commands::CreateMediaCmd; use crate::errors::app_error::AppResult; use crate::models::media::{self, Media, MediaStats}; -use crate::commands::CreateMediaCmd; use crate::repositories::define_sqlx_repo; define_sqlx_repo!(SqlxMediaRepository); diff --git a/src/services/aspect_dispatch.rs b/src/services/aspect_dispatch.rs index 8fd9745f..25a2fc1b 100644 --- a/src/services/aspect_dispatch.rs +++ b/src/services/aspect_dispatch.rs @@ -137,3 +137,35 @@ pub fn get_str(record: &Record, key: &str) -> Option { .and_then(|v| v.as_str()) .map(|s| s.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn id_record_contains_id() { + let r = id_record("abc-123"); + let val = r.get("id").and_then(|v| v.as_str()); + assert_eq!(val, Some("abc-123")); + } + + #[test] + fn get_str_returns_value() { + let mut r = Record::new(); + r.insert("name".into(), serde_json::json!("test-value")); + assert_eq!(get_str(&r, "name"), Some("test-value".to_string())); + } + + #[test] + fn get_str_missing_key_returns_none() { + let r = Record::new(); + assert_eq!(get_str(&r, "missing"), None); + } + + #[test] + fn get_str_non_string_returns_none() { + let mut r = Record::new(); + r.insert("num".into(), serde_json::json!(42)); + assert_eq!(get_str(&r, "num"), None); + } +} diff --git a/src/services/auth.rs b/src/services/auth.rs index dceb540a..1d09d9e8 100644 --- a/src/services/auth.rs +++ b/src/services/auth.rs @@ -14,11 +14,9 @@ use jsonwebtoken::{EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; use crate::commands::CreateUserCmd; +use crate::dto::{LoginResponse, RegisterRequest, UpdatePasswordRequest, UserResponse}; use crate::errors::app_error::{AppError, AppResult}; use crate::eventbus::{Event, EventBus}; -use crate::dto::{ - LoginResponse, RegisterRequest, UpdatePasswordRequest, UserResponse, -}; use crate::middleware::auth::AuthUser; use crate::plugins::{HookPoint, PluginManager}; use crate::repositories::{RefreshTokenRepository, UserRepository}; @@ -205,7 +203,13 @@ pub async fn register( }); if require_email_verification { - let _ = crate::services::email_verification::trigger_email_verification(pool, eventbus, user.id, &user.email).await; + let _ = crate::services::email_verification::trigger_email_verification( + pool, + eventbus, + user.id, + &user.email, + ) + .await; } Ok(user.into()) diff --git a/src/services/category.rs b/src/services/category.rs index cfeec66a..a474c531 100644 --- a/src/services/category.rs +++ b/src/services/category.rs @@ -3,8 +3,8 @@ use slug::slugify; use crate::commands::{CreateCategoryCmd, UpdateCategoryCmd}; -use crate::errors::app_error::{AppError, AppResult}; use crate::dto::{CreateCategoryRequest, UpdateCategoryRequest}; +use crate::errors::app_error::{AppError, AppResult}; use crate::middleware::auth::AuthUser; use crate::repositories::CategoryRepository; diff --git a/src/services/media.rs b/src/services/media.rs index ea9ce04a..3a523de4 100644 --- a/src/services/media.rs +++ b/src/services/media.rs @@ -425,3 +425,228 @@ fn detect_mime_from_magic(data: &[u8]) -> Option<&'static str> { } None } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mime_to_ext_known_types() { + assert_eq!(mime_to_ext("image/jpeg"), "jpg"); + assert_eq!(mime_to_ext("image/png"), "png"); + assert_eq!(mime_to_ext("image/gif"), "gif"); + assert_eq!(mime_to_ext("image/webp"), "webp"); + assert_eq!(mime_to_ext("image/svg+xml"), "svg"); + assert_eq!(mime_to_ext("video/mp4"), "mp4"); + assert_eq!(mime_to_ext("audio/mpeg"), "mp3"); + assert_eq!(mime_to_ext("application/pdf"), "pdf"); + assert_eq!(mime_to_ext("application/zip"), "zip"); + assert_eq!(mime_to_ext("text/plain"), "txt"); + assert_eq!(mime_to_ext("text/markdown"), "md"); + assert_eq!(mime_to_ext("text/csv"), "csv"); + } + + #[test] + fn mime_to_ext_unknown_returns_bin() { + assert_eq!(mime_to_ext("application/x-unknown"), "bin"); + assert_eq!(mime_to_ext("foo/bar"), "bin"); + } + + #[test] + fn validate_magic_bytes_empty_data() { + assert!(!validate_magic_bytes("image/jpeg", &[])); + } + + #[test] + fn validate_magic_bytes_jpeg() { + assert!(validate_magic_bytes("image/jpeg", b"\xFF\xD8\xFF\xE0")); + assert!(!validate_magic_bytes("image/jpeg", b"\x89PNG\r\n\x1a\n")); + } + + #[test] + fn validate_magic_bytes_png() { + let png_header = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"; + assert!(validate_magic_bytes("image/png", png_header)); + assert!(!validate_magic_bytes("image/png", b"\xFF\xD8\xFF")); + } + + #[test] + fn validate_magic_bytes_gif() { + assert!(validate_magic_bytes("image/gif", b"GIF89a\x00\x00")); + assert!(validate_magic_bytes("image/gif", b"GIF87a\x00\x00")); + assert!(!validate_magic_bytes("image/gif", b"BM\x00\x00\x00")); + } + + #[test] + fn validate_magic_bytes_webp() { + let mut webp = vec![b'R', b'I', b'F', b'F', 0, 0, 0, 0, b'W', b'E', b'B', b'P']; + webp.extend_from_slice(&[0; 20]); + assert!(validate_magic_bytes("image/webp", &webp)); + assert!(!validate_magic_bytes("image/webp", b"\xFF\xD8\xFF")); + } + + #[test] + fn validate_magic_bytes_pdf() { + assert!(validate_magic_bytes("application/pdf", b"%PDF-1.4\n")); + assert!(!validate_magic_bytes("application/pdf", b"")); + } + + #[test] + fn validate_magic_bytes_zip() { + assert!(validate_magic_bytes( + "application/zip", + b"PK\x03\x04\x00\x00" + )); + assert!(!validate_magic_bytes("application/zip", b"\x1F\x8B\x08")); + } + + #[test] + fn validate_magic_bytes_gzip() { + assert!(validate_magic_bytes( + "application/gzip", + b"\x1F\x8B\x08\x00" + )); + assert!(!validate_magic_bytes("application/gzip", b"PK\x03\x04")); + } + + #[test] + fn validate_magic_bytes_rar() { + assert!(validate_magic_bytes( + "application/x-rar-compressed", + b"Rar!\x1A\x07\x00" + )); + assert!(!validate_magic_bytes( + "application/x-rar-compressed", + b"PK\x03\x04" + )); + } + + #[test] + fn validate_magic_bytes_mp3() { + assert!(validate_magic_bytes("audio/mpeg", b"\xFF\xFB\x90\x00")); + assert!(validate_magic_bytes("audio/mpeg", b"ID3\x03\x00")); + assert!(!validate_magic_bytes("audio/mpeg", b"OggS\x00")); + } + + #[test] + fn validate_magic_bytes_ogg() { + assert!(validate_magic_bytes("audio/ogg", b"OggS\x00\x02")); + assert!(!validate_magic_bytes("audio/ogg", b"\xFF\xFB")); + } + + #[test] + fn validate_magic_bytes_wav() { + let mut wav = b"RIFF".to_vec(); + wav.extend_from_slice(&[0, 0, 0, 0]); + wav.extend_from_slice(b"WAVE"); + wav.extend_from_slice(&[0; 20]); + assert!(validate_magic_bytes("audio/wav", &wav)); + assert!(!validate_magic_bytes("audio/wav", b"\xFF\xD8\xFF")); + } + + #[test] + fn validate_magic_bytes_mp4() { + let mut mp4 = vec![0, 0, 0, 0x20]; + mp4.extend_from_slice(b"ftypisom"); + mp4.extend_from_slice(&[0; 24]); + assert!(validate_magic_bytes("video/mp4", &mp4)); + assert!(!validate_magic_bytes("video/mp4", b"\x1a\x45\xdf\xa3")); + } + + #[test] + fn validate_magic_bytes_webm() { + assert!(validate_magic_bytes( + "video/webm", + b"\x1a\x45\xdf\xa3\x00\x00" + )); + assert!(!validate_magic_bytes("video/webm", b"\xFF\xD8\xFF")); + } + + #[test] + fn validate_magic_bytes_skip_types_always_pass() { + assert!(validate_magic_bytes("text/plain", b"hello")); + assert!(validate_magic_bytes("text/csv", b"a,b")); + assert!(validate_magic_bytes("text/markdown", b"# Title")); + assert!(validate_magic_bytes("image/svg+xml", b"")); + assert!(validate_magic_bytes("application/x-tar", b"foo")); + } + + #[test] + fn validate_magic_bytes_unknown_type_fails() { + assert!(!validate_magic_bytes( + "application/x-totally-fake", + b"\x00\x01\x02" + )); + } + + #[test] + fn detect_mime_from_magic_jpeg() { + assert_eq!( + detect_mime_from_magic(b"\xFF\xD8\xFF\xE0"), + Some("image/jpeg") + ); + } + + #[test] + fn detect_mime_from_magic_png() { + assert_eq!( + detect_mime_from_magic(b"\x89PNG\r\n\x1a\n\x00"), + Some("image/png") + ); + } + + #[test] + fn detect_mime_from_magic_gif() { + assert_eq!( + detect_mime_from_magic(b"GIF89a\x00\x00\x00"), + Some("image/gif") + ); + } + + #[test] + fn detect_mime_from_magic_pdf() { + assert_eq!( + detect_mime_from_magic(b"%PDF-1.4\n"), + Some("application/pdf") + ); + } + + #[test] + fn detect_mime_from_magic_zip() { + assert_eq!( + detect_mime_from_magic(b"PK\x03\x04\x00"), + Some("application/zip") + ); + } + + #[test] + fn detect_mime_from_magic_unknown() { + assert_eq!(detect_mime_from_magic(b"\x00\x01"), None); + assert_eq!(detect_mime_from_magic(b"\x00"), None); + } + + #[test] + fn storage_key_format() { + let key = storage_key("uploads", "jpg"); + assert!(key.starts_with("uploads/")); + assert!(key.ends_with(".jpg")); + let parts: Vec<&str> = key.split('/').collect(); + assert_eq!(parts.len(), 4); + assert!(parts[1].len() == 4); + assert!(parts[2].len() == 2); + } + + #[test] + fn parse_image_dimensions_invalid_data() { + let (w, h) = parse_image_dimensions(b"not an image"); + assert_eq!(w, None); + assert_eq!(h, None); + } + + #[test] + fn parse_image_dimensions_empty() { + let (w, h) = parse_image_dimensions(&[]); + assert_eq!(w, None); + assert_eq!(h, None); + } +} diff --git a/src/services/oauth.rs b/src/services/oauth.rs index 40aa08e4..f98e6135 100644 --- a/src/services/oauth.rs +++ b/src/services/oauth.rs @@ -10,8 +10,8 @@ use chrono::Utc; use ts_rs::TS; use crate::commands::CreateUserCmd; -use crate::errors::app_error::{AppError, AppResult}; use crate::dto::LoginResponse; +use crate::errors::app_error::{AppError, AppResult}; use crate::middleware::auth::AuthUser; use crate::models::oauth; use crate::oauth::{OAuthProviderRegistry, OAuthUserInfo}; @@ -472,3 +472,52 @@ async fn update_oauth_account( ) .await } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_username_alphanumeric_only() { + assert_eq!(sanitize_username("JohnDoe123"), "JohnDoe123"); + } + + #[test] + fn sanitize_username_strips_special_chars() { + assert_eq!( + sanitize_username("john.doe@example.com"), + "johndoeexamplecom" + ); + } + + #[test] + fn sanitize_username_keeps_underscore() { + assert_eq!(sanitize_username("john_doe"), "john_doe"); + } + + #[test] + fn sanitize_username_strips_leading_trailing_underscores() { + assert_eq!(sanitize_username("__john__"), "john"); + } + + #[test] + fn sanitize_username_all_special_returns_default() { + assert_eq!(sanitize_username("@#$%"), "user"); + } + + #[test] + fn sanitize_username_empty_returns_default() { + assert_eq!(sanitize_username(""), "user"); + } + + #[test] + fn sanitize_username_unicode_stripped() { + let result = sanitize_username("用户名"); + assert_eq!(result, "user"); + } + + #[test] + fn sanitize_username_mixed() { + assert_eq!(sanitize_username("John_Doe-123!"), "John_Doe123"); + } +} diff --git a/src/services/page.rs b/src/services/page.rs index b4ffc4ea..140efea1 100644 --- a/src/services/page.rs +++ b/src/services/page.rs @@ -167,3 +167,59 @@ pub async fn sitemap( pub fn generate_slug(title: &str) -> String { slugify(title) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_blocks_json_valid_empty() { + let blocks = validate_blocks_json("[]").unwrap(); + assert!(blocks.is_empty()); + } + + #[test] + fn validate_blocks_json_valid_richtext() { + let json = r#"[{"type":"richtext","content":"hello"}]"#; + let blocks = validate_blocks_json(json).unwrap(); + assert_eq!(blocks.len(), 1); + assert!(matches!(blocks[0], page::PageBlock::Richtext { .. })); + } + + #[test] + fn validate_blocks_json_invalid() { + let result = validate_blocks_json("not json"); + assert!(result.is_err()); + } + + #[test] + fn validate_blocks_json_invalid_structure() { + let result = validate_blocks_json(r#"[{"wrong":"field"}]"#); + assert!(result.is_err()); + } + + #[test] + fn generate_slug_basic() { + assert_eq!(generate_slug("Hello World"), "hello-world"); + } + + #[test] + fn generate_slug_special_chars() { + let slug = generate_slug("Hello, World! (2024)"); + assert!(!slug.contains(',')); + assert!(!slug.contains('!')); + assert!(!slug.contains('(')); + } + + #[test] + fn generate_slug_chinese() { + let slug = generate_slug("你好世界"); + assert!(!slug.is_empty()); + } + + #[test] + fn generate_slug_empty() { + let slug = generate_slug(""); + assert!(slug.is_empty() || !slug.is_empty()); + } +} diff --git a/src/services/post.rs b/src/services/post.rs index 05e71d11..593a9ca2 100644 --- a/src/services/post.rs +++ b/src/services/post.rs @@ -10,9 +10,9 @@ use slug::slugify; use crate::commands::{CreatePostCmd, FindPublishedQuery, UpdatePostCmd}; +use crate::dto::{CreatePostRequest, PostResponse, UpdatePostRequest}; use crate::errors::app_error::{AppError, AppResult}; use crate::eventbus::{Event, EventBus}; -use crate::dto::{CreatePostRequest, PostResponse, UpdatePostRequest}; use crate::middleware::auth::AuthUser; use crate::models::post::PostJoinedRow; use crate::plugins::{HookPoint, PluginManager}; @@ -47,9 +47,9 @@ pub async fn resolve_doc_id_to_int( if let Some(tid) = tenant_id { q = q.bind(tid); } - q.fetch_optional(pool).await.map_err(|e| { - AppError::Internal(anyhow::anyhow!("resolve doc_id in {table} failed: {e}")) - }) + q.fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("resolve doc_id in {table} failed: {e}"))) } async fn joined_row_to_response( @@ -605,4 +605,27 @@ mod tests { let result = extract_excerpt(&content, 200); assert!(result.ends_with("...") || result.len() <= 200); } + + #[test] + fn make_unique_slug_has_suffix() { + let slug = make_unique_slug("my-post"); + assert!(slug.starts_with("my-post-")); + let suffix = &slug["my-post-".len()..]; + assert_eq!(suffix.len(), 4); + assert!(suffix.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn make_unique_slug_different_each_call() { + let s1 = make_unique_slug("test"); + let s2 = make_unique_slug("test"); + assert_ne!(s1, s2); + } + + #[test] + fn extract_excerpt_zero_max_len() { + let content = "hello world"; + let result = extract_excerpt(content, 0); + assert!(result.is_empty() || result.ends_with("...")); + } } diff --git a/src/services/rbac.rs b/src/services/rbac.rs index 5ef43a35..99095175 100644 --- a/src/services/rbac.rs +++ b/src/services/rbac.rs @@ -343,4 +343,85 @@ mod tests { assert_eq!(resolve_template("$user.id", &ctx), "user-1"); assert_eq!(resolve_template("literal_value", &ctx), "literal_value"); } + + #[test] + fn rsplit_dot_with_dot() { + assert_eq!(rsplit_dot("a.b.c"), ("a.b", "c")); + assert_eq!(rsplit_dot("single"), ("single", "")); + } + + #[test] + fn rsplit_dot_no_dot() { + assert_eq!(rsplit_dot("noun"), ("noun", "")); + } + + #[test] + fn ns_matches_exact() { + assert!(ns_matches("content-type", "content-type")); + assert!(ns_matches("a::b", "a::b")); + } + + #[test] + fn ns_matches_wildcard() { + assert!(ns_matches("*", "content-type")); + assert!(ns_matches("*::*", "content-type::post")); + assert!(!ns_matches("a::*", "b::c")); + } + + #[test] + fn ns_matches_different_depth() { + assert!(!ns_matches("a", "a::b")); + assert!(!ns_matches("a::b::c", "a::b")); + } + + #[test] + fn matches_action_exact_match() { + assert!(matches_action("post.create", "post.create")); + assert!(!matches_action("post.create", "post.delete")); + } + + #[test] + fn matches_action_wildcard_op() { + assert!(matches_action("post.*", "post.create")); + assert!(matches_action("post.*", "post.delete")); + } + + #[test] + fn matches_subject_wildcard_parts() { + assert!(matches_subject("content-type::*", "content-type::post")); + assert!(matches_subject("*::*", "content-type::post")); + assert!(!matches_subject("blog::*", "content-type::post")); + } + + #[test] + fn matches_subject_different_depth() { + assert!(!matches_subject("a", "a::b")); + } + + #[test] + fn check_conditions_missing_key() { + let mut conditions = HashMap::new(); + conditions.insert("missing_key".into(), "value".into()); + let context = HashMap::new(); + assert!(!check_conditions(&conditions, &context)); + } + + #[test] + fn check_conditions_empty_passes() { + let conditions = HashMap::new(); + let context = HashMap::new(); + assert!(check_conditions(&conditions, &context)); + } + + #[test] + fn resolve_template_missing_var() { + let ctx = HashMap::new(); + assert_eq!(resolve_template("$user.nonexistent", &ctx), ""); + } + + #[test] + fn resolve_template_non_user_var() { + let ctx = HashMap::new(); + assert_eq!(resolve_template("plain_text", &ctx), "plain_text"); + } } diff --git a/src/services/reusable_block.rs b/src/services/reusable_block.rs index cf8193aa..b181e018 100644 --- a/src/services/reusable_block.rs +++ b/src/services/reusable_block.rs @@ -81,3 +81,31 @@ pub async fn delete_reusable(pool: &crate::db::Pool, id: &str, auth: &AuthUser) .ok_or_else(|| AppError::not_found("reusable_block"))?; reusable_block::delete_reusable(pool, block.id, auth.tenant_id()).await } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_blocks_json_valid_empty() { + let blocks = validate_blocks_json("[]").unwrap(); + assert!(blocks.is_empty()); + } + + #[test] + fn validate_blocks_json_valid_block() { + let json = r#"[{"type":"richtext","content":"block content"}]"#; + let blocks = validate_blocks_json(json).unwrap(); + assert_eq!(blocks.len(), 1); + } + + #[test] + fn validate_blocks_json_invalid() { + assert!(validate_blocks_json("{bad").is_err()); + } + + #[test] + fn validate_blocks_json_wrong_type() { + assert!(validate_blocks_json(r#"[123]"#).is_err()); + } +} diff --git a/src/services/sms.rs b/src/services/sms.rs index bc710dbe..11918133 100644 --- a/src/services/sms.rs +++ b/src/services/sms.rs @@ -2,8 +2,8 @@ use chrono::Utc; -use crate::errors::app_error::{AppError, AppResult}; use crate::dto::LoginResponse; +use crate::errors::app_error::{AppError, AppResult}; use crate::middleware::auth::AuthUser; use crate::repositories::{RefreshTokenRepository, UserRepository}; diff --git a/src/services/tag.rs b/src/services/tag.rs index 309ff43f..7ced2922 100644 --- a/src/services/tag.rs +++ b/src/services/tag.rs @@ -2,8 +2,8 @@ use slug::slugify; -use crate::errors::app_error::{AppError, AppResult}; use crate::dto::CreateTagRequest; +use crate::errors::app_error::{AppError, AppResult}; use crate::middleware::auth::AuthUser; use crate::repositories::TagRepository; diff --git a/src/services/user.rs b/src/services/user.rs index 46adf6d3..7fd6e717 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -1,8 +1,8 @@ //! 用户资料管理服务。 use crate::commands::UpdateProfileCmd; -use crate::errors::app_error::{AppError, AppResult}; use crate::dto::{UpdateUserRequest, UserResponse}; +use crate::errors::app_error::{AppError, AppResult}; use crate::middleware::auth::AuthUser; use crate::repositories::UserRepository; diff --git a/src/services/workflow.rs b/src/services/workflow.rs index 4b47068d..9f3194dd 100644 --- a/src/services/workflow.rs +++ b/src/services/workflow.rs @@ -551,4 +551,112 @@ mod tests { assert_eq!(ctx["count"], 5); assert_eq!(ctx["name"], "test"); } + + #[test] + fn validate_steps_rejects_branch_with_non_array_next() { + let steps = vec![make_step("s1", "Branch", StepType::Branch, json!("s2"))]; + assert!(validate_steps(&steps).is_err()); + } + + #[test] + fn validate_steps_rejects_branch_unknown_ref() { + let steps = vec![make_step( + "s1", + "Branch", + StepType::Branch, + json!([{"condition": {"x": "y"}, "step": "s99"}]), + )]; + assert!(validate_steps(&steps).is_err()); + } + + #[test] + fn validate_steps_accepts_branch_valid() { + let steps = vec![ + make_step( + "s1", + "Branch", + StepType::Branch, + json!([{"condition": {"ok": true}, "step": "s2"}]), + ), + make_step("s2", "End", StepType::Task, json!(null)), + ]; + assert!(validate_steps(&steps).is_ok()); + } + + #[test] + fn validate_steps_rejects_parallel_with_non_array_next() { + let steps = vec![make_step("s1", "Par", StepType::Parallel, json!("s2"))]; + assert!(validate_steps(&steps).is_err()); + } + + #[test] + fn validate_steps_rejects_parallel_unknown_ref() { + let steps = vec![make_step("s1", "Par", StepType::Parallel, json!(["s99"]))]; + assert!(validate_steps(&steps).is_err()); + } + + #[test] + fn validate_steps_accepts_parallel_valid() { + let steps = vec![ + make_step("s1", "Par", StepType::Parallel, json!(["s2", "s3"])), + make_step("s2", "A", StepType::Task, json!(null)), + make_step("s3", "B", StepType::Task, json!(null)), + ]; + assert!(validate_steps(&steps).is_ok()); + } + + #[test] + fn validate_steps_accepts_empty_next_for_task() { + let steps = vec![make_step("s1", "Final", StepType::Task, json!(null))]; + assert!(validate_steps(&steps).is_ok()); + } + + #[test] + fn resolve_next_parallel_returns_first() { + let step = make_step("s1", "Par", StepType::Parallel, json!(["s2", "s3"])); + assert_eq!(resolve_next_step(&step, &json!({})), Some("s2".to_string())); + } + + #[test] + fn evaluate_condition_bool_match() { + let cond = json!({"active": true}); + let ctx = json!({"active": true, "name": "test"}); + assert!(evaluate_condition(&cond, &ctx)); + } + + #[test] + fn evaluate_condition_number_mismatch() { + let cond = json!({"count": 5}); + let ctx = json!({"count": 3}); + assert!(!evaluate_condition(&cond, &ctx)); + } + + #[test] + fn evaluate_condition_missing_field() { + let cond = json!({"missing": "value"}); + let ctx = json!({}); + assert!(!evaluate_condition(&cond, &ctx)); + } + + #[test] + fn evaluate_condition_non_object_returns_false() { + assert!(!evaluate_condition(&json!("string"), &json!({}))); + assert!(!evaluate_condition(&json!(42), &json!({}))); + } + + #[test] + fn merge_output_preserves_existing_keys() { + let mut ctx = json!({"a": 1, "b": 2}); + merge_output_into_context(&mut ctx, &json!({"b": 99, "c": 3})); + assert_eq!(ctx["a"], 1); + assert_eq!(ctx["b"], 99); + assert_eq!(ctx["c"], 3); + } + + #[test] + fn merge_output_non_object_context_noop() { + let mut ctx = json!("string"); + merge_output_into_context(&mut ctx, &json!({"a": 1})); + assert_eq!(ctx, json!("string")); + } } diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs index abd4c423..cf6d16af 100644 --- a/src/utils/crypto.rs +++ b/src/utils/crypto.rs @@ -1,5 +1,42 @@ //! 加密工具函数。 +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hmac_sha1_deterministic() { + let a = hmac_sha1_sign("key", "data"); + let b = hmac_sha1_sign("key", "data"); + assert_eq!(a, b); + } + + #[test] + fn hmac_sha1_different_keys() { + let a = hmac_sha1_sign("key1", "data"); + let b = hmac_sha1_sign("key2", "data"); + assert_ne!(a, b); + } + + #[test] + fn hmac_sha1_different_data() { + let a = hmac_sha1_sign("key", "data1"); + let b = hmac_sha1_sign("key", "data2"); + assert_ne!(a, b); + } + + #[test] + fn hmac_sha1_is_base64() { + let result = hmac_sha1_sign("secret", "message"); + use base64::Engine; + assert!( + base64::engine::general_purpose::STANDARD + .decode(&result) + .is_ok() + ); + } +} + /// 使用 HMAC-SHA1 签名(Base64 编码)。 /// /// key 会自动追加 `&` 后缀(OAuth 1.0 签名规范)。 diff --git a/src/utils/id.rs b/src/utils/id.rs index cad58541..1a43b993 100644 --- a/src/utils/id.rs +++ b/src/utils/id.rs @@ -1,5 +1,51 @@ //! ID 与时间戳生成工具 +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_document_id_is_valid_uuid_v7() { + let id = new_document_id(); + let parsed = uuid::Uuid::parse_str(&id).unwrap(); + assert_eq!(parsed.get_version_num(), 7); + } + + #[test] + fn new_document_id_and_timestamp_returns_both() { + let (id, ts) = new_document_id_and_timestamp(); + assert!(uuid::Uuid::parse_str(&id).is_ok()); + assert!(!ts.is_empty()); + } + + #[test] + fn new_id_and_timestamp_is_alias() { + let (id, ts) = new_id_and_timestamp(); + assert!(uuid::Uuid::parse_str(&id).is_ok()); + assert!(!ts.is_empty()); + } + + #[test] + fn random_hex_length() { + let hex = random_hex(16); + assert_eq!(hex.len(), 32); + assert!(hex.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn random_hex_uniqueness() { + let a = random_hex(32); + let b = random_hex(32); + assert_ne!(a, b); + } + + #[test] + fn random_hex_empty() { + let hex = random_hex(0); + assert_eq!(hex, ""); + } +} + /// 生成 document_id(UUID v7)和当前站点时区时间戳。 /// /// 用于 model 层的 `create` 函数。`id` 由数据库自增生成, diff --git a/tests/api.rs b/tests/api.rs index 12054fdb..a17a631f 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -19,9 +19,9 @@ use raisfast::cache::MemoryCache; use raisfast::config::app::AppConfig; use raisfast::handlers::{ api_token as h_token, auth as h_auth, category as h_cat, comment as h_cmt, cron as h_cron, - health as h_health, media as h_media, options as h_options, plugin as h_plugin, post as h_post, - rbac as h_rbac, rss as h_rss, sse as h_sse, stats as h_stats, tag as h_tag, tenant as h_tenant, - user as h_user, + health as h_health, media as h_media, options as h_options, page as h_page, plugin as h_plugin, + post as h_post, rbac as h_rbac, reusable_block as h_block, rss as h_rss, sse as h_sse, + stats as h_stats, tag as h_tag, tenant as h_tenant, user as h_user, }; use raisfast::middleware::locale::locale_middleware; use raisfast::middleware::rate_limit::{ @@ -269,6 +269,31 @@ pub(crate) async fn test_app() -> (axum::Router, AppState) { "/admin/workflows/instances/{id}/logs", get(raisfast::handlers::workflow::get_step_logs), ) + .route("/pages", get(h_page::list).post(h_page::create)) + .route( + "/pages/{slug}", + get(h_page::get_by_slug) + .put(h_page::update) + .delete(h_page::delete), + ) + .route("/admin/pages", get(h_page::admin_list)) + .route( + "/admin/pages/{id}", + get(h_page::admin_get) + .put(h_page::update) + .delete(h_page::delete), + ) + .route("/admin/pages/{id}/status", put(h_page::update_status)) + .route( + "/admin/reusable-blocks", + get(h_block::list_reusable).post(h_block::create_reusable), + ) + .route( + "/admin/reusable-blocks/{id}", + get(h_block::get_reusable) + .put(h_block::update_reusable) + .delete(h_block::delete_reusable), + ) .layer(from_fn(global_rate_limit)) .layer(axum::Extension(RateLimiterSet::new_default())); @@ -463,12 +488,16 @@ mod health; mod media; #[path = "api/options.rs"] mod options; +#[path = "api/page.rs"] +mod page; #[path = "api/plugin.rs"] mod plugin; #[path = "api/post.rs"] mod post; #[path = "api/rbac.rs"] mod rbac; +#[path = "api/reusable_block.rs"] +mod reusable_block; #[path = "api/rss.rs"] mod rss; #[path = "api/sse.rs"] diff --git a/tests/api/auth.rs b/tests/api/auth.rs index 1cdd4579..18cdbf50 100644 --- a/tests/api/auth.rs +++ b/tests/api/auth.rs @@ -171,3 +171,91 @@ async fn logout_without_token() { send(&mut app, post_json("/api/v1/auth/logout", json!({}))).await; assert_eq!(status, StatusCode::UNAUTHORIZED); } + +#[tokio::test] +async fn register_then_login_and_access_me() { + let (mut app, _) = test_app().await; + let (access, _) = register_and_login( + &mut app, + "lifecycle@test.com", + "lifecycleuser", + "Password123", + ) + .await; + + let (status, body) = send(&mut app, get_auth("/api/v1/users/me", &access)).await; + assert!(status.is_success(), "{status} {body:?}"); + assert_eq!(body["data"]["email"], "lifecycle@test.com"); + assert_eq!(body["data"]["username"], "lifecycleuser"); +} + +#[tokio::test] +async fn register_duplicate_username() { + let (mut app, _) = test_app().await; + let req_body = + json!({"email": "dupu1@test.com", "username": "dupuser", "password": "Password123"}); + let (s, _): (StatusCode, Value) = + send(&mut app, post_json("/api/v1/auth/register", req_body)).await; + assert!(s.is_success()); + + let (status, body): (StatusCode, Value) = send( + &mut app, + post_json( + "/api/v1/auth/register", + json!({"email": "dupu2@test.com", "username": "dupuser", "password": "Password123"}), + ), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(body["code"], 40900); +} + +#[tokio::test] +async fn refresh_token_after_password_change() { + let (mut app, _) = test_app().await; + let (access, refresh) = + register_and_login(&mut app, "rpc@test.com", "rpcuser", "OldPass123").await; + + let (status, _): (StatusCode, Value) = send( + &mut app, + put_json_auth( + "/api/v1/users/me/password", + json!({"old_password": "OldPass123", "new_password": "NewPass456"}), + &access, + ), + ) + .await; + assert!(status.is_success()); + + let (status, _): (StatusCode, Value) = send( + &mut app, + post_json("/api/v1/auth/refresh", json!({"refresh_token": refresh})), + ) + .await; + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "old refresh token should be invalidated after password change" + ); +} + +#[tokio::test] +async fn logout_invalidates_access() { + let (mut app, _) = test_app().await; + let (access, _) = + register_and_login(&mut app, "loinv@test.com", "loinvuser", "Password123").await; + + let (status, _): (StatusCode, Value) = send( + &mut app, + post_json_auth("/api/v1/auth/logout", json!({}), &access), + ) + .await; + assert!(status.is_success()); + + let (status, _): (StatusCode, Value) = + send(&mut app, get_auth("/api/v1/users/me", &access)).await; + assert!( + status.is_success(), + "JWT is stateless — still valid after logout" + ); +} diff --git a/tests/api/comment.rs b/tests/api/comment.rs index dba256f9..aa772f95 100644 --- a/tests/api/comment.rs +++ b/tests/api/comment.rs @@ -182,10 +182,11 @@ async fn update_status_admin() { ) .await; - let cid: String = sqlx::query_scalar("SELECT document_id FROM comments WHERE content = 'mod me'") - .fetch_one(&state.pool) - .await - .unwrap(); + let cid: String = + sqlx::query_scalar("SELECT document_id FROM comments WHERE content = 'mod me'") + .fetch_one(&state.pool) + .await + .unwrap(); let (admin_int_id, admin_doc_id) = create_admin(&state.pool).await; let admin_tok = make_token(&admin_doc_id, admin_int_id, "admin"); diff --git a/tests/api/page.rs b/tests/api/page.rs new file mode 100644 index 00000000..5728428e --- /dev/null +++ b/tests/api/page.rs @@ -0,0 +1,176 @@ +use super::*; + +async fn admin_author() -> (axum::Router, AppState, String) { + let (app, state) = test_app().await; + let (int_id, doc_id) = create_author(&state.pool).await; + let tok = make_token(&doc_id, int_id, "author"); + (app, state, tok) +} + +#[tokio::test] +async fn create_page_success() { + let (mut app, _, tok) = admin_author().await; + let (status, body) = send( + &mut app, + post_json_auth( + "/api/v1/pages", + json!({"title": "About Us", "content": "Our story", "status": "published"}), + &tok, + ), + ) + .await; + assert!(status.is_success(), "create page: {status} {body:?}"); + assert_eq!(body["data"]["title"], "About Us"); + assert!(body["data"]["slug"].is_string()); +} + +#[tokio::test] +async fn create_page_validation_empty_title() { + let (mut app, _, tok) = admin_author().await; + let (status, body) = send( + &mut app, + post_json_auth( + "/api/v1/pages", + json!({"title": "", "content": "c", "status": "published"}), + &tok, + ), + ) + .await; + assert!(!status.is_success(), "expected failure: {status} {body:?}"); +} + +#[tokio::test] +async fn list_published_pages() { + let (mut app, _, tok) = admin_author().await; + let _: (StatusCode, Value) = send( + &mut app, + post_json_auth( + "/api/v1/pages", + json!({"title": "Page1", "content": "c", "status": "published"}), + &tok, + ), + ) + .await; + let (status, body) = send(&mut app, get_req("/api/v1/pages")).await; + assert!(status.is_success(), "list pages: {status} {body:?}"); + let items = body["data"]["items"].as_array().unwrap(); + assert!(!items.is_empty()); +} + +#[tokio::test] +async fn get_page_by_slug() { + let (mut app, _, tok) = admin_author().await; + let (_, create_body) = send( + &mut app, + post_json_auth( + "/api/v1/pages", + json!({"title": "Contact", "content": "info", "status": "published"}), + &tok, + ), + ) + .await; + let slug = create_body["data"]["slug"].as_str().unwrap(); + let (status, body) = send(&mut app, get_req(&format!("/api/v1/pages/{slug}"))).await; + assert!(status.is_success(), "get page: {status} {body:?}"); + assert_eq!(body["data"]["title"], "Contact"); +} + +#[tokio::test] +async fn get_page_not_found() { + let (mut app, _, _) = admin_author().await; + let (status, _) = send(&mut app, get_req("/api/v1/pages/nonexistent")).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn update_page() { + let (mut app, _, tok) = admin_author().await; + let (_, create_body) = send( + &mut app, + post_json_auth( + "/api/v1/pages", + json!({"title": "Original", "content": "c", "status": "published"}), + &tok, + ), + ) + .await; + let doc_id = create_body["data"]["document_id"].as_str().unwrap(); + let (status, body) = send( + &mut app, + put_json_auth( + &format!("/api/v1/pages/{doc_id}"), + json!({"title": "Updated"}), + &tok, + ), + ) + .await; + assert!(status.is_success(), "update page: {status} {body:?}"); + assert_eq!(body["data"]["title"], "Updated"); +} + +#[tokio::test] +async fn delete_page() { + let (mut app, _, tok) = admin_author().await; + let (_, create_body) = send( + &mut app, + post_json_auth( + "/api/v1/pages", + json!({"title": "ToDelete", "content": "c", "status": "published"}), + &tok, + ), + ) + .await; + let doc_id = create_body["data"]["document_id"].as_str().unwrap(); + let slug = create_body["data"]["slug"].as_str().unwrap(); + let (status, _) = send( + &mut app, + delete_auth(&format!("/api/v1/pages/{doc_id}"), &tok), + ) + .await; + assert!(status.is_success()); + let (s, _) = send(&mut app, get_req(&format!("/api/v1/pages/{slug}"))).await; + assert_eq!(s, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn admin_list_pages() { + let (mut app, _, tok) = admin_author().await; + let _: (StatusCode, Value) = send( + &mut app, + post_json_auth( + "/api/v1/pages", + json!({"title": "Admin Page", "content": "c", "status": "draft"}), + &tok, + ), + ) + .await; + let (status, body) = send(&mut app, get_auth("/api/v1/admin/pages", &tok)).await; + assert!(status.is_success(), "admin list: {status} {body:?}"); + let items = body["data"]["items"].as_array().unwrap(); + assert!(!items.is_empty()); +} + +#[tokio::test] +async fn update_page_status() { + let (mut app, state, tok) = admin_author().await; + let (_, create_body) = send( + &mut app, + post_json_auth( + "/api/v1/pages", + json!({"title": "StatusTest", "content": "c", "status": "draft"}), + &tok, + ), + ) + .await; + let doc_id = create_body["data"]["document_id"].as_str().unwrap(); + let (status, body) = send( + &mut app, + put_json_auth( + &format!("/api/v1/admin/pages/{doc_id}/status"), + json!({"status": "published"}), + &tok, + ), + ) + .await; + assert!(status.is_success(), "update status: {status} {body:?}"); +} diff --git a/tests/api/post.rs b/tests/api/post.rs index 56aadfdd..2b591119 100644 --- a/tests/api/post.rs +++ b/tests/api/post.rs @@ -270,3 +270,142 @@ async fn filter_by_category() { let cat_int: i64 = c.cat_id.parse().unwrap(); assert!(items.iter().all(|p| p["category_id"] == cat_int)); } + +#[tokio::test] +async fn filter_by_status() { + let mut c = setup().await; + let _: (StatusCode, Value) = send( + &mut c.app, + post_json_auth( + "/api/v1/posts", + json!({"title": "Published1", "content": "c", "status": "published", "category_id": c.cat_id}), + &c.tok, + ), + ) + .await; + let _: (StatusCode, Value) = send( + &mut c.app, + post_json_auth( + "/api/v1/posts", + json!({"title": "Draft1", "content": "c", "status": "draft", "category_id": c.cat_id}), + &c.tok, + ), + ) + .await; + + let (_, body): (StatusCode, Value) = + send(&mut c.app, get_req("/api/v1/posts?status=published")).await; + let items = body["data"]["items"].as_array().unwrap(); + assert!( + items.iter().all(|p| p["status"] == "published"), + "expected all published, got: {items:?}" + ); +} + +#[tokio::test] +async fn filter_by_tag() { + let mut c = setup().await; + let (_, tb): (StatusCode, Value) = send( + &mut c.app, + post_json_auth("/api/v1/tags", json!({"name": "go"}), &c.tok), + ) + .await; + let tag_go = tb["data"]["id"].as_i64().unwrap().to_string(); + + let _: (StatusCode, Value) = send( + &mut c.app, + post_json_auth( + "/api/v1/posts", + json!({"title": "RustPost", "content": "c", "status": "published", "tag_ids": [c.tag_id]}), + &c.tok, + ), + ) + .await; + let _: (StatusCode, Value) = send( + &mut c.app, + post_json_auth( + "/api/v1/posts", + json!({"title": "GoPost", "content": "c", "status": "published", "tag_ids": [&tag_go]}), + &c.tok, + ), + ) + .await; + + let (_, body): (StatusCode, Value) = send( + &mut c.app, + get_req(&format!("/api/v1/posts?tag_id={}", c.tag_id)), + ) + .await; + let items = body["data"]["items"].as_array().unwrap(); + assert_eq!(items.len(), 1, "expected 1 post, got: {items:?}"); + assert_eq!(items[0]["title"], "RustPost"); +} + +#[tokio::test] +async fn update_changes_category() { + let mut c = setup().await; + let (_, cb): (StatusCode, Value) = send( + &mut c.app, + post_json_auth("/api/v1/categories", json!({"name": "CatB"}), &c.tok), + ) + .await; + let cat_b = cb["data"]["id"].as_i64().unwrap().to_string(); + + let slug = create_published_post(&mut c.app, &c.tok).await; + let (status, body): (StatusCode, Value) = send( + &mut c.app, + put_json_auth( + &format!("/api/v1/posts/{slug}"), + json!({"category_id": &cat_b}), + &c.tok, + ), + ) + .await; + assert!(status.is_success(), "{status} {body:?}"); + assert_eq!(body["data"]["category_id"], cat_b.parse::().unwrap()); +} + +#[tokio::test] +async fn create_with_excerpt() { + let mut c = setup().await; + let (status, body): (StatusCode, Value) = send( + &mut c.app, + post_json_auth( + "/api/v1/posts", + json!({ + "title": "ExcerptPost", + "content": "Some content here", + "status": "published", + "excerpt": "Custom excerpt text", + "category_id": c.cat_id, + }), + &c.tok, + ), + ) + .await; + assert!(status.is_success(), "{status} {body:?}"); + assert_eq!(body["data"]["excerpt"], "Custom excerpt text"); +} + +#[tokio::test] +async fn list_sorting_by_created_at() { + let mut c = setup().await; + let uid = uuid::Uuid::now_v7().to_string(); + for i in 1..=3u8 { + let (status, body): (StatusCode, Value) = send( + &mut c.app, + post_json_auth( + "/api/v1/posts", + json!({"title": format!("Sort{uid}{i}"), "content": "c", "status": "published"}), + &c.tok, + ), + ) + .await; + assert!(status.is_success(), "create post {i}: {status} {body:?}"); + } + + let (status, body): (StatusCode, Value) = send(&mut c.app, get_req("/api/v1/posts")).await; + assert!(status.is_success(), "list: {status} {body:?}"); + let total = body["data"]["total"].as_i64().unwrap_or(0); + assert!(total >= 3, "expected total >= 3, got {total}"); +} diff --git a/tests/api/reusable_block.rs b/tests/api/reusable_block.rs new file mode 100644 index 00000000..a4137c26 --- /dev/null +++ b/tests/api/reusable_block.rs @@ -0,0 +1,165 @@ +use super::*; + +async fn admin_setup() -> (axum::Router, AppState, String) { + let (app, state) = test_app().await; + let (int_id, doc_id) = create_author(&state.pool).await; + let tok = make_token(&doc_id, int_id, "author"); + (app, state, tok) +} + +fn block_content() -> serde_json::Value { + json!([{"type": "richtext", "content": "Hello world"}]) +} + +#[tokio::test] +async fn create_reusable_block_success() { + let (mut app, _, tok) = admin_setup().await; + let (status, body) = send( + &mut app, + post_json_auth( + "/api/v1/admin/reusable-blocks", + json!({ + "name": "Hero Banner", + "block_type": "hero", + "content": block_content().to_string() + }), + &tok, + ), + ) + .await; + assert!(status.is_success(), "create block: {status} {body:?}"); + assert_eq!(body["data"]["name"], "Hero Banner"); + assert!(body["data"]["document_id"].is_string()); +} + +#[tokio::test] +async fn create_block_validation_empty_name() { + let (mut app, _, tok) = admin_setup().await; + let (status, _) = send( + &mut app, + post_json_auth( + "/api/v1/admin/reusable-blocks", + json!({"name": "", "block_type": "hero", "content": "[]"}), + &tok, + ), + ) + .await; + assert!(!status.is_success()); +} + +#[tokio::test] +async fn list_reusable_blocks() { + let (mut app, _, tok) = admin_setup().await; + let _: (StatusCode, Value) = send( + &mut app, + post_json_auth( + "/api/v1/admin/reusable-blocks", + json!({"name": "Block1", "block_type": "text", "content": "[]"}), + &tok, + ), + ) + .await; + let (status, body) = send(&mut app, get_auth("/api/v1/admin/reusable-blocks", &tok)).await; + assert!(status.is_success(), "list blocks: {status} {body:?}"); + let items = body["data"].as_array().unwrap(); + assert!(!items.is_empty()); +} + +#[tokio::test] +async fn get_reusable_block_by_id() { + let (mut app, _, tok) = admin_setup().await; + let (_, create_body) = send( + &mut app, + post_json_auth( + "/api/v1/admin/reusable-blocks", + json!({"name": "CTA", "block_type": "cta", "content": "[]"}), + &tok, + ), + ) + .await; + let id = create_body["data"]["document_id"].as_str().unwrap(); + let (status, body) = send( + &mut app, + get_auth(&format!("/api/v1/admin/reusable-blocks/{id}"), &tok), + ) + .await; + assert!(status.is_success(), "get block: {status} {body:?}"); + assert_eq!(body["data"]["name"], "CTA"); +} + +#[tokio::test] +async fn get_reusable_block_not_found() { + let (mut app, _, tok) = admin_setup().await; + let fake = uuid::Uuid::now_v7().to_string(); + let (status, _) = send( + &mut app, + get_auth(&format!("/api/v1/admin/reusable-blocks/{fake}"), &tok), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn update_reusable_block() { + let (mut app, _, tok) = admin_setup().await; + let (_, create_body) = send( + &mut app, + post_json_auth( + "/api/v1/admin/reusable-blocks", + json!({"name": "Old", "block_type": "text", "content": "[]"}), + &tok, + ), + ) + .await; + let id = create_body["data"]["document_id"].as_str().unwrap(); + let (status, body) = send( + &mut app, + put_json_auth( + &format!("/api/v1/admin/reusable-blocks/{id}"), + json!({"name": "New", "description": "updated"}), + &tok, + ), + ) + .await; + assert!(status.is_success(), "update block: {status} {body:?}"); + assert_eq!(body["data"]["name"], "New"); +} + +#[tokio::test] +async fn delete_reusable_block() { + let (mut app, _, tok) = admin_setup().await; + let (_, create_body) = send( + &mut app, + post_json_auth( + "/api/v1/admin/reusable-blocks", + json!({"name": "Delete", "block_type": "text", "content": "[]"}), + &tok, + ), + ) + .await; + let id = create_body["data"]["document_id"].as_str().unwrap(); + let (status, _) = send( + &mut app, + delete_auth(&format!("/api/v1/admin/reusable-blocks/{id}"), &tok), + ) + .await; + assert!(status.is_success()); + let (s, _) = send( + &mut app, + get_auth(&format!("/api/v1/admin/reusable-blocks/{id}"), &tok), + ) + .await; + assert_eq!(s, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn delete_reusable_block_not_found() { + let (mut app, _, tok) = admin_setup().await; + let fake = uuid::Uuid::now_v7().to_string(); + let (status, _) = send( + &mut app, + delete_auth(&format!("/api/v1/admin/reusable-blocks/{fake}"), &tok), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); +} diff --git a/tests/api/user.rs b/tests/api/user.rs index 7aac08a8..fa3927da 100644 --- a/tests/api/user.rs +++ b/tests/api/user.rs @@ -119,3 +119,82 @@ async fn list_users_admin_only() { assert!(body["data"]["items"].is_array()); assert!(body["data"]["total"].is_number()); } + +#[tokio::test] +async fn update_me_with_bio_and_website() { + let (mut app, _) = test_app().await; + let (access, _) = + register_and_login(&mut app, "bioweb@test.com", "biowebuser", "Password123").await; + let (status, body): (StatusCode, Value) = send( + &mut app, + put_json_auth( + "/api/v1/users/me", + json!({"bio": "I write Rust", "website": "https://rust-lang.org"}), + &access, + ), + ) + .await; + assert!(status.is_success(), "{status} {body:?}"); + assert_eq!(body["data"]["bio"], "I write Rust"); + assert_eq!(body["data"]["website"], "https://rust-lang.org"); +} + +#[tokio::test] +async fn change_password_too_short() { + let (mut app, _) = test_app().await; + let (access, _) = + register_and_login(&mut app, "short@test.com", "shortuser", "Password123").await; + let (status, body): (StatusCode, Value) = send( + &mut app, + put_json_auth( + "/api/v1/users/me/password", + json!({"old_password": "Password123", "new_password": "abc"}), + &access, + ), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{status} {body:?}"); +} + +#[tokio::test] +async fn admin_can_update_role() { + let (mut app, state) = test_app().await; + let admin_id = create_admin(&state.pool).await; + let admin_token = make_token(&admin_id.1, admin_id.0, "admin"); + let _ = register_and_login(&mut app, "roleuser@test.com", "roleuser", "Password123").await; + let reader_id: String = + sqlx::query_scalar("SELECT document_id FROM users WHERE email = 'roleuser@test.com'") + .fetch_one(&state.pool) + .await + .unwrap(); + let (status, body): (StatusCode, Value) = send( + &mut app, + put_json_auth( + &format!("/api/v1/users/{reader_id}/role"), + json!({"role": "author"}), + &admin_token, + ), + ) + .await; + assert!(status.is_success(), "{status} {body:?}"); + assert_eq!(body["data"]["role"], "author"); +} + +#[tokio::test] +async fn get_user_by_id_returns_public_info() { + let (mut app, state) = test_app().await; + let _ = register_and_login(&mut app, "pubinfo@test.com", "pubinfouser", "Password123").await; + let user_id: String = + sqlx::query_scalar("SELECT document_id FROM users WHERE email = 'pubinfo@test.com'") + .fetch_one(&state.pool) + .await + .unwrap(); + let (status, body): (StatusCode, Value) = + send(&mut app, get_req(&format!("/api/v1/users/{user_id}"))).await; + assert!(status.is_success()); + assert_eq!(body["data"]["id"], user_id); + assert_eq!(body["data"]["email"], "pubinfo@test.com"); + assert_eq!(body["data"]["username"], "pubinfouser"); + assert!(body["data"]["created_at"].is_string()); + assert!(body["data"]["updated_at"].is_string()); +}