f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
929 lines
32 KiB
Rust
929 lines
32 KiB
Rust
//! E2E tests for conversation CRUD, clone, reset, associated, and auth protection.
|
|
|
|
mod common;
|
|
|
|
use axum::http::StatusCode;
|
|
use serde_json::json;
|
|
use tower::ServiceExt;
|
|
|
|
use common::{body_json, build_app, delete_with_token, get_request, get_with_token, json_with_token, setup_and_login};
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
fn create_body(name: &str) -> serde_json::Value {
|
|
json!({
|
|
"type": "acp",
|
|
"name": name,
|
|
"extra": { "workspace": "/project" }
|
|
})
|
|
}
|
|
|
|
fn create_body_with_extra(name: &str, extra: serde_json::Value) -> serde_json::Value {
|
|
json!({
|
|
"type": "acp",
|
|
"name": name,
|
|
"extra": extra
|
|
})
|
|
}
|
|
|
|
// ── T1: Create ────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn t1_1_create_conversation_success() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let req = json_with_token("POST", "/api/conversations", create_body("Code Review"), &token, &csrf);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["success"], true);
|
|
let data = &json["data"];
|
|
assert_eq!(data["name"], "Code Review");
|
|
assert_eq!(data["type"], "acp");
|
|
assert_eq!(data["status"], "pending");
|
|
assert_eq!(data["source"], "nomifun");
|
|
assert_eq!(data["pinned"], false);
|
|
assert!(data["id"].as_i64().is_some());
|
|
assert!(data["created_at"].as_i64().is_some());
|
|
assert!(data["modified_at"].as_i64().is_some());
|
|
assert_eq!(data["extra"]["workspace"], "/project");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t1_2_create_various_agent_types() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let types = ["acp", "openclaw-gateway", "nanobot", "remote"];
|
|
for agent_type in types {
|
|
let body = json!({
|
|
"type": agent_type,
|
|
"extra": {}
|
|
});
|
|
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED, "type={agent_type}");
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["type"], agent_type);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t1_3_create_with_optional_fields() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let body = json!({
|
|
"type": "acp",
|
|
"name": "Telegram Bot",
|
|
"source": "telegram",
|
|
"channel_chat_id": "user:123",
|
|
"extra": {}
|
|
});
|
|
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["source"], "telegram");
|
|
assert_eq!(json["data"]["channel_chat_id"], "user:123");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t1_4_create_missing_required_field() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
// Missing type
|
|
let body = json!({
|
|
"model": { "provider_id": "p1", "model": "m1" },
|
|
"extra": {}
|
|
});
|
|
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
|
|
// model is optional — omitting it should succeed
|
|
let body = json!({ "type": "acp", "extra": {} });
|
|
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
|
|
// Missing extra
|
|
let body = json!({
|
|
"type": "nomi",
|
|
"model": { "provider_id": "p1", "model": "m1" }
|
|
});
|
|
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t1_5_create_invalid_type() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let body = json!({
|
|
"type": "invalid_type",
|
|
"model": { "provider_id": "p1", "model": "m1" },
|
|
"extra": {}
|
|
});
|
|
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t1_5b_create_accepts_interior_whitespace_and_rejects_edge_whitespace() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
// Interior whitespace ("Application Support" on macOS, "my project") is a
|
|
// normal path and must be accepted.
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let workspace = temp.path().join("Application Support").join("my project");
|
|
std::fs::create_dir_all(&workspace).unwrap();
|
|
|
|
let body = json!({
|
|
"type": "acp",
|
|
"extra": {
|
|
"workspace": workspace.to_string_lossy()
|
|
}
|
|
});
|
|
let req = json_with_token("POST", "/api/conversations", body, &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["data"]["extra"]["workspace"], workspace.to_string_lossy().as_ref());
|
|
|
|
// A directory name that ends with whitespace is pathological (Win32
|
|
// strips trailing spaces on lookup) and stays rejected.
|
|
let edge_workspace = format!("{} ", temp.path().join("repo").to_string_lossy());
|
|
let body = json!({
|
|
"type": "acp",
|
|
"extra": {
|
|
"workspace": edge_workspace
|
|
}
|
|
});
|
|
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["code"], "WORKSPACE_PATH_EDGE_WHITESPACE_UNSUPPORTED");
|
|
assert!(
|
|
json["error"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.contains("begins or ends with whitespace"),
|
|
"unexpected error payload: {json}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t1_6_create_requires_auth() {
|
|
let (app, _services) = build_app().await;
|
|
|
|
let req = axum::http::Request::builder()
|
|
.method("POST")
|
|
.uri("/api/conversations")
|
|
.header("content-type", "application/json")
|
|
.body(axum::body::Body::from(
|
|
serde_json::to_vec(&create_body("test")).unwrap(),
|
|
))
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
// ── T2: List ──────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn t2_1_list_empty() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let resp = app.oneshot(get_with_token("/api/conversations", &token)).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["items"].as_array().unwrap().len(), 0);
|
|
assert_eq!(json["data"]["total"], 0);
|
|
assert_eq!(json["data"]["has_more"], false);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t2_2_list_basic() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
for i in 0..3 {
|
|
let req = json_with_token(
|
|
"POST",
|
|
"/api/conversations",
|
|
create_body(&format!("Conv {i}")),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
app.clone().oneshot(req).await.unwrap();
|
|
}
|
|
|
|
let resp = app.oneshot(get_with_token("/api/conversations", &token)).await.unwrap();
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["items"].as_array().unwrap().len(), 3);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t2_3_list_cursor_pagination() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
for i in 0..5 {
|
|
let req = json_with_token(
|
|
"POST",
|
|
"/api/conversations",
|
|
create_body(&format!("Conv {i}")),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
app.clone().oneshot(req).await.unwrap();
|
|
}
|
|
|
|
// First page: limit=2
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_token("/api/conversations?limit=2", &token))
|
|
.await
|
|
.unwrap();
|
|
let json = body_json(resp).await;
|
|
let items = json["data"]["items"].as_array().unwrap();
|
|
assert_eq!(items.len(), 2);
|
|
assert_eq!(json["data"]["has_more"], true);
|
|
|
|
// Second page using cursor
|
|
let cursor = items.last().unwrap()["id"].as_i64().unwrap();
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_token(
|
|
&format!("/api/conversations?limit=2&cursor={cursor}"),
|
|
&token,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let json = body_json(resp).await;
|
|
let items2 = json["data"]["items"].as_array().unwrap();
|
|
assert_eq!(items2.len(), 2);
|
|
assert_eq!(json["data"]["has_more"], true);
|
|
|
|
// Third page
|
|
let cursor2 = items2.last().unwrap()["id"].as_i64().unwrap();
|
|
let resp = app
|
|
.oneshot(get_with_token(
|
|
&format!("/api/conversations?limit=2&cursor={cursor2}"),
|
|
&token,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let json = body_json(resp).await;
|
|
let items3 = json["data"]["items"].as_array().unwrap();
|
|
assert_eq!(items3.len(), 1);
|
|
assert_eq!(json["data"]["has_more"], false);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t2_4_list_source_filter() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
// Create 2 nomifun + 1 telegram
|
|
for _ in 0..2 {
|
|
let req = json_with_token("POST", "/api/conversations", create_body("Nomi Conv"), &token, &csrf);
|
|
app.clone().oneshot(req).await.unwrap();
|
|
}
|
|
|
|
let tg_body = json!({
|
|
"type": "acp",
|
|
"name": "TG Conv",
|
|
"source": "telegram",
|
|
"extra": {}
|
|
});
|
|
let req = json_with_token("POST", "/api/conversations", tg_body, &token, &csrf);
|
|
app.clone().oneshot(req).await.unwrap();
|
|
|
|
let resp = app
|
|
.oneshot(get_with_token("/api/conversations?source=telegram", &token))
|
|
.await
|
|
.unwrap();
|
|
let json = body_json(resp).await;
|
|
let items = json["data"]["items"].as_array().unwrap();
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0]["source"], "telegram");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t2_5_list_pinned_filter() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
// Create 2 conversations
|
|
let req = json_with_token("POST", "/api/conversations", create_body("Unpinned"), &token, &csrf);
|
|
app.clone().oneshot(req).await.unwrap();
|
|
|
|
let req = json_with_token("POST", "/api/conversations", create_body("Will Pin"), &token, &csrf);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
let json = body_json(resp).await;
|
|
let pinned_id = json["data"]["id"].as_i64().unwrap();
|
|
|
|
// Pin one
|
|
let req = json_with_token(
|
|
"PATCH",
|
|
&format!("/api/conversations/{pinned_id}"),
|
|
json!({"pinned": true}),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
app.clone().oneshot(req).await.unwrap();
|
|
|
|
let resp = app
|
|
.oneshot(get_with_token("/api/conversations?pinned=true", &token))
|
|
.await
|
|
.unwrap();
|
|
let json = body_json(resp).await;
|
|
let items = json["data"]["items"].as_array().unwrap();
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0]["pinned"], true);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t2_6_list_requires_auth() {
|
|
let (app, _services) = build_app().await;
|
|
let resp = app.oneshot(get_request("/api/conversations")).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
// ── T3: Get ───────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn t3_1_get_existing() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let req = json_with_token("POST", "/api/conversations", create_body("My Conv"), &token, &csrf);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
let json = body_json(resp).await;
|
|
let id = json["data"]["id"].as_i64().unwrap();
|
|
|
|
let resp = app
|
|
.oneshot(get_with_token(&format!("/api/conversations/{id}"), &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["id"], id);
|
|
assert_eq!(json["data"]["name"], "My Conv");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t3_2_get_not_found() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let resp = app
|
|
.oneshot(get_with_token("/api/conversations/non-existent-id", &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t3_3_get_requires_auth() {
|
|
let (app, _services) = build_app().await;
|
|
let resp = app.oneshot(get_request("/api/conversations/some-id")).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
// ── T4: Update ────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn t4_1_update_name() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let req = json_with_token("POST", "/api/conversations", create_body("Original"), &token, &csrf);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
let json = body_json(resp).await;
|
|
let id = json["data"]["id"].as_i64().unwrap();
|
|
let original_modified = json["data"]["modified_at"].as_i64().unwrap();
|
|
|
|
let req = json_with_token(
|
|
"PATCH",
|
|
&format!("/api/conversations/{id}"),
|
|
json!({"name": "Updated"}),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["name"], "Updated");
|
|
assert!(json["data"]["modified_at"].as_i64().unwrap() >= original_modified);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t4_2_update_pin_and_unpin() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let req = json_with_token("POST", "/api/conversations", create_body("Pin Test"), &token, &csrf);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
let json = body_json(resp).await;
|
|
let id = json["data"]["id"].as_i64().unwrap();
|
|
|
|
// Pin
|
|
let req = json_with_token(
|
|
"PATCH",
|
|
&format!("/api/conversations/{id}"),
|
|
json!({"pinned": true}),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["pinned"], true);
|
|
assert!(json["data"]["pinned_at"].as_i64().is_some());
|
|
|
|
// Unpin
|
|
let req = json_with_token(
|
|
"PATCH",
|
|
&format!("/api/conversations/{id}"),
|
|
json!({"pinned": false}),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["pinned"], false);
|
|
assert!(json["data"]["pinned_at"].is_null());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t4_3_update_extra_merge() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let body = create_body_with_extra(
|
|
"Merge Test",
|
|
json!({"workspace": "/old", "context_file_name": "ctx.md"}),
|
|
);
|
|
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
let json = body_json(resp).await;
|
|
let id = json["data"]["id"].as_i64().unwrap();
|
|
|
|
// Merge update: change workspace, keep contextFileName
|
|
let req = json_with_token(
|
|
"PATCH",
|
|
&format!("/api/conversations/{id}"),
|
|
json!({"extra": {"workspace": "/new"}}),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["extra"]["workspace"], "/new");
|
|
assert_eq!(json["data"]["extra"]["context_file_name"], "ctx.md");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t4_4_update_model() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
// nomi — only type that allows top-level model updates
|
|
let create = json!({
|
|
"type": "nomi",
|
|
"name": "Model Test",
|
|
"model": { "provider_id": "p1", "model": "m1" },
|
|
"extra": {}
|
|
});
|
|
let req = json_with_token("POST", "/api/conversations", create, &token, &csrf);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
let json = body_json(resp).await;
|
|
let id = json["data"]["id"].as_i64().unwrap();
|
|
|
|
let req = json_with_token(
|
|
"PATCH",
|
|
&format!("/api/conversations/{id}"),
|
|
json!({"model": {"provider_id": "p2", "model": "new-model"}}),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["model"]["provider_id"], "p2");
|
|
assert_eq!(json["data"]["model"]["model"], "new-model");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t4_5_update_not_found() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let req = json_with_token(
|
|
"PATCH",
|
|
"/api/conversations/non-existent-id",
|
|
json!({"name": "X"}),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t4_6_update_requires_auth() {
|
|
let (app, _services) = build_app().await;
|
|
let resp = app.oneshot(get_request("/api/conversations/some-id")).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
// ── T5: Delete ────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn t5_1_delete_conversation() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let req = json_with_token("POST", "/api/conversations", create_body("To Delete"), &token, &csrf);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
let json = body_json(resp).await;
|
|
let id = json["data"]["id"].as_i64().unwrap();
|
|
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(delete_with_token(&format!("/api/conversations/{id}"), &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
// Verify it's gone
|
|
let resp = app
|
|
.oneshot(get_with_token(&format!("/api/conversations/{id}"), &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t5_2_delete_not_found() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let resp = app
|
|
.oneshot(delete_with_token("/api/conversations/non-existent-id", &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t5_3_delete_requires_auth() {
|
|
let (app, _services) = build_app().await;
|
|
let req = axum::http::Request::builder()
|
|
.method("DELETE")
|
|
.uri("/api/conversations/some-id")
|
|
.body(axum::body::Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
// ── T6: Clone ─────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn t6_2_clone_without_source() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let clone_body = json!({
|
|
"conversation": {
|
|
"type": "acp",
|
|
"name": "Fresh Clone",
|
|
"extra": {}
|
|
}
|
|
});
|
|
let req = json_with_token("POST", "/api/conversations/clone", clone_body, &token, &csrf);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["name"], "Fresh Clone");
|
|
assert_eq!(json["data"]["type"], "acp");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t6_4_clone_requires_auth() {
|
|
let (app, _services) = build_app().await;
|
|
let req = axum::http::Request::builder()
|
|
.method("POST")
|
|
.uri("/api/conversations/clone")
|
|
.header("content-type", "application/json")
|
|
.body(axum::body::Body::from(b"{}".to_vec()))
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
// ── T7: Reset ─────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn t7_1_reset_conversation() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
// Create conversation
|
|
let req = json_with_token("POST", "/api/conversations", create_body("Reset Test"), &token, &csrf);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
let json = body_json(resp).await;
|
|
let id = json["data"]["id"].as_i64().unwrap();
|
|
|
|
// Insert a message directly via repo
|
|
let repo = nomifun_db::SqliteConversationRepository::new(services.database.pool().clone());
|
|
let msg = nomifun_db::models::MessageRow {
|
|
id: "msg-1".into(),
|
|
conversation_id: id.clone(),
|
|
msg_id: None,
|
|
r#type: "text".into(),
|
|
content: r#"{"content":"hello"}"#.into(),
|
|
position: None,
|
|
status: None,
|
|
hidden: false,
|
|
created_at: 1000,
|
|
};
|
|
nomifun_db::IConversationRepository::insert_message(&repo, &msg)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Reset
|
|
let req = json_with_token(
|
|
"POST",
|
|
&format!("/api/conversations/{id}/reset"),
|
|
json!({}),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
// Verify messages cleared
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_token(&format!("/api/conversations/{id}/messages"), &token))
|
|
.await
|
|
.unwrap();
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["items"].as_array().unwrap().len(), 0);
|
|
|
|
// Verify status is pending
|
|
let resp = app
|
|
.oneshot(get_with_token(&format!("/api/conversations/{id}"), &token))
|
|
.await
|
|
.unwrap();
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["status"], "pending");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t7_2_reset_not_found() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let req = json_with_token(
|
|
"POST",
|
|
"/api/conversations/non-existent-id/reset",
|
|
json!({}),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t7_3_reset_requires_auth() {
|
|
let (app, _services) = build_app().await;
|
|
let req = axum::http::Request::builder()
|
|
.method("POST")
|
|
.uri("/api/conversations/some-id/reset")
|
|
.header("content-type", "application/json")
|
|
.body(axum::body::Body::from(b"{}".to_vec()))
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
// ── T10: Associated ───────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn t10_1_associated_same_workspace() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
// Create 3 conversations: 2 same workspace, 1 different
|
|
let body1 = create_body_with_extra("Conv A", json!({"workspace": "/same"}));
|
|
let req = json_with_token("POST", "/api/conversations", body1, &token, &csrf);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
let json = body_json(resp).await;
|
|
let id_a = json["data"]["id"].as_i64().unwrap();
|
|
|
|
let body2 = create_body_with_extra("Conv B", json!({"workspace": "/same"}));
|
|
let req = json_with_token("POST", "/api/conversations", body2, &token, &csrf);
|
|
app.clone().oneshot(req).await.unwrap();
|
|
|
|
let body3 = create_body_with_extra("Conv C", json!({"workspace": "/other"}));
|
|
let req = json_with_token("POST", "/api/conversations", body3, &token, &csrf);
|
|
app.clone().oneshot(req).await.unwrap();
|
|
|
|
let resp = app
|
|
.oneshot(get_with_token(&format!("/api/conversations/{id_a}/associated"), &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let json = body_json(resp).await;
|
|
let items = json["data"].as_array().unwrap();
|
|
assert_eq!(items.len(), 1); // only Conv B, not self or Conv C
|
|
assert_eq!(items[0]["extra"]["workspace"], "/same");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t10_2_associated_none() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let body = create_body_with_extra("Unique", json!({"workspace": "/unique"}));
|
|
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
let json = body_json(resp).await;
|
|
let id = json["data"]["id"].as_i64().unwrap();
|
|
|
|
let resp = app
|
|
.oneshot(get_with_token(&format!("/api/conversations/{id}/associated"), &token))
|
|
.await
|
|
.unwrap();
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"].as_array().unwrap().len(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t10_3_associated_not_found() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let resp = app
|
|
.oneshot(get_with_token("/api/conversations/non-existent-id/associated", &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t10_4_associated_requires_auth() {
|
|
let (app, _services) = build_app().await;
|
|
let resp = app
|
|
.oneshot(get_request("/api/conversations/some-id/associated"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
// ── T12: Boundary scenarios ───────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn t12_1_long_name() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let long_name = "A".repeat(1000);
|
|
let req = json_with_token("POST", "/api/conversations", create_body(&long_name), &token, &csrf);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["name"].as_str().unwrap().len(), 1000);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t12_2_large_nested_extra() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let big_extra = json!({
|
|
"workspace": "/project",
|
|
"nested": {
|
|
"level1": {
|
|
"level2": {
|
|
"level3": { "deep": true }
|
|
}
|
|
}
|
|
},
|
|
"array": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
|
});
|
|
let body = create_body_with_extra("Big Extra", big_extra.clone());
|
|
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
let json = body_json(resp).await;
|
|
assert_eq!(
|
|
json["data"]["extra"]["nested"]["level1"]["level2"]["level3"]["deep"],
|
|
true
|
|
);
|
|
assert_eq!(json["data"]["extra"]["array"].as_array().unwrap().len(), 10);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn t12_3_concurrent_creates() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let mut ids = Vec::new();
|
|
for i in 0..10 {
|
|
let req = json_with_token(
|
|
"POST",
|
|
"/api/conversations",
|
|
create_body(&format!("Concurrent {i}")),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
let json = body_json(resp).await;
|
|
ids.push(json["data"]["id"].as_i64().unwrap());
|
|
}
|
|
|
|
// All IDs should be unique
|
|
let unique: std::collections::HashSet<_> = ids.iter().collect();
|
|
assert_eq!(unique.len(), 10);
|
|
}
|
|
|
|
// ── Full lifecycle ────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn full_conversation_lifecycle() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
// Create
|
|
let req = json_with_token(
|
|
"POST",
|
|
"/api/conversations",
|
|
create_body("Lifecycle Test"),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
let json = body_json(resp).await;
|
|
let id = json["data"]["id"].as_i64().unwrap();
|
|
assert_eq!(json["data"]["status"], "pending");
|
|
|
|
// Read
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_token(&format!("/api/conversations/{id}"), &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
// Update
|
|
let req = json_with_token(
|
|
"PATCH",
|
|
&format!("/api/conversations/{id}"),
|
|
json!({"name": "Updated Lifecycle"}),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["name"], "Updated Lifecycle");
|
|
|
|
// Delete
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(delete_with_token(&format!("/api/conversations/{id}"), &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
// Verify gone
|
|
let resp = app
|
|
.oneshot(get_with_token(&format!("/api/conversations/{id}"), &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|