improve testing code

This commit is contained in:
zhongqin
2026-05-09 00:30:34 +08:00
parent 425d4342c6
commit 3da80826ab
62 changed files with 3972 additions and 67 deletions
+95
View File
@@ -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<dyn CacheStore> = 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<dyn CacheStore> = 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);
}
}
+13 -15
View File
@@ -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));
}
_ => {}
+4 -1
View File
@@ -109,7 +109,10 @@ pub fn generate_junction_tables(ct: &ContentTypeSchema) -> Vec<String> {
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
+1 -3
View File
@@ -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() {
+1 -3
View File
@@ -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 => {
+75
View File
@@ -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());
}
}
+38
View File
@@ -25,3 +25,41 @@ pub struct UpdateCategoryRequest {
pub parent_id: Option<String>,
pub sort_order: Option<i64>,
}
#[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());
}
}
+43
View File
@@ -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());
}
}
+29
View File
@@ -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());
}
}
+120
View File
@@ -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\""));
}
}
+43
View File
@@ -267,3 +267,46 @@ impl From<sqlx::Error> for AppError {
/// 所有 handler 和 service 函数的返回类型统一使用 `AppResult<T>`,
/// 等价于 `Result<T, AppError>`,简化函数签名并保证错误处理的一致性。
pub type AppResult<T> = Result<T, AppError>;
#[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"),
}
}
}
+1 -1
View File
@@ -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;
+3 -3
View File
@@ -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};
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+1 -3
View File
@@ -101,7 +101,5 @@ pub async fn stats(
State(state): State<crate::AppState>,
) -> AppResult<ApiResponse<crate::dto::MediaStatsResponse>> {
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)))
}
+5 -8
View File
@@ -21,7 +21,9 @@ async fn resolve_page_parent_id(
pool: &crate::db::Pool,
parent_id: Option<String>,
) -> AppResult<Option<i64>> {
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,
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+1 -3
View File
@@ -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;
+1 -1
View File
@@ -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;
+107
View File
@@ -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());
}
}
+176
View File
@@ -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);
}
}
}
+85
View File
@@ -114,3 +114,88 @@ pub async fn cleanup_expired(pool: &crate::db::Pool) -> AppResult<u64> {
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);
}
}
+110
View File
@@ -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());
}
}
+210
View File
@@ -278,3 +278,213 @@ pub async fn count_by_user(pool: &crate::db::Pool, user_id: i64) -> AppResult<i6
.await?;
Ok(count)
}
#[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_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);
}
}
+113
View File
@@ -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));
}
}
+140
View File
@@ -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());
}
}
+103
View File
@@ -115,3 +115,106 @@ pub async fn cleanup_expired(pool: &crate::db::Pool) -> AppResult<u64> {
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<String>,) = 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);
}
}
+79
View File
@@ -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());
}
}
+129
View File
@@ -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);
}
}
+67
View File
@@ -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());
}
}
+115
View File
@@ -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());
}
}
+122
View File
@@ -200,3 +200,125 @@ pub async fn cleanup_expired(pool: &crate::db::Pool) -> AppResult<u64> {
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);
}
}
+86
View File
@@ -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());
}
}
+128
View File
@@ -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());
}
}
+135
View File
@@ -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");
}
}
+200
View File
@@ -401,3 +401,203 @@ pub async fn list_step_logs(pool: &Pool, instance_id: i64) -> anyhow::Result<Vec
.await?;
Ok(rows)
}
#[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
}
#[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());
}
}
+2 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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);
+32
View File
@@ -137,3 +137,35 @@ pub fn get_str(record: &Record, key: &str) -> Option<String> {
.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);
}
}
+8 -4
View File
@@ -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())
+1 -1
View File
@@ -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;
+225
View File
@@ -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"<html>"));
}
#[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"<svg>"));
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);
}
}
+50 -1
View File
@@ -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");
}
}
+56
View File
@@ -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());
}
}
+27 -4
View File
@@ -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("..."));
}
}
+81
View File
@@ -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");
}
}
+28
View File
@@ -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());
}
}
+1 -1
View File
@@ -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};
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+108
View File
@@ -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"));
}
}
+37
View File
@@ -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 签名规范)。
+46
View File
@@ -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` 由数据库自增生成,
+32 -3
View File
@@ -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"]
+88
View File
@@ -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"
);
}
+5 -4
View File
@@ -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");
+176
View File
@@ -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:?}");
}
+139
View File
@@ -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::<i64>().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}");
}
+165
View File
@@ -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);
}
+79
View File
@@ -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());
}