//! Test harness for constructing `AgentDeps` with sensible defaults. //! //! Provides: //! - [`StubLlm`]: A configurable LLM provider that returns a fixed response //! - [`StubChannel`]: A configurable channel stub with message injection and response capture //! - [`TestHarnessBuilder`]: Builder for wiring `AgentDeps` with defaults //! - [`TestHarness`]: The assembled components ready for use in tests //! //! # Usage //! //! ```rust,no_run //! use ironclaw::testing::TestHarnessBuilder; //! //! #[tokio::test] //! async fn test_something() { //! let harness = TestHarnessBuilder::new().build().await; //! // use harness.deps, harness.db, etc. //! } //! ``` pub mod credentials; pub mod fault_injection; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use async_trait::async_trait; use rust_decimal::Decimal; use tokio::sync::mpsc; use crate::agent::AgentDeps; use crate::channels::{ Channel, ChannelManager, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate, }; use crate::db::Database; use crate::error::{ChannelError, LlmError}; use crate::llm::{ CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ToolCompletionRequest, ToolCompletionResponse, }; use crate::tools::ToolRegistry; /// Create a libSQL-backed test database in a temporary directory. /// /// Returns the database and a `TempDir` guard — the database file is /// deleted when the guard is dropped. #[cfg(feature = "libsql")] pub async fn test_db() -> (Arc, tempfile::TempDir) { use crate::db::libsql::LibSqlBackend; let dir = tempfile::tempdir().expect("failed to create temp dir"); let path = dir.path().join("test.db"); let backend = LibSqlBackend::new_local(&path) .await .expect("failed to create test LibSqlBackend"); backend .run_migrations() .await .expect("failed to run migrations"); (Arc::new(backend) as Arc, dir) } /// What kind of error the stub should produce when failing. #[derive(Clone, Copy, Debug)] pub enum StubErrorKind { /// Transient/retryable error (`LlmError::RequestFailed`). Transient, /// Non-transient error (`LlmError::ContextLengthExceeded`). NonTransient, } /// A configurable LLM provider stub for tests. /// /// Supports: /// - Fixed response content /// - Call counting via [`calls()`](Self::calls) /// - Runtime failure toggling via [`set_failing()`](Self::set_failing) /// - Configurable error kinds (transient vs non-transient) /// /// Use this in tests instead of creating ad-hoc stub implementations. pub struct StubLlm { model_name: String, response: String, call_count: AtomicU32, should_fail: AtomicBool, error_kind: StubErrorKind, /// Optional fault injector for fine-grained failure control. /// When set, takes precedence over the `should_fail` / `error_kind` fields. fault_injector: Option>, } impl StubLlm { /// Create a new stub that returns the given response. pub fn new(response: impl Into) -> Self { Self { model_name: "stub-model".to_string(), response: response.into(), call_count: AtomicU32::new(0), should_fail: AtomicBool::new(false), error_kind: StubErrorKind::Transient, fault_injector: None, } } /// Create a stub that always fails with a transient error. pub fn failing(name: impl Into) -> Self { Self { model_name: name.into(), response: String::new(), call_count: AtomicU32::new(0), should_fail: AtomicBool::new(true), error_kind: StubErrorKind::Transient, fault_injector: None, } } /// Create a stub that always fails with a non-transient error. pub fn failing_non_transient(name: impl Into) -> Self { Self { model_name: name.into(), response: String::new(), call_count: AtomicU32::new(0), should_fail: AtomicBool::new(true), error_kind: StubErrorKind::NonTransient, fault_injector: None, } } /// Set the model name. pub fn with_model_name(mut self, name: impl Into) -> Self { self.model_name = name.into(); self } /// Get the number of times `complete` or `complete_with_tools` was called. pub fn calls(&self) -> u32 { self.call_count.load(Ordering::Relaxed) } /// Attach a fault injector for fine-grained failure control. /// /// When set, the injector's `next_action()` is consulted on every call, /// taking precedence over the `should_fail` / `error_kind` fields. pub fn with_fault_injector(mut self, injector: Arc) -> Self { self.fault_injector = Some(injector); self } /// Toggle whether calls should fail at runtime. pub fn set_failing(&self, fail: bool) { self.should_fail.store(fail, Ordering::Relaxed); } /// Check the fault injector or should_fail flag, returning an error if /// the call should fail, or None if it should succeed. async fn check_faults(&self) -> Option { if let Some(ref injector) = self.fault_injector { match injector.next_action() { fault_injection::FaultAction::Fail(fault) => { return Some(fault.to_llm_error(&self.model_name)); } fault_injection::FaultAction::Delay(duration) => { tokio::time::sleep(duration).await; } fault_injection::FaultAction::Succeed => {} } } else if self.should_fail.load(Ordering::Relaxed) { return Some(self.make_error()); } None } fn make_error(&self) -> LlmError { match self.error_kind { StubErrorKind::Transient => LlmError::RequestFailed { provider: self.model_name.clone(), reason: "server error".to_string(), }, StubErrorKind::NonTransient => LlmError::ContextLengthExceeded { used: 100_000, limit: 50_000, }, } } } impl Default for StubLlm { fn default() -> Self { Self::new("OK") } } #[async_trait] impl LlmProvider for StubLlm { fn model_name(&self) -> &str { &self.model_name } fn cost_per_token(&self) -> (Decimal, Decimal) { (Decimal::ZERO, Decimal::ZERO) } async fn complete(&self, _request: CompletionRequest) -> Result { self.call_count.fetch_add(1, Ordering::Relaxed); if let Some(err) = self.check_faults().await { return Err(err); } Ok(CompletionResponse { content: self.response.clone(), input_tokens: 10, output_tokens: 5, finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, }) } async fn complete_with_tools( &self, _request: ToolCompletionRequest, ) -> Result { self.call_count.fetch_add(1, Ordering::Relaxed); if let Some(err) = self.check_faults().await { return Err(err); } Ok(ToolCompletionResponse { content: Some(self.response.clone()), tool_calls: Vec::new(), input_tokens: 10, output_tokens: 5, finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, }) } } /// A configurable channel stub for tests. /// /// Supports: /// - Message injection via the returned `mpsc::Sender` /// - Response capture for assertion /// - Status update capture /// - Configurable health check failure /// /// # Usage /// /// ```rust,no_run /// let (channel, sender) = StubChannel::new("test"); /// sender.send(IncomingMessage::new("test", "user1", "hello")).await.unwrap(); /// // ... run agent logic that calls channel.respond() ... /// let responses = channel.captured_responses(); /// ``` pub struct StubChannel { name: String, rx: tokio::sync::Mutex>>, responses: Arc>>, statuses: Arc>>, healthy: AtomicBool, } impl StubChannel { /// Create a new stub channel and its message sender. /// /// The sender is used by tests to inject messages into the channel's stream. /// The channel captures all responses and status updates for later assertion. pub fn new(name: impl Into) -> (Self, mpsc::Sender) { let (tx, rx) = mpsc::channel(64); let channel = Self { name: name.into(), rx: tokio::sync::Mutex::new(Some(rx)), responses: Arc::new(Mutex::new(Vec::new())), statuses: Arc::new(Mutex::new(Vec::new())), healthy: AtomicBool::new(true), }; (channel, tx) } /// Get all captured (message, response) pairs. pub fn captured_responses(&self) -> Vec<(IncomingMessage, OutgoingResponse)> { self.responses.lock().expect("poisoned").clone() } /// Get a shared handle to the response capture list. /// /// Call this *before* moving the channel into a `ChannelManager`, /// since `add()` takes ownership. pub fn captured_responses_handle( &self, ) -> Arc>> { Arc::clone(&self.responses) } /// Get all captured status updates. pub fn captured_statuses(&self) -> Vec { self.statuses.lock().expect("poisoned").clone() } /// Get a shared handle to the status capture list. pub fn captured_statuses_handle(&self) -> Arc>> { Arc::clone(&self.statuses) } /// Set whether `health_check()` succeeds or fails. pub fn set_healthy(&self, healthy: bool) { self.healthy.store(healthy, Ordering::Relaxed); } } #[async_trait] impl Channel for StubChannel { fn name(&self) -> &str { &self.name } async fn start(&self) -> Result { let rx = self .rx .lock() .await .take() .ok_or_else(|| ChannelError::StartupFailed { name: self.name.clone(), reason: "start() already called".to_string(), })?; let stream = tokio_stream::wrappers::ReceiverStream::new(rx); Ok(Box::pin(stream)) } async fn respond( &self, msg: &IncomingMessage, response: OutgoingResponse, ) -> Result<(), ChannelError> { self.responses .lock() .expect("poisoned") .push((msg.clone(), response)); Ok(()) } async fn send_status( &self, status: StatusUpdate, _metadata: &serde_json::Value, ) -> Result<(), ChannelError> { self.statuses.lock().expect("poisoned").push(status); Ok(()) } async fn health_check(&self) -> Result<(), ChannelError> { if self.healthy.load(Ordering::Relaxed) { Ok(()) } else { Err(ChannelError::HealthCheckFailed { name: self.name.clone(), }) } } } /// Assembled test components. pub struct TestHarness { /// The agent dependencies, ready for use. pub deps: AgentDeps, /// Direct reference to the database (as `Arc`). pub db: Arc, /// Stub channel sender + manager, present if `with_stub_channel()` was called. pub channel: Option<(mpsc::Sender, ChannelManager)>, /// Temp directory guard — keeps the test database alive. Dropped /// automatically when the harness goes out of scope. #[cfg(feature = "libsql")] _temp_dir: tempfile::TempDir, } /// Builder for constructing a [`TestHarness`] with sensible defaults. /// /// All defaults are designed to work without any external services: /// - Database: libSQL in a temp directory (real SQL, FTS5, no network) /// - LLM: `StubLlm` returning "OK" /// - Safety: permissive config /// - Tools: builtin tools registered /// - Hooks: empty registry /// - Cost guard: no limits pub struct TestHarnessBuilder { db: Option>, llm: Option>, tools: Option>, stub_channel: bool, } impl TestHarnessBuilder { /// Create a new builder with all defaults. pub fn new() -> Self { Self { db: None, llm: None, tools: None, stub_channel: false, } } /// Override the database backend. pub fn with_db(mut self, db: Arc) -> Self { self.db = Some(db); self } /// Override the LLM provider. pub fn with_llm(mut self, llm: Arc) -> Self { self.llm = Some(llm); self } /// Override the tool registry. pub fn with_tools(mut self, tools: Arc) -> Self { self.tools = Some(tools); self } /// Include a `StubChannel` wired into a `ChannelManager`. /// /// The harness will expose the sender (for injecting messages) and /// the manager (for routing responses) via [`TestHarness::channel`]. pub fn with_stub_channel(mut self) -> Self { self.stub_channel = true; self } /// Build the harness with defaults applied. #[cfg(feature = "libsql")] pub async fn build(self) -> TestHarness { use crate::agent::cost_guard::{CostGuard, CostGuardConfig}; use crate::config::{SafetyConfig, SkillsConfig}; use crate::hooks::HookRegistry; use crate::safety::SafetyLayer; let (db, temp_dir) = if let Some(db) = self.db { // Caller provided a DB; create a dummy temp dir to satisfy the struct. let dir = tempfile::tempdir().expect("failed to create temp dir"); (db, dir) } else { test_db().await }; let llm: Arc = self.llm.unwrap_or_else(|| Arc::new(StubLlm::default())); let tools = self.tools.unwrap_or_else(|| { let t = Arc::new(ToolRegistry::new()); t.register_builtin_tools(); t }); let safety = Arc::new(SafetyLayer::new(&SafetyConfig { max_output_length: 100_000, injection_check_enabled: false, })); let hooks = Arc::new(HookRegistry::new()); let cost_guard = Arc::new(CostGuard::new(CostGuardConfig { max_cost_per_day_cents: None, max_actions_per_hour: None, })); let channel = if self.stub_channel { let (stub, sender) = StubChannel::new("stub"); let manager = ChannelManager::new(); manager.add(Box::new(stub)).await; Some((sender, manager)) } else { None }; let deps = AgentDeps { owner_id: "default".to_string(), store: Some(Arc::clone(&db)), llm, cheap_llm: None, safety, tools, workspace: None, extension_manager: None, skill_registry: None, skill_catalog: None, skills_config: SkillsConfig::default(), hooks, cost_guard, sse_tx: None, http_interceptor: None, transcription: None, document_extraction: None, }; TestHarness { deps, db, channel, _temp_dir: temp_dir, } } } impl Default for TestHarnessBuilder { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[cfg(feature = "libsql")] #[tokio::test] async fn test_harness_builds_with_defaults() { let harness = TestHarnessBuilder::new().build().await; assert!(harness.deps.store.is_some()); assert_eq!(harness.deps.llm.model_name(), "stub-model"); } #[cfg(feature = "libsql")] #[tokio::test] async fn test_harness_custom_llm() { let custom_llm = Arc::new(StubLlm::new("custom response").with_model_name("my-model")); let harness = TestHarnessBuilder::new().with_llm(custom_llm).build().await; assert_eq!(harness.deps.llm.model_name(), "my-model"); } #[cfg(feature = "libsql")] #[tokio::test] async fn test_harness_db_works() { let harness = TestHarnessBuilder::new().build().await; let id = harness .db .create_conversation("test", "user1", None) .await .expect("create conversation"); assert!(!id.is_nil()); } // === QA Plan P1 - 2.2: Turn persistence round-trip tests === #[cfg(feature = "libsql")] #[tokio::test] async fn test_conversation_message_round_trip() { let harness = TestHarnessBuilder::new().build().await; let db = &harness.db; let conv_id = db .create_conversation("tui", "alice", None) .await .expect("create conversation"); // Add several messages in order. let m1 = db .add_conversation_message(conv_id, "user", "Hello!") .await .expect("add msg 1"); let m2 = db .add_conversation_message(conv_id, "assistant", "Hi there!") .await .expect("add msg 2"); let m3 = db .add_conversation_message(conv_id, "user", "How are you?") .await .expect("add msg 3"); // IDs must be unique. assert_ne!(m1, m2); assert_ne!(m2, m3); // List messages and verify content + ordering. let msgs = db .list_conversation_messages(conv_id) .await .expect("list messages"); assert_eq!(msgs.len(), 3); assert_eq!(msgs[0].role, "user"); assert_eq!(msgs[0].content, "Hello!"); assert_eq!(msgs[1].role, "assistant"); assert_eq!(msgs[1].content, "Hi there!"); assert_eq!(msgs[2].role, "user"); assert_eq!(msgs[2].content, "How are you?"); // Timestamps should be monotonically non-decreasing. assert!(msgs[0].created_at <= msgs[1].created_at); assert!(msgs[1].created_at <= msgs[2].created_at); } #[cfg(feature = "libsql")] #[tokio::test] async fn test_conversation_metadata_persistence() { let harness = TestHarnessBuilder::new().build().await; let db = &harness.db; let conv_id = db .create_conversation("web", "bob", None) .await .expect("create conversation"); // Initially no metadata. let meta = db .get_conversation_metadata(conv_id) .await .expect("get metadata"); // May be None or empty object depending on backend. if let Some(m) = &meta { assert!(m.is_null() || m.as_object().is_none_or(|o| o.is_empty())); } // Set a metadata field. db.update_conversation_metadata_field( conv_id, "thread_type", &serde_json::json!("assistant"), ) .await .expect("set thread_type"); // Read it back. let meta = db .get_conversation_metadata(conv_id) .await .expect("get metadata after update") .expect("metadata should exist"); assert_eq!(meta["thread_type"], "assistant"); // Update with a second field — first field should still be there. db.update_conversation_metadata_field(conv_id, "model", &serde_json::json!("gpt-4")) .await .expect("set model"); let meta = db .get_conversation_metadata(conv_id) .await .expect("get metadata after second update") .expect("metadata should exist"); assert_eq!(meta["thread_type"], "assistant"); assert_eq!(meta["model"], "gpt-4"); } #[cfg(feature = "libsql")] #[tokio::test] async fn test_conversation_belongs_to_user() { let harness = TestHarnessBuilder::new().build().await; let db = &harness.db; let conv_id = db .create_conversation("tui", "alice", None) .await .expect("create conversation"); // Owner check should pass. assert!( db.conversation_belongs_to_user(conv_id, "alice") .await .expect("belongs check") ); // Different user should NOT own it. assert!( !db.conversation_belongs_to_user(conv_id, "mallory") .await .expect("belongs check other user") ); } #[cfg(feature = "libsql")] #[tokio::test] async fn test_ensure_conversation_idempotent() { let harness = TestHarnessBuilder::new().build().await; let db = &harness.db; let conv_id = uuid::Uuid::new_v4(); // ensure_conversation should create the row. assert!( db.ensure_conversation(conv_id, "web", "carol", None) .await .expect("ensure first"), "first ensure_conversation should create the row" ); // Calling again with the same ID should not error. assert!( db.ensure_conversation(conv_id, "web", "carol", None) .await .expect("ensure second (idempotent)"), "second ensure_conversation should touch owned row" ); // Should be able to add messages to it. let msg_id = db .add_conversation_message(conv_id, "user", "test message") .await .expect("add message to ensured conversation"); assert!(!msg_id.is_nil()); // Verify the message is there. let msgs = db .list_conversation_messages(conv_id) .await .expect("list messages"); assert_eq!(msgs.len(), 1); assert_eq!(msgs[0].content, "test message"); } #[cfg(feature = "libsql")] #[tokio::test] async fn test_ensure_conversation_foreign_conflict_does_not_touch_last_activity() { let harness = TestHarnessBuilder::new().build().await; let db = &harness.db; let conv_id = db .create_conversation("web", "alice", None) .await .expect("create conversation"); let before = db .list_conversations_all_channels("alice", 10) .await .expect("list conversations before foreign ensure") .into_iter() .find(|c| c.id == conv_id) .expect("conversation must exist before foreign ensure") .last_activity; tokio::time::sleep(std::time::Duration::from_millis(25)).await; assert!( !db.ensure_conversation(conv_id, "web", "mallory", None) .await .expect("foreign ensure should not error"), "foreign ensure_conversation should report not ensured" ); let after = db .list_conversations_all_channels("alice", 10) .await .expect("list conversations after foreign ensure") .into_iter() .find(|c| c.id == conv_id) .expect("conversation must still exist after foreign ensure") .last_activity; assert_eq!( after, before, "foreign ensure_conversation should not mutate last_activity" ); } #[cfg(feature = "libsql")] #[tokio::test] async fn test_paginated_messages() { let harness = TestHarnessBuilder::new().build().await; let db = &harness.db; let conv_id = db .create_conversation("tui", "dave", None) .await .expect("create conversation"); // Add messages. for i in 0..5 { db.add_conversation_message(conv_id, "user", &format!("msg {i}")) .await .expect("add message"); } // First page with limit 3, no cursor. Returns newest-first. let (page1, has_more) = db .list_conversation_messages_paginated(conv_id, None, 3) .await .expect("page 1"); assert_eq!(page1.len(), 3, "first page should have 3 messages"); assert!(has_more, "should indicate more messages exist"); // Verify all messages can be retrieved with a large limit. let (all, _) = db .list_conversation_messages_paginated(conv_id, None, 100) .await .expect("all messages"); assert_eq!(all.len(), 5); // Messages are returned oldest-first (ascending created_at). for w in all.windows(2) { assert!( w[0].created_at <= w[1].created_at, "messages should be in ascending created_at order" ); } } #[cfg(feature = "libsql")] #[tokio::test] async fn test_conversations_with_preview() { let harness = TestHarnessBuilder::new().build().await; let db = &harness.db; // Create two conversations for the same user. let c1 = db .create_conversation("tui", "eve", None) .await .expect("create c1"); db.add_conversation_message(c1, "user", "First conversation opener") .await .expect("add msg to c1"); let c2 = db .create_conversation("tui", "eve", None) .await .expect("create c2"); db.add_conversation_message(c2, "user", "Second conversation opener") .await .expect("add msg to c2"); // List with preview. let summaries = db .list_conversations_with_preview("eve", "tui", 10) .await .expect("list with preview"); assert_eq!(summaries.len(), 2); // Both should have message_count >= 1. for s in &summaries { assert!(s.message_count >= 1); } } #[cfg(feature = "libsql")] #[tokio::test] async fn test_job_action_persistence() { use crate::context::{ActionRecord, JobContext, JobState}; let harness = TestHarnessBuilder::new().build().await; let db = &harness.db; let ctx = JobContext::with_user("user1", "Do something", "test task"); let job_id = ctx.job_id; // Save job. db.save_job(&ctx).await.expect("save job"); // Get job back. let fetched = db.get_job(job_id).await.expect("get job"); assert!(fetched.is_some()); let fetched = fetched.unwrap(); assert_eq!(fetched.job_id, job_id); // Save an action. let action = ActionRecord { id: uuid::Uuid::new_v4(), sequence: 1, tool_name: "echo".to_string(), input: serde_json::json!({"message": "hello"}), output_raw: Some("hello".to_string()), output_sanitized: None, sanitization_warnings: vec![], cost: None, duration: std::time::Duration::from_millis(42), success: true, error: None, executed_at: chrono::Utc::now(), }; db.save_action(job_id, &action).await.expect("save action"); // Retrieve actions. let actions = db.get_job_actions(job_id).await.expect("get actions"); assert_eq!(actions.len(), 1); assert_eq!(actions[0].tool_name, "echo"); assert_eq!(actions[0].output_raw, Some("hello".to_string())); assert!(actions[0].success); assert_eq!(actions[0].duration, std::time::Duration::from_millis(42)); // Update job status. db.update_job_status(job_id, JobState::Completed, None) .await .expect("update status"); let updated = db .get_job(job_id) .await .expect("get updated job") .expect("job should exist"); assert!(matches!(updated.state, JobState::Completed)); } #[tokio::test] async fn test_stub_llm_complete() { let llm = StubLlm::new("hello world"); let response = llm .complete(CompletionRequest::new(vec![])) .await .expect("complete"); assert_eq!(response.content, "hello world"); assert_eq!(response.finish_reason, FinishReason::Stop); } #[tokio::test] async fn test_stub_channel_inject_and_capture() { use futures::StreamExt; let (channel, sender) = StubChannel::new("test-channel"); // Start the channel to get the message stream let mut stream = channel.start().await.expect("start failed"); // Inject a message sender .send(IncomingMessage::new("test-channel", "user1", "hello")) .await .expect("send failed"); // Read it from the stream let msg = stream.next().await.expect("stream ended"); assert_eq!(msg.content, "hello"); assert_eq!(msg.user_id, "user1"); assert_eq!(msg.channel, "test-channel"); // Send a response and verify it was captured let response = OutgoingResponse::text("world"); channel .respond(&msg, response) .await .expect("respond failed"); let captured = channel.captured_responses(); assert_eq!(captured.len(), 1); assert_eq!(captured[0].1.content, "world"); } #[tokio::test] async fn test_stub_channel_health_check() { let (channel, _sender) = StubChannel::new("healthy"); channel.health_check().await.expect("health check failed"); channel.set_healthy(false); assert!(channel.health_check().await.is_err()); } // === Database CRUD coverage for untested trait methods === #[cfg(feature = "libsql")] #[tokio::test] async fn test_settings_crud() { let harness = TestHarnessBuilder::new().build().await; let db = &harness.db; // Initially no setting let val = db.get_setting("user1", "theme").await.expect("get"); assert!(val.is_none()); // Set a value db.set_setting("user1", "theme", &serde_json::json!("dark")) .await .expect("set"); // Read it back let val = db .get_setting("user1", "theme") .await .expect("get") .expect("should exist"); assert_eq!(val, serde_json::json!("dark")); // Update it db.set_setting("user1", "theme", &serde_json::json!("light")) .await .expect("set update"); let val = db .get_setting("user1", "theme") .await .expect("get") .expect("should exist"); assert_eq!(val, serde_json::json!("light")); // List settings let all = db.list_settings("user1").await.expect("list"); assert_eq!(all.len(), 1); // Delete let deleted = db.delete_setting("user1", "theme").await.expect("delete"); assert!(deleted); let val = db.get_setting("user1", "theme").await.expect("get"); assert!(val.is_none()); // Delete non-existent let deleted = db.delete_setting("user1", "theme").await.expect("delete"); assert!(!deleted); } #[tokio::test] async fn test_harness_with_channel() { let harness = TestHarnessBuilder::new().with_stub_channel().build().await; let (sender, channel_manager) = harness.channel.as_ref().expect("channel should be present"); // Inject a message via sender sender .send(IncomingMessage::new("stub", "user1", "test message")) .await .expect("send failed"); // Verify channel is registered in the manager let names = channel_manager.channel_names().await; assert!(names.contains(&"stub".to_string())); } #[cfg(feature = "libsql")] #[tokio::test] async fn test_settings_bulk_operations() { let harness = TestHarnessBuilder::new().build().await; let db = &harness.db; // Initially no settings let has = db.has_settings("bulk_user").await.expect("has_settings"); assert!(!has); // Set all settings at once let mut settings = std::collections::HashMap::new(); settings.insert("key1".to_string(), serde_json::json!("value1")); settings.insert("key2".to_string(), serde_json::json!(42)); db.set_all_settings("bulk_user", &settings) .await .expect("set_all"); // Has settings should now be true let has = db.has_settings("bulk_user").await.expect("has_settings"); assert!(has); // Get all settings let all = db.get_all_settings("bulk_user").await.expect("get_all"); assert_eq!(all.len(), 2); assert_eq!(all["key1"], serde_json::json!("value1")); assert_eq!(all["key2"], serde_json::json!(42)); // Get full setting row let full = db .get_setting_full("bulk_user", "key1") .await .expect("get_full") .expect("should exist"); assert_eq!(full.key, "key1"); } #[cfg(feature = "libsql")] #[tokio::test] async fn test_tool_failure_tracking() { let harness = TestHarnessBuilder::new().build().await; let db = &harness.db; // Record some failures db.record_tool_failure("bad_tool", "connection refused") .await .expect("record 1"); db.record_tool_failure("bad_tool", "timeout") .await .expect("record 2"); db.record_tool_failure("bad_tool", "parse error") .await .expect("record 3"); // Get broken tools (threshold = 2, should include bad_tool with 3 failures) let broken = db.get_broken_tools(2).await.expect("get broken"); assert!(!broken.is_empty()); let found = broken.iter().find(|b| b.name == "bad_tool"); assert!(found.is_some(), "bad_tool should be in broken tools list"); // Mark as repaired db.mark_tool_repaired("bad_tool") .await .expect("mark repaired"); } #[cfg(feature = "libsql")] #[tokio::test] async fn test_routine_crud() { use crate::agent::routine::{ NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger, }; let harness = TestHarnessBuilder::new().build().await; let db = &harness.db; let routine_id = uuid::Uuid::new_v4(); let routine = Routine { id: routine_id, name: "test-routine".to_string(), description: "A test routine".to_string(), user_id: "user1".to_string(), enabled: true, trigger: Trigger::Cron { schedule: "0 * * * *".to_string(), timezone: None, }, action: RoutineAction::Lightweight { prompt: "Check status".to_string(), context_paths: vec![], max_tokens: 500, use_tools: false, max_tool_rounds: 3, }, guardrails: RoutineGuardrails { cooldown: std::time::Duration::from_secs(60), max_concurrent: 1, dedup_window: None, }, notify: NotifyConfig { channel: None, user: Some("user1".to_string()), on_attention: true, on_failure: true, on_success: false, }, last_run_at: None, next_fire_at: None, run_count: 0, consecutive_failures: 0, state: serde_json::json!({}), created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; // Create db.create_routine(&routine).await.expect("create routine"); // Get by ID let fetched = db .get_routine(routine_id) .await .expect("get routine") .expect("should exist"); assert_eq!(fetched.name, "test-routine"); assert!(fetched.enabled); // Get by name let by_name = db .get_routine_by_name("user1", "test-routine") .await .expect("get by name") .expect("should exist"); assert_eq!(by_name.id, routine_id); // List routines for user let list = db.list_routines("user1").await.expect("list routines"); assert_eq!(list.len(), 1); // List all routines let all = db.list_all_routines().await.expect("list all"); assert!(!all.is_empty()); // Update routine (disable + change description) let mut updated = fetched; updated.enabled = false; updated.description = "Updated description".to_string(); db.update_routine(&updated).await.expect("update routine"); let re_fetched = db .get_routine(routine_id) .await .expect("get") .expect("exists"); assert!(!re_fetched.enabled); assert_eq!(re_fetched.description, "Updated description"); // Create a routine run let run_id = uuid::Uuid::new_v4(); let run = RoutineRun { id: run_id, routine_id, trigger_type: "cron".to_string(), trigger_detail: Some("0 * * * *".to_string()), started_at: chrono::Utc::now(), completed_at: None, status: RunStatus::Running, result_summary: None, tokens_used: None, job_id: None, created_at: chrono::Utc::now(), }; db.create_routine_run(&run).await.expect("create run"); // List runs let runs = db .list_routine_runs(routine_id, 10) .await .expect("list runs"); assert_eq!(runs.len(), 1); assert!(matches!(runs[0].status, RunStatus::Running)); // Complete the run db.complete_routine_run(run_id, RunStatus::Ok, Some("All good"), Some(150)) .await .expect("complete run"); let runs = db .list_routine_runs(routine_id, 10) .await .expect("list runs after complete"); assert!(matches!(runs[0].status, RunStatus::Ok)); // Delete let deleted = db.delete_routine(routine_id).await.expect("delete"); assert!(deleted); // Delete non-existent let deleted = db.delete_routine(routine_id).await.expect("delete again"); assert!(!deleted); } #[cfg(feature = "libsql")] #[tokio::test] async fn test_routine_runtime_update() { use crate::agent::routine::{ NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, }; let harness = TestHarnessBuilder::new().build().await; let db = &harness.db; let routine_id = uuid::Uuid::new_v4(); let routine = Routine { id: routine_id, name: "runtime-test".to_string(), description: "Test runtime update".to_string(), user_id: "user1".to_string(), enabled: true, trigger: Trigger::Manual, action: RoutineAction::Lightweight { prompt: "test".to_string(), context_paths: vec![], max_tokens: 100, use_tools: false, max_tool_rounds: 3, }, guardrails: RoutineGuardrails { cooldown: std::time::Duration::from_secs(0), max_concurrent: 1, dedup_window: None, }, notify: NotifyConfig { channel: None, user: Some("user1".to_string()), on_attention: false, on_failure: false, on_success: false, }, last_run_at: None, next_fire_at: None, run_count: 0, consecutive_failures: 0, state: serde_json::json!({}), created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; db.create_routine(&routine).await.expect("create"); let now = chrono::Utc::now(); db.update_routine_runtime( routine_id, now, Some(now + chrono::TimeDelta::seconds(3600)), 5, 2, &serde_json::json!({"last_result": "ok"}), ) .await .expect("update runtime"); let fetched = db .get_routine(routine_id) .await .expect("get") .expect("exists"); assert_eq!(fetched.run_count, 5); assert_eq!(fetched.consecutive_failures, 2); assert!(fetched.last_run_at.is_some()); assert!(fetched.next_fire_at.is_some()); // Cleanup db.delete_routine(routine_id).await.expect("delete"); } #[cfg(feature = "libsql")] #[tokio::test] async fn test_llm_call_recording() { use crate::history::LlmCallRecord; let harness = TestHarnessBuilder::new().build().await; let db = &harness.db; let record = LlmCallRecord { job_id: None, conversation_id: None, provider: "openai", model: "gpt-4", input_tokens: 100, output_tokens: 50, cost: Decimal::new(5, 3), // 0.005 purpose: Some("test"), }; let call_id = db.record_llm_call(&record).await.expect("record llm call"); assert!(!call_id.is_nil()); } #[cfg(feature = "libsql")] #[tokio::test] async fn test_sandbox_job_lifecycle() { use crate::history::SandboxJobRecord; let harness = TestHarnessBuilder::new().build().await; let db = &harness.db; let job_id = uuid::Uuid::new_v4(); let job = SandboxJobRecord { id: job_id, task: "Build a test tool".to_string(), status: "creating".to_string(), user_id: "user1".to_string(), project_dir: "/workspace/test".to_string(), success: None, failure_reason: None, created_at: chrono::Utc::now(), started_at: None, completed_at: None, credential_grants_json: "[]".to_string(), }; // Create db.save_sandbox_job(&job).await.expect("save sandbox job"); // Get let fetched = db .get_sandbox_job(job_id) .await .expect("get") .expect("should exist"); assert_eq!(fetched.task, "Build a test tool"); assert_eq!(fetched.status, "creating"); // Update status to running db.update_sandbox_job_status( job_id, "running", None, None, Some(chrono::Utc::now()), None, ) .await .expect("update to running"); // Update to completed db.update_sandbox_job_status( job_id, "completed", Some(true), Some("Done"), None, Some(chrono::Utc::now()), ) .await .expect("update to completed"); let fetched = db .get_sandbox_job(job_id) .await .expect("get") .expect("should exist"); assert_eq!(fetched.status, "completed"); assert_eq!(fetched.success, Some(true)); // List let all = db.list_sandbox_jobs().await.expect("list"); assert!(!all.is_empty()); // Summary let summary = db.sandbox_job_summary().await.expect("summary"); assert!(summary.total >= 1); // Per-user list let user_jobs = db .list_sandbox_jobs_for_user("user1") .await .expect("user list"); assert!(!user_jobs.is_empty()); // Ownership check let belongs = db .sandbox_job_belongs_to_user(job_id, "user1") .await .expect("belongs check"); assert!(belongs); let not_belongs = db .sandbox_job_belongs_to_user(job_id, "other_user") .await .expect("belongs check"); assert!(!not_belongs); } #[cfg(feature = "libsql")] #[tokio::test] async fn test_sandbox_job_mode() { use crate::history::SandboxJobRecord; let harness = TestHarnessBuilder::new().build().await; let db = &harness.db; let job_id = uuid::Uuid::new_v4(); let job = SandboxJobRecord { id: job_id, task: "Mode test".to_string(), status: "creating".to_string(), user_id: "user1".to_string(), project_dir: "/workspace".to_string(), success: None, failure_reason: None, created_at: chrono::Utc::now(), started_at: None, completed_at: None, credential_grants_json: "[]".to_string(), }; db.save_sandbox_job(&job).await.expect("save"); // Default mode let mode = db.get_sandbox_job_mode(job_id).await.expect("get mode"); // Default is "worker" per schema or NULL assert!(mode.is_none() || mode.as_deref() == Some("worker")); // Update mode db.update_sandbox_job_mode(job_id, "claude_code") .await .expect("update mode"); let mode = db .get_sandbox_job_mode(job_id) .await .expect("get mode") .expect("should have mode"); assert_eq!(mode, "claude_code"); } #[cfg(feature = "libsql")] #[tokio::test] async fn test_job_events() { use crate::history::SandboxJobRecord; let harness = TestHarnessBuilder::new().build().await; let db = &harness.db; // Create a sandbox job first (foreign key) let job_id = uuid::Uuid::new_v4(); let job = SandboxJobRecord { id: job_id, task: "Event test".to_string(), status: "running".to_string(), user_id: "user1".to_string(), project_dir: "/workspace".to_string(), success: None, failure_reason: None, created_at: chrono::Utc::now(), started_at: Some(chrono::Utc::now()), completed_at: None, credential_grants_json: "[]".to_string(), }; db.save_sandbox_job(&job).await.expect("save job"); // Save events db.save_job_event( job_id, "tool_call", &serde_json::json!({"tool": "shell", "args": {"command": "ls"}}), ) .await .expect("save event 1"); db.save_job_event( job_id, "tool_result", &serde_json::json!({"output": "file1.txt\nfile2.txt"}), ) .await .expect("save event 2"); db.save_job_event( job_id, "llm_response", &serde_json::json!({"content": "Found 2 files"}), ) .await .expect("save event 3"); // List all events let events = db.list_job_events(job_id, None).await.expect("list events"); assert_eq!(events.len(), 3); // List with limit let events = db .list_job_events(job_id, Some(2)) .await .expect("list events limited"); assert_eq!(events.len(), 2); } #[cfg(feature = "libsql")] #[tokio::test] async fn test_estimation_snapshot_round_trip() { let harness = TestHarnessBuilder::new().build().await; let db = &harness.db; // Create a job first let job_ctx = crate::context::JobContext::with_user("user1", "Estimate test", "testing"); let job_id = job_ctx.job_id; db.save_job(&job_ctx).await.expect("save job"); // Save estimation snapshot let snap_id = db .save_estimation_snapshot( job_id, "code_generation", &["shell".to_string(), "write_file".to_string()], Decimal::new(50, 2), // 0.50 120, Decimal::new(500, 2), // 5.00 ) .await .expect("save snapshot"); assert!(!snap_id.is_nil()); // Update with actuals db.update_estimation_actuals( snap_id, Decimal::new(45, 2), // 0.45 110, Some(Decimal::new(600, 2)), // 6.00 ) .await .expect("update actuals"); } #[tokio::test] async fn stub_llm_fault_injector_sequence() { use crate::llm::LlmProvider; use crate::testing::fault_injection::{FaultAction, FaultInjector, FaultType}; let injector = Arc::new(FaultInjector::sequence([ FaultAction::Fail(FaultType::RateLimited { retry_after: None }), FaultAction::Succeed, ])); let stub = StubLlm::new("hello").with_fault_injector(injector); let req = crate::llm::CompletionRequest::new(vec![crate::llm::ChatMessage::user("hi")]); // First call should fail with RateLimited let result = stub.complete(req.clone()).await; assert!(result.is_err()); assert!(matches!(result.unwrap_err(), LlmError::RateLimited { .. })); // Second call should succeed let result = stub.complete(req).await; assert!(result.is_ok()); assert_eq!(result.unwrap().content, "hello"); } }