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

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);
}