use std::collections::HashMap;
use std::sync::Arc;
use crate::rate_limit::GlobalRateLimitLayer;
use async_trait::async_trait;
use axum::Json;
use axum::body::Body;
use axum::extract::{Query, State as AxumState};
use axum::http::{Request, StatusCode};
use axum::routing::get;
use roboticus_agent::policy::{AuthorityRule, CommandSafetyRule, PolicyEngine};
use roboticus_agent::subagents::SubagentRegistry;
use roboticus_browser::Browser;
use roboticus_channels::a2a::A2aProtocol;
use roboticus_channels::router::ChannelRouter;
use roboticus_channels::telegram::TelegramAdapter;
use roboticus_channels::whatsapp::WhatsAppAdapter;
use roboticus_channels::{ChannelAdapter, InboundMessage, OutboundMessage};
use roboticus_core::InputAuthority;
use roboticus_db::Database;
use roboticus_llm::LlmService;
use roboticus_llm::OAuthManager;
use roboticus_plugin_sdk::registry::PluginRegistry;
use roboticus_plugin_sdk::{Plugin, ToolDef, ToolResult};
use serde_json::json;
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tower::ServiceExt;
use roboticus_agent::approvals::ApprovalManager;
use roboticus_agent::tools::ToolRegistry;
use super::*;
#[path = "tests/channel_and_pipeline_tests.rs"]
mod channel_and_pipeline_tests;
#[path = "tests/composition_workflow_tests.rs"]
mod composition_workflow_tests;
#[path = "tests/context.rs"]
mod context;
#[path = "tests/financial_tests.rs"]
mod financial_tests;
#[path = "tests/mock_and_integration_tests.rs"]
mod mock_and_integration_tests;
#[path = "tests/regression_tests.rs"]
mod regression_tests;
#[path = "tests/track1_observability_tests.rs"]
mod track1_observability_tests;
fn test_config_str() -> &'static str {
r#"
[agent]
name = "TestBot"
id = "test"
[server]
port = 9999
[database]
path = ":memory:"
[session]
scope_mode = "agent"
[models]
primary = "ollama/qwen3:8b"
"#
}
pub(crate) fn test_state() -> AppState {
let db = Database::new(":memory:").unwrap();
db.conn()
.execute_batch(
"CREATE TABLE IF NOT EXISTS task_events ( \
id TEXT PRIMARY KEY, \
task_id TEXT NOT NULL, \
parent_task_id TEXT, \
assigned_to TEXT, \
event_type TEXT NOT NULL CHECK ( \
event_type IN ('pending','assigned','running','progress','completed','failed','cancelled','retry') \
), \
summary TEXT, \
detail_json TEXT, \
percentage REAL, \
retry_count INTEGER NOT NULL DEFAULT 0, \
created_at TEXT NOT NULL DEFAULT (datetime('now')) \
); \
CREATE INDEX IF NOT EXISTS idx_task_events_task_id ON task_events(task_id); \
CREATE INDEX IF NOT EXISTS idx_task_events_parent ON task_events(parent_task_id); \
CREATE INDEX IF NOT EXISTS idx_task_events_assigned_to ON task_events(assigned_to); \
CREATE INDEX IF NOT EXISTS idx_task_events_created ON task_events(created_at DESC); \
CREATE INDEX IF NOT EXISTS idx_task_events_type ON task_events(event_type);",
)
.unwrap();
let config = roboticus_core::RoboticusConfig::from_str(test_config_str()).unwrap();
let llm = LlmService::new(&config).unwrap();
let a2a = A2aProtocol::new(config.a2a.clone());
let wallet = roboticus_wallet::Wallet::test_mock();
let treasury = roboticus_wallet::TreasuryPolicy::new(&config.treasury);
let yield_engine = roboticus_wallet::YieldEngine::new(&config.r#yield);
let wallet_svc = roboticus_wallet::WalletService {
wallet,
treasury,
yield_engine,
};
let plugins = Arc::new(PluginRegistry::new(
vec![],
vec![],
roboticus_plugin_sdk::registry::PermissionPolicy {
strict: false,
allowed: vec![],
},
));
let mut policy_engine = PolicyEngine::new();
policy_engine.add_rule(Box::new(AuthorityRule));
policy_engine.add_rule(Box::new(CommandSafetyRule));
let policy_engine = Arc::new(policy_engine);
let browser = Arc::new(Browser::new(
roboticus_core::config::BrowserConfig::default(),
));
let registry = Arc::new(SubagentRegistry::new(4, vec![]));
let event_bus = EventBus::new(256);
let channel_router = Arc::new(ChannelRouter::new());
let retriever = Arc::new(roboticus_agent::retrieval::MemoryRetriever::new(
config.memory.clone(),
));
let config_path = std::env::temp_dir().join(format!(
"roboticus-test-config-{}.toml",
uuid::Uuid::new_v4()
));
let config_toml = toml::to_string_pretty(&config).expect("serialize test config");
std::fs::write(&config_path, config_toml).expect("write test config file");
AppState {
db,
config: Arc::new(RwLock::new(config)),
llm: Arc::new(RwLock::new(llm)),
wallet: Arc::new(wallet_svc),
a2a: Arc::new(RwLock::new(a2a)),
personality: Arc::new(RwLock::new(PersonalityState::empty())),
hmac_secret: Arc::new(b"test-hmac-secret-key-for-tests!!".to_vec()),
interviews: Arc::new(RwLock::new(HashMap::new())),
plugins,
policy_engine,
browser,
registry,
event_bus,
channel_router,
telegram: None,
whatsapp: None,
retriever,
ann_index: roboticus_db::ann::AnnIndex::new(false),
tools: Arc::new(ToolRegistry::new()),
capabilities: Arc::new(roboticus_agent::capability::CapabilityRegistry::new()),
approvals: Arc::new(ApprovalManager::new(
roboticus_core::config::ApprovalsConfig::default(),
)),
discord: None,
signal: None,
email: None,
voice: None,
discovery: Arc::new(RwLock::new(
roboticus_agent::discovery::DiscoveryRegistry::new(),
)),
devices: Arc::new(RwLock::new(roboticus_agent::device::DeviceManager::new(
roboticus_agent::device::DeviceIdentity::generate("test-device"),
5,
))),
mcp_clients: Arc::new(RwLock::new(roboticus_agent::mcp::McpClientManager::new())),
mcp_server: Arc::new(RwLock::new(roboticus_agent::mcp::McpServerRegistry::new())),
live_mcp: Arc::new(roboticus_agent::mcp::manager::McpConnectionManager::new()),
oauth: Arc::new(OAuthManager::new().unwrap()),
keystore: Arc::new(roboticus_core::keystore::Keystore::new(
std::env::temp_dir().join(format!("roboticus-test-ks-{}.enc", uuid::Uuid::new_v4())),
)),
obsidian: None,
started_at: std::time::Instant::now(),
config_path: Arc::new(config_path.clone()),
config_apply_status: Arc::new(RwLock::new(ConfigApplyStatus::new(&config_path))),
pending_specialist_proposals: Arc::new(RwLock::new(HashMap::new())),
ws_tickets: crate::ws_ticket::TicketStore::new(),
rate_limiter: crate::rate_limit::GlobalRateLimitLayer::new(
100,
std::time::Duration::from_secs(60),
),
media_service: None,
semantic_classifier: Arc::new(roboticus_llm::semantic_classifier::SemanticClassifier::new(
roboticus_llm::EmbeddingClient::new(None).unwrap(),
)),
}
}
fn test_state_with_telegram_webhook_secret(secret: &str) -> AppState {
let mut state = test_state();
let adapter = TelegramAdapter::with_config(
"test-bot-token".into(),
30,
vec![],
Some(secret.to_string()),
false,
);
state.telegram = Some(Arc::new(adapter));
state
}
fn test_state_with_whatsapp_app_secret(secret: &str) -> AppState {
let mut state = test_state();
let adapter = WhatsAppAdapter::with_config(
"test-token".into(),
"phone-id".into(),
"verify-token".into(),
vec![],
Some(secret.to_string()),
false,
)
.unwrap();
state.whatsapp = Some(Arc::new(adapter));
state
}
fn full_app(state: AppState) -> Router {
build_router(state.clone()).merge(build_public_router(state))
}
async fn json_body(resp: axum::http::Response<Body>) -> serde_json::Value {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
async fn text_body(resp: axum::http::Response<Body>) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn health_returns_ok() {
let app = build_router(test_state());
let req = Request::builder()
.uri("/api/health")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["status"], "ok");
assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
assert!(
body["uptime_seconds"].as_u64().is_some(),
"uptime_seconds should be a number"
);
}
#[tokio::test]
async fn logs_endpoint_returns_valid_json() {
let app = build_router(test_state());
let req = Request::builder()
.uri("/api/logs")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
let entries = body
.get("entries")
.expect("response must have 'entries' key");
assert!(entries.is_array(), "entries must be a JSON array");
}
#[tokio::test]
async fn create_and_get_session() {
let state = test_state();
let app = build_router(state);
let req = Request::builder()
.method("POST")
.uri("/api/sessions")
.header("content-type", "application/json")
.body(Body::from(r#"{"agent_id":"test-agent"}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
let session_id = body["id"].as_str().unwrap().to_string();
assert!(!session_id.is_empty());
}
#[tokio::test]
async fn get_session_not_found() {
let app = build_router(test_state());
let req = Request::builder()
.uri("/api/sessions/nonexistent-id")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn post_and_list_messages() {
let state = test_state();
let session_id = roboticus_db::sessions::find_or_create(&state.db, "agent-1", None).unwrap();
let app = build_router(state.clone());
let req = Request::builder()
.method("POST")
.uri(format!("/api/sessions/{session_id}/messages"))
.header("content-type", "application/json")
.body(Body::from(r#"{"role":"user","content":"hello"}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let app = build_router(state);
let req = Request::builder()
.uri(format!("/api/sessions/{session_id}/messages"))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let body = json_body(resp).await;
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
assert_eq!(messages[0]["content"], "hello");
}
#[tokio::test]
async fn list_skills_includes_built_ins() {
let app = build_router(test_state());
let req = Request::builder()
.uri("/api/skills")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
let skills = body["skills"].as_array().unwrap();
assert!(!skills.is_empty());
assert!(
skills
.iter()
.all(|s| s["enabled"].as_bool().unwrap_or(false))
);
assert!(
skills
.iter()
.any(|s| s["name"].as_str() == Some("supervisor-protocol"))
);
}
#[tokio::test]
async fn list_skills_exposes_usage_telemetry() {
let state = test_state();
let skill_id = roboticus_db::skills::register_skill(
&state.db,
"telemetry-skill",
"instruction",
Some("Tracks usage"),
"/tmp/telemetry-skill.md",
"hash",
Some("[\"telemetry\"]"),
None,
None,
None,
Some("Safe"),
)
.unwrap();
roboticus_db::skills::record_skill_usage(&state.db, &skill_id).unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/skills")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
let skill = body["skills"]
.as_array()
.unwrap()
.iter()
.find(|s| s["id"] == skill_id)
.cloned()
.expect("registered skill should appear in listing");
assert_eq!(skill["usage_count"], 1);
assert!(skill["last_used_at"].as_str().is_some());
}
#[tokio::test]
async fn agent_status_returns_running() {
let app = build_router(test_state());
let req = Request::builder()
.uri("/api/agent/status")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["state"], "running");
}
#[tokio::test]
async fn get_config_returns_config_without_secrets() {
let app = build_router(test_state());
let req = Request::builder()
.uri("/api/config")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert!(body.get("agent").is_some());
assert!(body.get("server").is_some());
}
#[tokio::test]
async fn put_config_updates_runtime_config() {
let state = test_state();
let app = build_router(state);
let req = Request::builder()
.method("PUT")
.uri("/api/config")
.header("content-type", "application/json")
.body(Body::from(r#"{"agent":{"name":"UpdatedBot"}}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["updated"], true);
assert_eq!(body["persisted"], true);
assert!(body["backup_path"].is_string());
}
#[tokio::test]
async fn put_config_routing_weights_persist_round_trip() {
let state = test_state();
let app = build_router(state.clone());
let patch = r#"{
"models": {
"routing": {
"accuracy_floor": 0.42,
"cost_weight": 0.31,
"cost_aware": true,
"confidence_threshold": 0.77,
"estimated_output_tokens": 640
}
}
}"#;
let put_resp = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/config")
.header("content-type", "application/json")
.body(Body::from(patch))
.unwrap(),
)
.await
.unwrap();
assert_eq!(put_resp.status(), StatusCode::OK);
let put_body = json_body(put_resp).await;
assert_eq!(put_body["persisted"], true);
let get_resp = app
.oneshot(
Request::builder()
.uri("/api/config")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(get_resp.status(), StatusCode::OK);
let cfg = json_body(get_resp).await;
assert_eq!(cfg["models"]["routing"]["accuracy_floor"], 0.42);
assert_eq!(cfg["models"]["routing"]["cost_weight"], 0.31);
assert_eq!(cfg["models"]["routing"]["cost_aware"], true);
assert_eq!(cfg["models"]["routing"]["confidence_threshold"], 0.77);
assert_eq!(cfg["models"]["routing"]["estimated_output_tokens"], 640);
}
#[tokio::test]
async fn put_config_context_budget_persist_round_trip() {
let state = test_state();
let app = build_router(state.clone());
let patch = r#"{
"context_budget": {
"l0": 4500,
"l1": 9000,
"l2": 36000,
"l3": 72000,
"channel_minimum": "L2"
}
}"#;
let put_resp = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/config")
.header("content-type", "application/json")
.body(Body::from(patch))
.unwrap(),
)
.await
.unwrap();
assert_eq!(put_resp.status(), StatusCode::OK);
let put_body = json_body(put_resp).await;
assert_eq!(put_body["persisted"], true);
assert_eq!(put_body["ignored_keys"], json!([]));
let get_resp = app
.oneshot(
Request::builder()
.uri("/api/config")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(get_resp.status(), StatusCode::OK);
let cfg = json_body(get_resp).await;
assert_eq!(cfg["context_budget"]["l0"], 4500);
assert_eq!(cfg["context_budget"]["l1"], 9000);
assert_eq!(cfg["context_budget"]["l2"], 36000);
assert_eq!(cfg["context_budget"]["l3"], 72000);
assert_eq!(cfg["context_budget"]["channel_minimum"], "L2");
}
#[tokio::test]
async fn get_config_raw_returns_toml_text() {
let state = test_state();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/config/raw")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let content_type = resp
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(content_type.starts_with("text/plain"));
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert!(text.contains("[agent]"));
assert!(text.contains("[server]"));
}
#[tokio::test]
async fn put_config_raw_persists_round_trip() {
let state = test_state();
let app = build_router(state.clone());
let raw = r#"
[agent]
name = "RawBot"
id = "test-agent"
workspace = "/tmp/workspace"
log_level = "info"
[server]
bind = "127.0.0.1"
port = 18789
[database]
path = ":memory:"
[models]
primary = "ollama/qwen3:8b"
"#;
let put_resp = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/config/raw")
.header("content-type", "text/plain")
.body(Body::from(raw))
.unwrap(),
)
.await
.unwrap();
assert_eq!(put_resp.status(), StatusCode::OK);
let put_body = json_body(put_resp).await;
assert_eq!(put_body["persisted"], true);
let get_resp = app
.oneshot(
Request::builder()
.uri("/api/config")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(get_resp.status(), StatusCode::OK);
let cfg = json_body(get_resp).await;
assert_eq!(cfg["agent"]["name"], "RawBot");
}
#[tokio::test]
async fn routing_diagnostics_exposes_full_routing_tunables() {
let state = test_state();
{
let mut config = state.config.write().await;
config.models.routing.confidence_threshold = 0.73;
config.models.routing.estimated_output_tokens = 777;
}
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/models/routing-diagnostics")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["config"]["confidence_threshold"], 0.73);
assert_eq!(body["config"]["estimated_output_tokens"], 777);
}
#[tokio::test]
async fn put_config_rejects_invalid() {
let state = test_state();
let old_name = state.config.read().await.agent.name.clone();
let app = build_router(state.clone());
let req = Request::builder()
.method("PUT")
.uri("/api/config")
.header("content-type", "application/json")
.body(Body::from(r#"{"memory":{"working_budget_pct":200}}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let current_name = state.config.read().await.agent.name.clone();
assert_eq!(current_name, old_name);
}
#[tokio::test]
async fn get_session_ok() {
let state = test_state();
let session_id = roboticus_db::sessions::find_or_create(&state.db, "agent-1", None).unwrap();
let app = build_router(state);
let req = Request::builder()
.uri(format!("/api/sessions/{session_id}"))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["id"], session_id);
assert_eq!(body["agent_id"], "agent-1");
}
#[tokio::test]
async fn list_sessions_returns_array() {
let app = build_router(test_state());
let req = Request::builder()
.uri("/api/sessions")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
let sessions = body["sessions"].as_array().unwrap();
assert!(sessions.is_empty());
}
#[tokio::test]
async fn get_working_memory_returns_entries() {
let state = test_state();
let session_id = roboticus_db::sessions::find_or_create(&state.db, "agent-1", None).unwrap();
let app = build_router(state);
let req = Request::builder()
.uri(format!("/api/memory/working/{session_id}"))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert!(body["entries"].as_array().is_some());
}
#[tokio::test]
async fn get_episodic_memory_returns_entries() {
let app = build_router(test_state());
let req = Request::builder()
.uri("/api/memory/episodic")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
let entries = body["entries"].as_array().unwrap();
assert!(entries.is_empty());
}
#[tokio::test]
async fn get_episodic_memory_with_limit() {
let app = build_router(test_state());
let req = Request::builder()
.uri("/api/memory/episodic?limit=5")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert!(body["entries"].as_array().is_some());
}
#[tokio::test]
async fn get_semantic_memory_returns_entries() {
let app = build_router(test_state());
let req = Request::builder()
.uri("/api/memory/semantic/foo")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
let entries = body["entries"].as_array().unwrap();
assert!(entries.is_empty());
}
#[tokio::test]
async fn memory_search_with_q_returns_results() {
let app = build_router(test_state());
let req = Request::builder()
.uri("/api/memory/search?q=test")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert!(body["results"].as_array().is_some());
}
#[tokio::test]
async fn memory_search_missing_q_returns_400() {
let app = build_router(test_state());
let req = Request::builder()
.uri("/api/memory/search")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = text_body(resp).await;
assert!(body.contains("missing"));
}
#[tokio::test]
async fn memory_search_fts5_operator_stripping() {
let app = build_router(test_state());
let with_ops = Request::builder()
.uri("/api/memory/search?q=foo+AND+bar+OR+NOT+baz")
.body(Body::empty())
.unwrap();
let without_ops = Request::builder()
.uri("/api/memory/search?q=foo+bar+baz")
.body(Body::empty())
.unwrap();
let resp_with = app.clone().oneshot(with_ops).await.unwrap();
let resp_without = app.oneshot(without_ops).await.unwrap();
assert_eq!(resp_with.status(), StatusCode::OK);
assert_eq!(resp_without.status(), StatusCode::OK);
let json_with = json_body(resp_with).await;
let json_without = json_body(resp_without).await;
let results_with = json_with["results"].as_array().unwrap();
let results_without = json_without["results"].as_array().unwrap();
assert_eq!(
results_with.len(),
results_without.len(),
"FTS5 operator stripping should yield same result count"
);
}
#[tokio::test]
async fn knowledge_ingest_rejects_path_outside_workspace() {
let state = test_state();
let workspace = tempfile::tempdir().unwrap();
{
let mut cfg = state.config.write().await;
cfg.agent.workspace = workspace.path().to_path_buf();
}
let app = build_router(state);
let outside = std::env::temp_dir().join(format!("ic-outside-{}.txt", uuid::Uuid::new_v4()));
std::fs::write(&outside, b"secret").unwrap();
let req = Request::builder()
.method("POST")
.uri("/api/knowledge/ingest")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({ "path": outside.to_string_lossy() }).to_string(),
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = text_body(resp).await;
assert!(body.contains("escapes workspace root"));
let _ = std::fs::remove_file(outside);
}
#[tokio::test]
async fn knowledge_ingest_rejects_missing_workspace_root() {
let state = test_state();
let missing =
std::env::temp_dir().join(format!("ic-missing-workspace-{}", uuid::Uuid::new_v4()));
{
let mut cfg = state.config.write().await;
cfg.agent.workspace = missing.clone();
}
let app = build_router(state);
let req = Request::builder()
.method("POST")
.uri("/api/knowledge/ingest")
.header("content-type", "application/json")
.body(Body::from(r#"{"path":"README.md"}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = text_body(resp).await;
assert!(body.contains("workspace root"));
}
#[tokio::test]
async fn list_cron_jobs_returns_array() {
let app = build_router(test_state());
let req = Request::builder()
.uri("/api/cron/jobs")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
let jobs = body["jobs"].as_array().unwrap();
assert!(jobs.is_empty());
}
#[tokio::test]
async fn create_cron_job_returns_job_id() {
let app = build_router(test_state());
let req = Request::builder()
.method("POST")
.uri("/api/cron/jobs")
.header("content-type", "application/json")
.body(Body::from(
r#"{"name":"test-job","agent_id":"test","schedule_kind":"interval","schedule_expr":"1h"}"#,
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert!(!body["job_id"].as_str().unwrap().is_empty());
}
#[tokio::test]
async fn create_cron_job_defaults_payload_to_agent_task_when_description_present() {
let state = test_state();
let app = build_router(state.clone());
let req = Request::builder()
.method("POST")
.uri("/api/cron/jobs")
.header("content-type", "application/json")
.body(Body::from(
r#"{"name":"morning-briefing","description":"summarize overnight events","agent_id":"test","schedule_kind":"cron","schedule_expr":"0 9 * * *"}"#,
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
let job_id = body["job_id"].as_str().unwrap().to_string();
let job = roboticus_db::cron::get_job(&state.db, &job_id)
.unwrap()
.expect("job should exist");
let payload: serde_json::Value =
serde_json::from_str(&job.payload_json).expect("payload should be valid JSON");
assert_eq!(payload["action"], "agent_task");
assert_eq!(payload["task"], "summarize overnight events");
}
#[tokio::test]
async fn create_cron_job_persists_description_field() {
let state = test_state();
let app = build_router(state.clone());
let req = Request::builder()
.method("POST")
.uri("/api/cron/jobs")
.header("content-type", "application/json")
.body(Body::from(
r#"{"name":"morning-briefing","description":"summarize overnight events","agent_id":"test","schedule_kind":"cron","schedule_expr":"0 9 * * *"}"#,
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
let job_id = body["job_id"].as_str().unwrap().to_string();
let job = roboticus_db::cron::get_job(&state.db, &job_id)
.unwrap()
.expect("job should exist");
assert_eq!(
job.description.as_deref(),
Some("summarize overnight events")
);
}
#[tokio::test]
async fn get_cron_job_returns_detail() {
let state = test_state();
let job_id = roboticus_db::cron::create_job(
&state.db,
"heartbeat",
"agent-1",
"every",
None,
r#"{"action":"ping"}"#,
)
.unwrap();
let app = build_router(state);
let req = Request::builder()
.uri(format!("/api/cron/jobs/{job_id}"))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["id"], job_id);
assert_eq!(body["name"], "heartbeat");
assert_eq!(body["agent_id"], "agent-1");
}
#[tokio::test]
async fn get_cron_job_returns_404_for_missing() {
let app = build_router(test_state());
let req = Request::builder()
.uri("/api/cron/jobs/nonexistent-id")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn run_cron_job_now_executes_and_records_run() {
let state = test_state();
let job_id = roboticus_db::cron::create_job(
&state.db,
"run-now",
"agent-1",
"cron",
Some("0 * * * *"),
r#"{"action":"noop"}"#,
)
.unwrap();
let app = build_router(state.clone());
let req = Request::builder()
.method("POST")
.uri(format!("/api/cron/jobs/{job_id}/run"))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["job_id"], job_id);
assert_eq!(body["status"], "success");
let runs = roboticus_db::cron::list_runs(&state.db, None, None, Some(&job_id), 10).unwrap();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].status, "success");
}
#[tokio::test]
async fn run_cron_job_now_returns_output_text_for_log_job() {
let state = test_state();
let job_id = roboticus_db::cron::create_job(
&state.db,
"run-now-log",
"agent-1",
"cron",
Some("0 * * * *"),
r#"{"action":"log","message":"hello from cron"}"#,
)
.unwrap();
let app = build_router(state.clone());
let req = Request::builder()
.method("POST")
.uri(format!("/api/cron/jobs/{job_id}/run"))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["status"], "success");
assert_eq!(body["output_text"], "hello from cron");
let runs = roboticus_db::cron::list_runs(&state.db, None, None, Some(&job_id), 10).unwrap();
assert_eq!(runs[0].output_text.as_deref(), Some("hello from cron"));
}
#[tokio::test]
async fn delete_cron_job_removes_job() {
let state = test_state();
let job_id = roboticus_db::cron::create_job(
&state.db,
"disposable",
"agent-1",
"cron",
Some("0 * * * *"),
"{}",
)
.unwrap();
let app = build_router(state);
let req = Request::builder()
.method("DELETE")
.uri(format!("/api/cron/jobs/{job_id}"))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["deleted"], true);
assert_eq!(body["id"], job_id);
}
#[tokio::test]
async fn delete_cron_job_returns_404_for_missing() {
let app = build_router(test_state());
let req = Request::builder()
.method("DELETE")
.uri("/api/cron/jobs/nonexistent-id")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}