Files
zclaw_openfang/crates/zclaw-saas/tests/knowledge_test.rs
iven 7de486bfca
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
test(saas): Phase 1 integration tests — billing + scheduled_task + knowledge (68 tests)
- Fix TIMESTAMPTZ decode errors: add ::TEXT cast to all SELECT queries
  where Row structs use String for TIMESTAMPTZ columns (~22 locations)
- Fix Axum 0.7 route params: {id} → :id in billing/knowledge/scheduled_task routes
- Fix JSONB bind: scheduled_task INSERT uses ::jsonb cast for input_payload
- Add billing_test.rs (14 tests): plans, subscription, usage, payments, invoices
- Add scheduled_task_test.rs (12 tests): CRUD, validation, isolation
- Add knowledge_test.rs (20 tests): categories, items, versions, search, analytics, permissions
- Fix auth test regression: 6 tests were failing due to TIMESTAMPTZ type mismatch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 14:25:34 +08:00

434 lines
15 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 知识库模块集成测试
//!
//! 覆盖 knowledge 模块的分类/条目/版本/检索/分析端点。
//! 需要 super_admin 权限knowledge:read/write/admin/search
mod common;
use common::*;
use axum::http::StatusCode;
use axum::Router;
use sqlx::PgPool;
/// 辅助:创建 super_admin token知识库需要 knowledge:* 权限,仅 super_admin 有 admin:full
async fn setup_admin(app: &Router, pool: &sqlx::PgPool) -> String {
super_admin_token(app, pool, "kb_admin").await
}
/// 辅助:创建一个分类
async fn create_category(app: &Router, token: &str, name: &str) -> String {
let (status, body) = send(app, post("/api/v1/knowledge/categories", token,
serde_json::json!({ "name": name, "description": "测试分类" })
)).await;
assert_eq!(status, StatusCode::OK, "create_category failed: {body}");
body["id"].as_str().unwrap().to_string()
}
/// 辅助:创建一个知识条目
async fn create_item(app: &Router, token: &str, category_id: &str, title: &str) -> String {
let (status, body) = send(app, post("/api/v1/knowledge/items", token,
serde_json::json!({
"category_id": category_id,
"title": title,
"content": format!("这是 {} 的内容", title),
"keywords": ["测试"],
"tags": ["test"]
})
)).await;
assert_eq!(status, StatusCode::OK, "create_item failed: {body}");
body["id"].as_str().unwrap().to_string()
}
// ── 分类管理 ───────────────────────────────────────────────────
#[tokio::test]
async fn test_create_category() {
let (app, pool) = build_test_app().await;
let token = setup_admin(&app, &pool).await;
let (status, body) = send(&app, post("/api/v1/knowledge/categories", &token,
serde_json::json!({ "name": "技术文档", "description": "技术相关文档", "icon": "📚" })
)).await;
assert_eq!(status, StatusCode::OK, "create_category failed: {body}");
assert!(body["id"].is_string(), "missing id");
assert_eq!(body["name"], "技术文档");
}
#[tokio::test]
async fn test_create_category_empty_name() {
let (app, pool) = build_test_app().await;
let token = setup_admin(&app, &pool).await;
let (status, _) = send(&app, post("/api/v1/knowledge/categories", &token,
serde_json::json!({ "name": " ", "description": "test" })
)).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "should reject empty name");
}
#[tokio::test]
async fn test_list_categories() {
let (app, pool) = build_test_app().await;
let token = setup_admin(&app, &pool).await;
// 创建两个分类
create_category(&app, &token, "分类A").await;
create_category(&app, &token, "分类B").await;
let (status, body) = send(&app, get("/api/v1/knowledge/categories", &token)).await;
assert_eq!(status, StatusCode::OK, "list_categories failed: {body}");
let arr = body.as_array().expect("should be array");
assert!(arr.len() >= 2, "expected >= 2 categories, got {}", arr.len());
}
#[tokio::test]
async fn test_update_category() {
let (app, pool) = build_test_app().await;
let token = setup_admin(&app, &pool).await;
let cat_id = create_category(&app, &token, "旧名称").await;
let (status, body) = send(&app, put(
&format!("/api/v1/knowledge/categories/{}", cat_id),
&token,
serde_json::json!({ "name": "新名称", "description": "更新后" }),
)).await;
assert_eq!(status, StatusCode::OK, "update_category failed: {body}");
assert_eq!(body["name"], "新名称");
assert_eq!(body["updated"], true);
}
#[tokio::test]
async fn test_delete_category() {
let (app, pool) = build_test_app().await;
let token = setup_admin(&app, &pool).await;
let cat_id = create_category(&app, &token, "待删除分类").await;
let (status, body) = send(&app, delete(
&format!("/api/v1/knowledge/categories/{}", cat_id),
&token,
)).await;
assert_eq!(status, StatusCode::OK, "delete_category failed: {body}");
assert_eq!(body["deleted"], true);
}
#[tokio::test]
async fn test_reorder_categories() {
let (app, pool) = build_test_app().await;
let token = setup_admin(&app, &pool).await;
let cat_a = create_category(&app, &token, "分类A").await;
let cat_b = create_category(&app, &token, "分类B").await;
let (status, body) = send(&app, patch(
"/api/v1/knowledge/categories/reorder",
&token,
serde_json::json!([
{ "id": cat_a, "sort_order": 2 },
{ "id": cat_b, "sort_order": 1 }
]),
)).await;
assert_eq!(status, StatusCode::OK, "reorder failed: {body}");
assert_eq!(body["reordered"], true);
assert_eq!(body["count"], 2);
}
// ── 知识条目 CRUD ──────────────────────────────────────────────
#[tokio::test]
async fn test_create_item() {
let (app, pool) = build_test_app().await;
let token = setup_admin(&app, &pool).await;
let cat_id = create_category(&app, &token, "条目测试分类").await;
let item_id = create_item(&app, &token, &cat_id, "测试条目").await;
assert!(!item_id.is_empty(), "item id should not be empty");
}
#[tokio::test]
async fn test_create_item_validation() {
let (app, pool) = build_test_app().await;
let token = setup_admin(&app, &pool).await;
let cat_id = create_category(&app, &token, "验证分类").await;
// 空标题
let (status, _) = send(&app, post("/api/v1/knowledge/items", &token,
serde_json::json!({
"category_id": cat_id,
"title": " ",
"content": "有内容"
})
)).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "should reject empty title");
// 空内容
let (status, _) = send(&app, post("/api/v1/knowledge/items", &token,
serde_json::json!({
"category_id": cat_id,
"title": "有标题",
"content": ""
})
)).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "should reject empty content");
}
#[tokio::test]
async fn test_list_items() {
let (app, pool) = build_test_app().await;
let token = setup_admin(&app, &pool).await;
let cat_id = create_category(&app, &token, "列表分类").await;
create_item(&app, &token, &cat_id, "条目1").await;
create_item(&app, &token, &cat_id, "条目2").await;
let (status, body) = send(&app, get(
&format!("/api/v1/knowledge/items?category_id={}&page=1&page_size=10", cat_id),
&token,
)).await;
assert_eq!(status, StatusCode::OK, "list_items failed: {body}");
let items = body["items"].as_array().expect("items should be array");
assert!(items.len() >= 2, "expected >= 2 items");
assert!(body["total"].is_number(), "missing total");
}
#[tokio::test]
async fn test_get_item() {
let (app, pool) = build_test_app().await;
let token = setup_admin(&app, &pool).await;
let cat_id = create_category(&app, &token, "获取分类").await;
let item_id = create_item(&app, &token, &cat_id, "获取测试条目").await;
let (status, body) = send(&app, get(
&format!("/api/v1/knowledge/items/{}", item_id),
&token,
)).await;
assert_eq!(status, StatusCode::OK, "get_item failed: {body}");
assert_eq!(body["id"], item_id);
assert_eq!(body["title"], "获取测试条目");
assert!(body["content"].is_string());
}
#[tokio::test]
async fn test_update_item() {
let (app, pool) = build_test_app().await;
let token = setup_admin(&app, &pool).await;
let cat_id = create_category(&app, &token, "更新分类").await;
let item_id = create_item(&app, &token, &cat_id, "原始标题").await;
let (status, body) = send(&app, put(
&format!("/api/v1/knowledge/items/{}", item_id),
&token,
serde_json::json!({
"title": "更新后标题",
"content": "更新后的内容",
"change_summary": "修改标题和内容"
}),
)).await;
assert_eq!(status, StatusCode::OK, "update_item failed: {body}");
assert_eq!(body["id"], item_id);
// 更新后 version 应该增加
assert!(body["version"].as_i64().unwrap() >= 2, "version should increment after update");
}
#[tokio::test]
async fn test_delete_item() {
let (app, pool) = build_test_app().await;
let token = setup_admin(&app, &pool).await;
let cat_id = create_category(&app, &token, "删除分类").await;
let item_id = create_item(&app, &token, &cat_id, "待删除条目").await;
let (status, body) = send(&app, delete(
&format!("/api/v1/knowledge/items/{}", item_id),
&token,
)).await;
assert_eq!(status, StatusCode::OK, "delete_item failed: {body}");
assert_eq!(body["deleted"], true);
}
// ── 批量操作 ───────────────────────────────────────────────────
#[tokio::test]
async fn test_batch_create_items() {
let (app, pool) = build_test_app().await;
let token = setup_admin(&app, &pool).await;
let cat_id = create_category(&app, &token, "批量分类").await;
let items: Vec<serde_json::Value> = (1..=3).map(|i| {
serde_json::json!({
"category_id": cat_id,
"title": format!("批量条目{}", i),
"content": format!("批量内容{}", i),
"keywords": ["batch"]
})
}).collect();
let (status, body) = send(&app, post(
"/api/v1/knowledge/items/batch",
&token,
serde_json::json!(items),
)).await;
assert_eq!(status, StatusCode::OK, "batch_create failed: {body}");
assert_eq!(body["created_count"], 3);
assert!(body["ids"].as_array().unwrap().len() == 3);
}
#[tokio::test]
async fn test_import_items() {
let (app, pool) = build_test_app().await;
let token = setup_admin(&app, &pool).await;
let cat_id = create_category(&app, &token, "导入分类").await;
let (status, body) = send(&app, post(
"/api/v1/knowledge/items/import",
&token,
serde_json::json!({
"category_id": cat_id,
"files": [
{
"content": "# 导入文档1\n这是第一个文档的内容",
"keywords": ["import"],
"tags": ["docs"]
},
{
"title": "自定义标题",
"content": "第二个文档的内容",
}
]
}),
)).await;
assert_eq!(status, StatusCode::OK, "import failed: {body}");
assert_eq!(body["created_count"], 2);
}
// ── 版本控制 ───────────────────────────────────────────────────
#[tokio::test]
async fn test_list_versions() {
let (app, pool) = build_test_app().await;
let token = setup_admin(&app, &pool).await;
let cat_id = create_category(&app, &token, "版本分类").await;
let item_id = create_item(&app, &token, &cat_id, "版本测试").await;
// 更新一次产生 v2
let _ = send(&app, put(
&format!("/api/v1/knowledge/items/{}", item_id),
&token,
serde_json::json!({ "content": "v2 content", "change_summary": "第二次修改" }),
)).await;
let (status, body) = send(&app, get(
&format!("/api/v1/knowledge/items/{}/versions", item_id),
&token,
)).await;
assert_eq!(status, StatusCode::OK, "list_versions failed: {body}");
let versions = body["versions"].as_array().expect("versions should be array");
assert!(versions.len() >= 2, "expected >= 2 versions, got {}", versions.len());
}
#[tokio::test]
async fn test_rollback_version() {
let (app, pool) = build_test_app().await;
let token = setup_admin(&app, &pool).await;
let cat_id = create_category(&app, &token, "回滚分类").await;
let item_id = create_item(&app, &token, &cat_id, "回滚测试").await;
// 更新一次产生 v2
let _ = send(&app, put(
&format!("/api/v1/knowledge/items/{}", item_id),
&token,
serde_json::json!({ "content": "v2 content" }),
)).await;
// 回滚到 v1
let (status, body) = send(&app, post(
&format!("/api/v1/knowledge/items/{}/rollback/1", item_id),
&token,
serde_json::json!({}),
)).await;
assert_eq!(status, StatusCode::OK, "rollback failed: {body}");
assert_eq!(body["rolled_back_to"], 1);
}
// ── 检索 ───────────────────────────────────────────────────────
#[tokio::test]
async fn test_search() {
let (app, pool) = build_test_app().await;
let token = setup_admin(&app, &pool).await;
// 搜索应返回空结果(无 embedding但不应报错
let (status, body) = send(&app, post(
"/api/v1/knowledge/search",
&token,
serde_json::json!({ "query": "测试搜索", "limit": 5 }),
)).await;
// 搜索可能返回 200空结果或 500pgvector 不可用)
// 不强制要求 200只要不是 panic
assert!(
status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR,
"search should not panic: status={}, body={}", status, body
);
}
// ── 分析看板 ───────────────────────────────────────────────────
#[tokio::test]
async fn test_analytics_overview() {
let (app, pool) = build_test_app().await;
let token = setup_admin(&app, &pool).await;
let cat_id = create_category(&app, &token, "分析分类").await;
create_item(&app, &token, &cat_id, "分析条目").await;
let (status, body) = send(&app, get(
"/api/v1/knowledge/analytics/overview",
&token,
)).await;
assert_eq!(status, StatusCode::OK, "analytics_overview failed: {body}");
assert!(body["total_items"].is_number());
assert!(body["active_items"].is_number());
assert!(body["total_categories"].is_number());
}
#[tokio::test]
async fn test_analytics_trends() {
let (app, pool) = build_test_app().await;
let token = setup_admin(&app, &pool).await;
let (status, body) = send(&app, get(
"/api/v1/knowledge/analytics/trends",
&token,
)).await;
assert_eq!(status, StatusCode::OK, "analytics_trends failed: {body}");
assert!(body["trends"].is_array());
}
// ── 权限验证 ───────────────────────────────────────────────────
#[tokio::test]
async fn test_permission_read_only_user() {
let (app, _pool) = build_test_app().await;
// 普通用户没有 knowledge:read 权限
let token = register_token(&app, "kb_noperm_user").await;
let (status, _) = send(&app, get("/api/v1/knowledge/categories", &token)).await;
assert_eq!(status, StatusCode::FORBIDDEN, "普通用户不应访问知识库");
let (status, _) = send(&app, post("/api/v1/knowledge/categories", &token,
serde_json::json!({ "name": "不应成功" })
)).await;
assert_eq!(status, StatusCode::FORBIDDEN, "普通用户不应创建分类");
}