//! Shared test helpers for nomifun-app E2E tests. #![allow(dead_code)] use axum::body::Body; use axum::http::{Request, StatusCode, header}; use http_body_util::BodyExt; use tower::ServiceExt; use wiremock::MockServer; use nomifun_ai_agent::{AgentInstance, IAgentTask, IMockAgent, WorkerTaskManagerImpl}; use nomifun_app::{AppConfig, AppServices, build_module_states, create_router, create_router_with_states}; use nomifun_extension::{ExternalPathsManager, SkillPaths, SkillRouterState}; use nomifun_file::FileService; use nomifun_system::VersionCheckService; pub async fn build_app() -> (axum::Router, AppServices) { let db = nomifun_db::init_database_memory().await.unwrap(); let services = AppServices::from_config(db, &AppConfig::default()).await.unwrap(); let router = create_router(&services).await; (router, services) } /// Build an app whose skill router reads from the given temp directories. /// /// Use for HTTP integration tests that need deterministic on-disk layouts /// (E1 `/api/skills`, E2 `/api/skills/builtin-auto`, E3/E4 built-in reads, /// E5 `/api/skills/info`). Returns the router, services, and the /// `SkillPaths` so the test can seed fixtures at known locations. #[allow(dead_code)] pub async fn build_app_with_skill_paths(root: &std::path::Path) -> (axum::Router, AppServices, SkillPaths) { let db = nomifun_db::init_database_memory().await.unwrap(); let services = AppServices::from_config(db, &AppConfig::default()).await.unwrap(); let (mut states, _) = build_module_states(&services).await; let builtin_dir = root.join("builtin-skills"); let paths = SkillPaths { data_dir: root.to_path_buf(), user_skills_dir: root.join("skills"), cron_skills_dir: root.join("cron").join("skills"), builtin_skills_dir: builtin_dir.clone(), builtin_rules_dir: root.join("builtin-rules"), assistant_rules_dir: root.join("assistant-rules"), assistant_skills_dir: root.join("assistant-skills"), }; for dir in [ &paths.user_skills_dir, &builtin_dir, &paths.builtin_rules_dir, &paths.assistant_rules_dir, &paths.assistant_skills_dir, ] { std::fs::create_dir_all(dir).unwrap(); } let ext_paths_mgr = std::sync::Arc::new(ExternalPathsManager::with_file(root.join("paths.json")).await); states.skill = SkillRouterState { skill_paths: paths.clone(), external_paths_manager: ext_paths_mgr, assistant_dispatcher: states.skill.assistant_dispatcher.clone(), skill_tag_repo: std::sync::Arc::new(nomifun_db::SqliteSkillTagRepository::new( services.database.pool().clone(), )), builtin_skill_tags: std::sync::Arc::new(std::collections::HashMap::new()), }; let router = create_router_with_states(&services, states); (router, services, paths) } pub async fn build_app_with_noop_opener() -> (axum::Router, AppServices) { let db = nomifun_db::init_database_memory().await.unwrap(); let services = AppServices::from_config(db, &AppConfig::default()).await.unwrap(); let (mut states, _) = build_module_states(&services).await; states.shell.shell_service = std::sync::Arc::new(nomifun_shell::ShellService::new(std::sync::Arc::new( nomifun_shell::NoopSystemOpener, ))); let router = create_router_with_states(&services, states); (router, services) } pub async fn build_app_with_file_roots(allowed_roots: Vec) -> (axum::Router, AppServices) { let db = nomifun_db::init_database_memory().await.unwrap(); let services = AppServices::from_config(db, &AppConfig::default()).await.unwrap(); let (mut states, _) = build_module_states(&services).await; states.file.file_service = std::sync::Arc::new(FileService::new(services.event_bus.clone(), allowed_roots)); let router = create_router_with_states(&services, states); (router, services) } pub async fn build_app_with_mock_version( current_version: &str, mock_server: &MockServer, ) -> (axum::Router, AppServices) { let db = nomifun_db::init_database_memory().await.unwrap(); let services = AppServices::from_config(db, &AppConfig::default()).await.unwrap(); let (mut states, _) = build_module_states(&services).await; states.system.version_check_service = VersionCheckService::with_api_base(reqwest::Client::new(), current_version.to_owned(), mock_server.uri()); let router = create_router_with_states(&services, states); (router, services) } /// Build app with a mock worker task manager that returns noop agents. /// /// Use for tests that exercise session/warmup paths (team ensure_session, /// send_message) where spawning a real CLI process is not feasible. pub async fn build_app_with_mock_agents() -> (axum::Router, AppServices) { let db = nomifun_db::init_database_memory().await.unwrap(); let factory: std::sync::Arc< dyn Fn( nomifun_ai_agent::types::BuildTaskOptions, ) -> futures_util::future::BoxFuture<'static, Result> + Send + Sync, > = std::sync::Arc::new(|opts| { Box::pin(async move { Ok(AgentInstance::Mock(std::sync::Arc::new(NoopMockAgent { conversation_id: opts.conversation_id, }))) }) }); let wtm: std::sync::Arc = std::sync::Arc::new(WorkerTaskManagerImpl::new(factory)); let services = AppServices::from_config(db, &AppConfig::default()) .await .unwrap() .with_worker_task_manager(wtm); let router = create_router(&services).await; (router, services) } struct NoopMockAgent { conversation_id: String, } #[async_trait::async_trait] impl IAgentTask for NoopMockAgent { fn agent_type(&self) -> nomifun_common::AgentType { nomifun_common::AgentType::Acp } fn conversation_id(&self) -> &str { &self.conversation_id } fn workspace(&self) -> &str { "/tmp/test" } fn status(&self) -> Option { None } fn last_activity_at(&self) -> nomifun_common::TimestampMs { nomifun_common::now_ms() } fn subscribe(&self) -> tokio::sync::broadcast::Receiver { let (tx, _) = tokio::sync::broadcast::channel(1); tx.subscribe() } async fn send_message( &self, _data: nomifun_ai_agent::types::SendMessageData, ) -> Result<(), nomifun_ai_agent::AgentSendError> { Ok(()) } async fn cancel(&self) -> Result<(), nomifun_common::AppError> { Ok(()) } fn kill(&self, _reason: Option) -> Result<(), nomifun_common::AppError> { Ok(()) } } #[async_trait::async_trait] impl IMockAgent for NoopMockAgent {} pub async fn body_json(resp: axum::response::Response) -> serde_json::Value { let bytes = resp.into_body().collect().await.unwrap().to_bytes(); serde_json::from_slice(&bytes).unwrap() } pub fn extract_csrf_token(resp: &axum::response::Response) -> Option { resp.headers() .get_all(header::SET_COOKIE) .iter() .filter_map(|v| v.to_str().ok()) .find(|s| s.starts_with("nomifun-csrf-token=")) .map(|s| { s.strip_prefix("nomifun-csrf-token=") .unwrap() .split(';') .next() .unwrap() .to_owned() }) } pub fn get_request(uri: &str) -> Request { Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap() } pub fn get_with_token(uri: &str, token: &str) -> Request { Request::builder() .method("GET") .uri(uri) .header("authorization", format!("Bearer {token}")) .body(Body::empty()) .unwrap() } pub fn json_with_token(method_str: &str, uri: &str, body: serde_json::Value, token: &str, csrf: &str) -> Request { Request::builder() .method(method_str) .uri(uri) .header("content-type", "application/json") .header("authorization", format!("Bearer {token}")) .header("x-csrf-token", csrf) .header("cookie", format!("nomifun-csrf-token={csrf}")) .body(Body::from(serde_json::to_vec(&body).unwrap())) .unwrap() } pub fn delete_with_token(uri: &str, token: &str, csrf: &str) -> Request { Request::builder() .method("DELETE") .uri(uri) .header("authorization", format!("Bearer {token}")) .header("x-csrf-token", csrf) .header("cookie", format!("nomifun-csrf-token={csrf}")) .body(Body::empty()) .unwrap() } /// Set up a user and login, returning (session_token, csrf_token). /// /// The seeded `system_default_user` row already uses `username = "admin"`; if /// the test asks for that username, overwrite the seed row's empty credentials /// in place instead of trying to INSERT a duplicate. pub async fn setup_and_login( app: &mut axum::Router, services: &AppServices, username: &str, password: &str, ) -> (String, String) { let hash = nomifun_auth::hash_password(password).unwrap(); if username == "admin" { services .user_repo .set_system_user_credentials(username, &hash) .await .unwrap(); } else { services.user_repo.create_user(username, &hash).await.unwrap(); } let resp = app.clone().oneshot(get_request("/api/auth/status")).await.unwrap(); let csrf = extract_csrf_token(&resp).expect("CSRF cookie should be set"); let body = format!(r#"{{"username":"{username}","password":"{password}"}}"#); let req = Request::builder() .method("POST") .uri("/login") .header("content-type", "application/json") .body(Body::from(body)) .unwrap(); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK, "login should succeed"); let json = body_json(resp).await; let token = json["token"].as_str().unwrap().to_owned(); (token, csrf) }