Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,934 @@
|
||||
//! E2E tests for message listing, search, pagination, and auth protection.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{body_json, build_app, build_app_with_mock_agents, get_request, get_with_token, setup_and_login};
|
||||
use nomifun_db::{ConversationRowUpdate, IConversationRepository};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
fn create_conv_body(name: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "acp",
|
||||
"name": name,
|
||||
"extra": { "workspace": "/project", "backend": "gemini" }
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_conversation(app: &mut axum::Router, token: &str, csrf: &str, name: &str) -> i64 {
|
||||
let req = common::json_with_token("POST", "/api/conversations", create_conv_body(name), token, csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = common::body_json(resp).await;
|
||||
json["data"]["id"].as_i64().unwrap()
|
||||
}
|
||||
|
||||
async fn insert_message(
|
||||
services: &nomifun_app::AppServices,
|
||||
conv_id: i64,
|
||||
msg_id: &str,
|
||||
content: &str,
|
||||
created_at: i64,
|
||||
) {
|
||||
let repo = nomifun_db::SqliteConversationRepository::new(services.database.pool().clone());
|
||||
let msg = nomifun_db::models::MessageRow {
|
||||
id: msg_id.into(),
|
||||
conversation_id: conv_id,
|
||||
msg_id: None,
|
||||
r#type: "text".into(),
|
||||
content: serde_json::json!({"content": content}).to_string(),
|
||||
position: Some("right".into()),
|
||||
status: Some("finish".into()),
|
||||
hidden: false,
|
||||
created_at,
|
||||
};
|
||||
nomifun_db::IConversationRepository::insert_message(&repo, &msg)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn update_conversation_workspace(services: &nomifun_app::AppServices, conv_id: i64, workspace: &str) {
|
||||
let repo = nomifun_db::SqliteConversationRepository::new(services.database.pool().clone());
|
||||
IConversationRepository::update(
|
||||
&repo,
|
||||
conv_id,
|
||||
&ConversationRowUpdate {
|
||||
extra: Some(json!({ "workspace": workspace, "backend": "gemini" }).to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn insert_acp_tool_message(
|
||||
services: &nomifun_app::AppServices,
|
||||
conv_id: i64,
|
||||
msg_id: &str,
|
||||
output: &str,
|
||||
created_at: i64,
|
||||
) {
|
||||
let repo = nomifun_db::SqliteConversationRepository::new(services.database.pool().clone());
|
||||
let msg = nomifun_db::models::MessageRow {
|
||||
id: msg_id.into(),
|
||||
conversation_id: conv_id,
|
||||
msg_id: Some(msg_id.into()),
|
||||
r#type: "acp_tool_call".into(),
|
||||
content: serde_json::json!({
|
||||
"session_id": "session-1",
|
||||
"update": {
|
||||
"session_update": "tool_call",
|
||||
"tool_call_id": msg_id,
|
||||
"status": "completed",
|
||||
"title": "rg",
|
||||
"kind": "search",
|
||||
"raw_input": { "pattern": "needle", "path": "." },
|
||||
"content": [{
|
||||
"type": "content",
|
||||
"content": { "type": "text", "text": output }
|
||||
}]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
position: Some("left".into()),
|
||||
status: Some("finish".into()),
|
||||
hidden: false,
|
||||
created_at,
|
||||
};
|
||||
nomifun_db::IConversationRepository::insert_message(&repo, &msg)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn upsert_artifact(services: &nomifun_app::AppServices, artifact: nomifun_db::ConversationArtifactRow) -> i64 {
|
||||
let repo = nomifun_db::SqliteConversationRepository::new(services.database.pool().clone());
|
||||
nomifun_db::IConversationRepository::upsert_artifact(&repo, &artifact)
|
||||
.await
|
||||
.unwrap()
|
||||
.id
|
||||
}
|
||||
|
||||
/// Seed a minimal `cron_jobs` parent row so artifacts referencing it satisfy
|
||||
/// the `conversation_artifacts.cron_job_id -> cron_jobs(id)` foreign key.
|
||||
async fn seed_cron_job(services: &nomifun_app::AppServices, id: &str) {
|
||||
sqlx::query(
|
||||
"INSERT INTO cron_jobs \
|
||||
(id, name, schedule_kind, schedule_value, payload_message, agent_type, created_by, created_at, updated_at) \
|
||||
VALUES (?, 'Job', 'every', '60000', 'msg', 'acp', 'user', 0, 0)",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(services.database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ── T8: Message list ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_1_messages_empty() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Empty Conv").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages"),
|
||||
&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);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_2_messages_pagination() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Paginated Conv").await;
|
||||
|
||||
// Insert 10 messages
|
||||
for i in 0..10 {
|
||||
insert_message(
|
||||
&services,
|
||||
conv_id,
|
||||
&format!("msg-{i}"),
|
||||
&format!("Message {i}"),
|
||||
1000 + i * 100,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Page 1, page_size 3
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages?page=1&page_size=3"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["items"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(json["data"]["total"], 10);
|
||||
assert_eq!(json["data"]["has_more"], true);
|
||||
|
||||
// Last page
|
||||
let resp = app
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages?page=4&page_size=3"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(json["data"]["has_more"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_2b_messages_compact_mode_truncates_large_tool_payload() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Compact Tool Conv").await;
|
||||
let large_output = "match line\n".repeat(10_000);
|
||||
|
||||
insert_acp_tool_message(&services, conv_id, "tool-big", &large_output, 1000).await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages?content_mode=compact"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
let content = &json["data"]["items"][0]["content"];
|
||||
let preview = content["update"]["content"][0]["content"]["text"].as_str().unwrap();
|
||||
|
||||
assert_eq!(content["_compact"]["truncated"], true);
|
||||
assert!(preview.len() < large_output.len());
|
||||
assert!(!preview.contains(&large_output));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_2c_get_message_returns_full_tool_payload() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Tool Detail Conv").await;
|
||||
let large_output = "wide rg output\n".repeat(10_000);
|
||||
|
||||
insert_acp_tool_message(&services, conv_id, "tool-detail", &large_output, 1000).await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages/tool-detail"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
|
||||
assert_eq!(
|
||||
json["data"]["content"]["update"]["content"][0]["content"]["text"]
|
||||
.as_str()
|
||||
.unwrap(),
|
||||
large_output
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_2d_get_message_requires_auth() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Tool Detail Auth Conv").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_request(&format!(
|
||||
"/api/conversations/{conv_id}/messages/tool-detail"
|
||||
)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_2e_get_message_not_found_returns_specific_error() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Tool Detail Missing Conv").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages/missing-message"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
let json = body_json(resp).await;
|
||||
|
||||
assert_eq!(json["code"], "NOT_FOUND");
|
||||
assert!(
|
||||
json["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Message missing-message not found")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_2f_get_message_does_not_leak_cross_user_conversation() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (owner_token, owner_csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let owner_conv_id = create_conversation(&mut app, &owner_token, &owner_csrf, "Owner Tool Conv").await;
|
||||
insert_acp_tool_message(&services, owner_conv_id, "owner-tool", "private output", 1000).await;
|
||||
|
||||
let (other_token, _other_csrf) = setup_and_login(&mut app, &services, "other-user", "StrongP@ss2").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{owner_conv_id}/messages/owner-tool"),
|
||||
&other_token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
let json = body_json(resp).await;
|
||||
|
||||
assert_eq!(json["code"], "NOT_FOUND");
|
||||
assert!(
|
||||
json["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(&format!("Conversation {owner_conv_id} not found"))
|
||||
);
|
||||
assert!(!json["error"].as_str().unwrap().contains("owner-tool"));
|
||||
assert!(!json["error"].as_str().unwrap().contains("private output"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_3_messages_order_asc_default() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Order Test").await;
|
||||
|
||||
insert_message(&services, conv_id, "msg-old", "Old", 1000).await;
|
||||
insert_message(&services, conv_id, "msg-mid", "Mid", 2000).await;
|
||||
insert_message(&services, conv_id, "msg-new", "New", 3000).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let items = json["data"]["items"].as_array().unwrap();
|
||||
// ASC order (default): oldest first
|
||||
assert!(items[0]["created_at"].as_i64().unwrap() < items[1]["created_at"].as_i64().unwrap());
|
||||
assert!(items[1]["created_at"].as_i64().unwrap() < items[2]["created_at"].as_i64().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_4_messages_order_asc() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "ASC Test").await;
|
||||
|
||||
insert_message(&services, conv_id, "msg-old", "Old", 1000).await;
|
||||
insert_message(&services, conv_id, "msg-mid", "Mid", 2000).await;
|
||||
insert_message(&services, conv_id, "msg-new", "New", 3000).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages?order=ASC"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let items = json["data"]["items"].as_array().unwrap();
|
||||
// ASC order: oldest first
|
||||
assert!(items[0]["created_at"].as_i64().unwrap() < items[1]["created_at"].as_i64().unwrap());
|
||||
assert!(items[1]["created_at"].as_i64().unwrap() < items[2]["created_at"].as_i64().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_5_messages_conversation_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/messages", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_6_messages_requires_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
let resp = app
|
||||
.oneshot(get_request("/api/conversations/some-id/messages"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_7_messages_exclude_legacy_cron_rows() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Legacy Filter").await;
|
||||
|
||||
insert_message(&services, conv_id, "msg-text", "Visible", 1000).await;
|
||||
|
||||
let repo = nomifun_db::SqliteConversationRepository::new(services.database.pool().clone());
|
||||
for (id, ty, content) in [
|
||||
(
|
||||
"legacy-cron",
|
||||
"cron_trigger",
|
||||
json!({
|
||||
"cron_job_id": "cron_1",
|
||||
"cron_job_name": "Daily",
|
||||
"triggered_at": 2000
|
||||
}),
|
||||
),
|
||||
(
|
||||
"legacy-skill",
|
||||
"skill_suggest",
|
||||
json!({
|
||||
"cron_job_id": "cron_1",
|
||||
"name": "daily-report",
|
||||
"description": "Daily report",
|
||||
"skillContent": "---\nname: daily-report\n---\nUse it."
|
||||
}),
|
||||
),
|
||||
] {
|
||||
let msg = nomifun_db::models::MessageRow {
|
||||
id: id.into(),
|
||||
conversation_id: conv_id.clone(),
|
||||
msg_id: None,
|
||||
r#type: ty.into(),
|
||||
content: content.to_string(),
|
||||
position: Some("center".into()),
|
||||
status: Some("finish".into()),
|
||||
hidden: false,
|
||||
created_at: 2000,
|
||||
};
|
||||
nomifun_db::IConversationRepository::insert_message(&repo, &msg)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let items = json["data"]["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(json["data"]["total"], 1);
|
||||
assert_eq!(items[0]["type"], "text");
|
||||
assert_eq!(items[0]["content"]["content"], "Visible");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_8_artifacts_list_and_patch_status() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Artifacts").await;
|
||||
seed_cron_job(&services, "cron_1").await;
|
||||
|
||||
let artifact_id = upsert_artifact(
|
||||
&services,
|
||||
nomifun_db::ConversationArtifactRow {
|
||||
id: 0,
|
||||
conversation_id: conv_id.clone(),
|
||||
cron_job_id: Some("cron_1".into()),
|
||||
kind: "skill_suggest".into(),
|
||||
status: "active".into(),
|
||||
payload: json!({
|
||||
"cron_job_id": "cron_1",
|
||||
"name": "daily-report",
|
||||
"description": "Daily report",
|
||||
"skillContent": "---\nname: daily-report\n---\nUse it."
|
||||
})
|
||||
.to_string(),
|
||||
created_at: 1000,
|
||||
updated_at: 1000,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/artifacts"),
|
||||
&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);
|
||||
assert_eq!(items[0]["id"], artifact_id);
|
||||
assert_eq!(items[0]["kind"], "skill_suggest");
|
||||
assert_eq!(items[0]["status"], "active");
|
||||
|
||||
let patch_req = common::json_with_token(
|
||||
"PATCH",
|
||||
&format!("/api/conversations/{conv_id}/artifacts/{artifact_id}"),
|
||||
json!({ "status": "dismissed" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let patch_resp = app.oneshot(patch_req).await.unwrap();
|
||||
assert_eq!(patch_resp.status(), StatusCode::OK);
|
||||
let patch_json = body_json(patch_resp).await;
|
||||
assert_eq!(patch_json["data"]["status"], "dismissed");
|
||||
}
|
||||
|
||||
// ── T9: Message search ────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_1_search_keyword_match() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Search Conv").await;
|
||||
insert_message(&services, conv_id, "msg-1", "Rust is great", 1000).await;
|
||||
insert_message(&services, conv_id, "msg-2", "Python is also nice", 2000).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/messages/search?keyword=Rust", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
let items = json["data"]["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0]["conversation"]["name"], "Search Conv");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_2_search_no_match() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "No Match Conv").await;
|
||||
insert_message(&services, conv_id, "msg-1", "Hello world", 1000).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/messages/search?keyword=xxxxnotexist", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["items"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(json["data"]["total"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_3_search_pagination() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Search Paged").await;
|
||||
for i in 0..5 {
|
||||
insert_message(
|
||||
&services,
|
||||
conv_id,
|
||||
&format!("msg-{i}"),
|
||||
&format!("Matching keyword {i}"),
|
||||
1000 + i * 100,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(
|
||||
"/api/messages/search?keyword=Matching&page=1&page_size=2",
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["items"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(json["data"]["total"], 5);
|
||||
assert_eq!(json["data"]["has_more"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_4_search_empty_keyword() {
|
||||
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/messages/search?keyword=", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_5_search_requires_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
let resp = app
|
||||
.oneshot(get_request("/api/messages/search?keyword=test"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ── T12.4: SQL injection safety ───────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_4_search_sql_injection_safe() {
|
||||
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/messages/search?keyword=';%20DROP%20TABLE%20messages;%20--",
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
// Should not crash; just return empty results
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["items"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
// ── Message response field validation ─────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn message_response_has_correct_fields() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Field Check").await;
|
||||
insert_message(&services, conv_id, "msg-fc", "Content check", 5000).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let msg = &json["data"]["items"][0];
|
||||
|
||||
// Verify snake_case fields exist
|
||||
assert!(msg.get("id").is_some());
|
||||
assert!(msg.get("conversation_id").is_some());
|
||||
assert!(msg.get("type").is_some());
|
||||
assert!(msg.get("content").is_some());
|
||||
assert!(msg.get("position").is_some());
|
||||
assert!(msg.get("status").is_some());
|
||||
assert!(msg.get("created_at").is_some());
|
||||
// Verify no camelCase leaks
|
||||
assert!(msg.get("conversationId").is_none());
|
||||
assert!(msg.get("createdAt").is_none());
|
||||
assert!(msg.get("msgId").is_none());
|
||||
}
|
||||
|
||||
// ── Delete cascades messages ──────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_conversation_cascades_messages() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Cascade Test").await;
|
||||
insert_message(&services, conv_id, "msg-cas-1", "msg 1", 1000).await;
|
||||
insert_message(&services, conv_id, "msg-cas-2", "msg 2", 2000).await;
|
||||
|
||||
// Delete the conversation
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(common::delete_with_token(
|
||||
&format!("/api/conversations/{conv_id}"),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Search for messages from the deleted conversation should return nothing
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/messages/search?keyword=msg", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["items"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
// ── Cross-conversation search ─────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_across_multiple_conversations() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let conv1 = create_conversation(&mut app, &token, &csrf, "Conv Alpha").await;
|
||||
let conv2 = create_conversation(&mut app, &token, &csrf, "Conv Beta").await;
|
||||
|
||||
insert_message(&services, conv1, "msg-a1", "Rust review needed", 1000).await;
|
||||
insert_message(&services, conv2, "msg-b1", "Rust performance tips", 2000).await;
|
||||
insert_message(&services, conv2, "msg-b2", "Python patterns", 3000).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/messages/search?keyword=Rust", &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"]["total"], 2);
|
||||
}
|
||||
|
||||
// ── T2.1: Send message ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_1_send_message_accepted() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Send Test").await;
|
||||
|
||||
let body = json!({ "content": "Hello AI" });
|
||||
let req = common::json_with_token(
|
||||
"POST",
|
||||
&format!("/api/conversations/{conv_id}/messages"),
|
||||
body,
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
// The stub agent factory returns an error, so we expect 500
|
||||
// (the route itself is wired correctly — 202 when factory is real)
|
||||
// In E2E with stub factory, the get_or_build_task fails.
|
||||
// We verify the route is reachable and returns an error (not 404/405).
|
||||
// 400 may occur when the stub environment lacks valid backend configuration.
|
||||
let status = resp.status();
|
||||
assert!(
|
||||
status == StatusCode::ACCEPTED
|
||||
|| status == StatusCode::INTERNAL_SERVER_ERROR
|
||||
|| status == StatusCode::BAD_REQUEST,
|
||||
"Expected 202, 400, or 500 (stub factory), got {status}"
|
||||
);
|
||||
|
||||
if status == StatusCode::ACCEPTED {
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap();
|
||||
assert!(body["success"].as_bool().unwrap());
|
||||
assert!(body["data"]["msg_id"].as_str().is_some_and(|s| !s.is_empty()));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_1_send_message_empty_content_bad_request() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Empty Content").await;
|
||||
|
||||
let body = json!({ "content": "" });
|
||||
let req = common::json_with_token(
|
||||
"POST",
|
||||
&format!("/api/conversations/{conv_id}/messages"),
|
||||
body,
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_1_send_message_conversation_not_found() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = json!({ "content": "Hello" });
|
||||
let req = common::json_with_token("POST", "/api/conversations/non-existent/messages", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_1b_send_message_pathological_workspace_returns_runtime_whitespace_code() {
|
||||
let (mut app, services) = build_app_with_mock_agents().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Legacy Workspace").await;
|
||||
update_conversation_workspace(&services, conv_id, "/tmp/my project ").await;
|
||||
|
||||
let body = json!({ "content": "Hello" });
|
||||
let req = common::json_with_token(
|
||||
"POST",
|
||||
&format!("/api/conversations/{conv_id}/messages"),
|
||||
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_RUNTIME_UNSUPPORTED");
|
||||
assert_eq!(json["details"]["workspace_path"], "/tmp/my project ");
|
||||
assert_eq!(json["details"]["operation"], "runtime");
|
||||
}
|
||||
|
||||
/// Regression for the macOS per-user data dir: `~/Library/Application
|
||||
/// Support/NomiFun/Nomi/conversations/...` contains interior whitespace and
|
||||
/// every conversation auto-provisioned under it must remain sendable.
|
||||
#[tokio::test]
|
||||
async fn t2_1c_send_message_accepts_interior_whitespace_workspace() {
|
||||
let (mut app, services) = build_app_with_mock_agents().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "macOS Workspace").await;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let workspace = temp.path().join("Application Support").join("Nomi").join("conversations").join("nomi-temp-1");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
update_conversation_workspace(&services, conv_id, &workspace.to_string_lossy()).await;
|
||||
|
||||
let body = json!({ "content": "Hello" });
|
||||
let req = common::json_with_token(
|
||||
"POST",
|
||||
&format!("/api/conversations/{conv_id}/messages"),
|
||||
body,
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::ACCEPTED);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["success"].as_bool().unwrap());
|
||||
assert!(json["data"]["msg_id"].as_str().is_some_and(|s| !s.is_empty()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_1_send_message_requires_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let body = json!({ "content": "Hello" });
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/conversations/some-id/messages")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ── T2.2: Stop stream ───────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_2_stop_stream_conversation_not_found() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = common::json_with_token(
|
||||
"POST",
|
||||
"/api/conversations/non-existent/cancel",
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_2_stop_stream_requires_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/conversations/some-id/cancel")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ── T2.3: Warmup ────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_3_warmup_conversation_not_found() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = common::json_with_token(
|
||||
"POST",
|
||||
"/api/conversations/non-existent/warmup",
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_3b_warmup_pathological_workspace_returns_runtime_whitespace_code() {
|
||||
let (mut app, services) = build_app_with_mock_agents().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Legacy Warmup").await;
|
||||
update_conversation_workspace(&services, conv_id, "/tmp/my project ").await;
|
||||
|
||||
let req = common::json_with_token(
|
||||
"POST",
|
||||
&format!("/api/conversations/{conv_id}/warmup"),
|
||||
json!({}),
|
||||
&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_RUNTIME_UNSUPPORTED");
|
||||
assert_eq!(json["details"]["workspace_path"], "/tmp/my project ");
|
||||
assert_eq!(json["details"]["operation"], "runtime");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_3_warmup_requires_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/conversations/some-id/warmup")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
Reference in New Issue
Block a user