//! 知识库模块集成测试 //! //! 覆盖 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 = (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(空结果)或 500(pgvector 不可用) // 不强制要求 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, "普通用户不应创建分类"); }