//! Test harness for constructing `AgentDeps` with sensible defaults. //! //! Provides: //! - [`StubLlm`]: A configurable LLM provider that returns a fixed response //! - [`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. //! } //! ``` use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use async_trait::async_trait; use rust_decimal::Decimal; use crate::agent::AgentDeps; use crate::db::Database; use crate::error::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, } 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, } } /// 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, } } /// 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, } } /// 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) } /// Toggle whether calls should fail at runtime. pub fn set_failing(&self, fail: bool) { self.should_fail.store(fail, Ordering::Relaxed); } 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 self.should_fail.load(Ordering::Relaxed) { return Err(self.make_error()); } Ok(CompletionResponse { content: self.response.clone(), input_tokens: 10, output_tokens: 5, finish_reason: FinishReason::Stop, }) } async fn complete_with_tools( &self, _request: ToolCompletionRequest, ) -> Result { self.call_count.fetch_add(1, Ordering::Relaxed); if self.should_fail.load(Ordering::Relaxed) { return Err(self.make_error()); } Ok(ToolCompletionResponse { content: Some(self.response.clone()), tool_calls: Vec::new(), input_tokens: 10, output_tokens: 5, finish_reason: FinishReason::Stop, }) } } /// 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, /// 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>, } impl TestHarnessBuilder { /// Create a new builder with all defaults. pub fn new() -> Self { Self { db: None, llm: None, tools: None, } } /// 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 } /// 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 deps = AgentDeps { 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, }; TestHarness { deps, db, _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. db.ensure_conversation(conv_id, "web", "carol", None) .await .expect("ensure first"); // Calling again with the same ID should not error. db.ensure_conversation(conv_id, "web", "carol", None) .await .expect("ensure second (idempotent)"); // 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_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); } // === 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); } #[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(), }, action: RoutineAction::Lightweight { prompt: "Check status".to_string(), context_paths: vec![], max_tokens: 500, }, guardrails: RoutineGuardrails { cooldown: std::time::Duration::from_secs(60), max_concurrent: 1, dedup_window: None, }, notify: NotifyConfig { channel: None, user: "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, }, guardrails: RoutineGuardrails { cooldown: std::time::Duration::from_secs(0), max_concurrent: 1, dedup_window: None, }, notify: NotifyConfig { channel: None, user: "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"); } }