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