f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
935 lines
33 KiB
Rust
935 lines
33 KiB
Rust
//! 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);
|
|
}
|