Files
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

283 lines
10 KiB
Rust

//! E2E tests for the multi-companion REST surface: `/api/companion/companions*` CRUD and the
//! per-companion companion thread routes (T2.1).
//!
//! The companion roster persists on disk under the shared test data dir, so the
//! assertions are id-scoped (find-by-id / 404-after-delete) and never assume
//! an empty roster or absolute counts.
mod common;
use axum::http::StatusCode;
use serde_json::json;
use tower::ServiceExt;
use common::{body_json, build_app, delete_with_token, get_with_token, json_with_token, setup_and_login};
/// POST /api/companion/companions and return the created profile JSON (asserts 201).
async fn create_companion(
app: &axum::Router,
token: &str,
csrf: &str,
name: &str,
character: &str,
) -> serde_json::Value {
let req = json_with_token(
"POST",
"/api/companion/companions",
json!({ "name": name, "character": character }),
token,
csrf,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
json["data"].clone()
}
// ── companions CRUD ─────────────────────────────────────────────────────────
#[tokio::test]
async fn companions_crud_happy_path() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
// Create.
let created = create_companion(&app, &token, &csrf, "毛球", "ink").await;
let id = created["id"].as_str().unwrap().to_owned();
assert!(id.starts_with("companion_"));
assert_eq!(created["name"], "毛球");
assert_eq!(created["character"], "ink");
// List: profile fields flattened + embedded status.
let resp = app
.clone()
.oneshot(get_with_token("/api/companion/companions", &token))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let list = body_json(resp).await;
let entry = list["data"]
.as_array()
.unwrap()
.iter()
.find(|p| p["id"] == id.as_str())
.expect("created companion should appear in the list")
.clone();
assert_eq!(entry["name"], "毛球");
assert_eq!(entry["status"]["companion_id"], id.as_str());
assert!(entry["status"]["level"].as_i64().unwrap() >= 1);
// Detail: same flattened shape.
let resp = app
.clone()
.oneshot(get_with_token(&format!("/api/companion/companions/{id}"), &token))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let detail = body_json(resp).await;
assert_eq!(detail["data"]["id"], id.as_str());
assert_eq!(detail["data"]["character"], "ink");
assert_eq!(detail["data"]["status"]["companion_id"], id.as_str());
// RFC 7396 patch: rename + nested appearance merge.
let req = json_with_token(
"PATCH",
&format!("/api/companion/companions/{id}"),
json!({ "name": "新名", "appearance": { "companion_enabled": true } }),
&token,
&csrf,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let patched = body_json(resp).await;
assert_eq!(patched["data"]["id"], id.as_str());
assert_eq!(patched["data"]["name"], "新名");
assert_eq!(patched["data"]["appearance"]["companion_enabled"], true);
// Untouched field survives the merge.
assert_eq!(patched["data"]["character"], "ink");
// Per-companion status endpoint.
let resp = app
.clone()
.oneshot(get_with_token(&format!("/api/companion/companions/{id}/status"), &token))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let status = body_json(resp).await;
assert_eq!(status["data"]["companion_id"], id.as_str());
// Delete: 204 and the companion is gone.
let resp = app
.clone()
.oneshot(delete_with_token(&format!("/api/companion/companions/{id}"), &token, &csrf))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let resp = app
.clone()
.oneshot(get_with_token(&format!("/api/companion/companions/{id}"), &token))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn companions_unknown_id_is_404_and_bad_name_is_400() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
// 404 on every per-companion verb for an unknown id.
let resp = app
.clone()
.oneshot(get_with_token("/api/companion/companions/companion_missing", &token))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let resp = app
.clone()
.oneshot(get_with_token("/api/companion/companions/companion_missing/status", &token))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let req = json_with_token(
"PATCH",
"/api/companion/companions/companion_missing",
json!({ "name": "x" }),
&token,
&csrf,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let resp = app
.clone()
.oneshot(delete_with_token("/api/companion/companions/companion_missing", &token, &csrf))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
// 400 on invalid names (service-level validation).
let req = json_with_token("POST", "/api/companion/companions", json!({ "name": " " }), &token, &csrf);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let req = json_with_token(
"POST",
"/api/companion/companions",
json!({ "name": "x".repeat(41) }),
&token,
&csrf,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
// ── per-companion companion threads ─────────────────────────────────────────
#[tokio::test]
async fn companion_single_session_happy_path() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let companion = create_companion(&app, &token, &csrf, "", "ink").await;
let id = companion["id"].as_str().unwrap().to_owned();
// Without a configured model the companion cannot open its session (400).
let req = json_with_token(
"POST",
&format!("/api/companion/companions/{id}/companion/threads"),
json!({}),
&token,
&csrf,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
// Configure the model. The work-partner is a SINGLE-session model now:
// configuring the model auto-ensures the one companion session.
let req = json_with_token(
"PATCH",
&format!("/api/companion/companions/{id}"),
json!({ "model": { "provider_id": "prov_test", "model": "test-model" } }),
&token,
&csrf,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// POST create-thread is an idempotent ensure of that single session; it
// returns the conversation bound to this companion.
let req = json_with_token(
"POST",
&format!("/api/companion/companions/{id}/companion/threads"),
json!({ "title": "第一聊" }),
&token,
&csrf,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let thread = body_json(resp).await;
let conv = thread["data"]["conversation_id"].as_str().unwrap().to_owned();
assert!(!conv.is_empty());
assert_eq!(thread["data"]["companion_id"], id.as_str());
// GET active points at that same single session.
let resp = app
.clone()
.oneshot(get_with_token(&format!("/api/companion/companions/{id}/companion/active"), &token))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let active = body_json(resp).await;
assert_eq!(active["data"]["conversation_id"], conv.as_str());
// Re-ensuring is idempotent — the same conversation, never a second one.
let req = json_with_token(
"POST",
&format!("/api/companion/companions/{id}/companion/threads"),
json!({ "title": "忽略" }),
&token,
&csrf,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let again = body_json(resp).await;
assert_eq!(again["data"]["conversation_id"], conv.as_str());
}
#[tokio::test]
async fn companion_thread_unknown_companion_404_and_no_model_400() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
// Unknown companion: the single-session surface 404s (existence-gated)
// instead of reading as "no active session".
let resp = app
.clone()
.oneshot(get_with_token("/api/companion/companions/companion_missing/companion/active", &token))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let req = json_with_token(
"POST",
"/api/companion/companions/companion_missing/companion/threads",
json!({}),
&token,
&csrf,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
// A known companion without a configured model cannot open its session (400).
let b = create_companion(&app, &token, &csrf, "", "boo").await;
let b_id = b["id"].as_str().unwrap().to_owned();
let req = json_with_token(
"POST",
&format!("/api/companion/companions/{b_id}/companion/threads"),
json!({}),
&token,
&csrf,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}