Test framework improvements and fixture updates

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-12-28 11:51:00 -03:00
parent b38574c588
commit 1232b2fc65
24 changed files with 208 additions and 266 deletions

View file

@ -131,7 +131,7 @@ impl ConversationTest {
ConversationBuilder::new(bot_name).build() ConversationBuilder::new(bot_name).build()
} }
pub async fn with_context(ctx: &TestContext, bot_name: &str) -> Result<Self> { pub fn with_context(ctx: &TestContext, bot_name: &str) -> Result<Self> {
let mut conv = ConversationBuilder::new(bot_name).build(); let mut conv = ConversationBuilder::new(bot_name).build();
conv.llm_url = Some(ctx.llm_url()); conv.llm_url = Some(ctx.llm_url());
Ok(conv) Ok(conv)
@ -306,7 +306,7 @@ impl ConversationTest {
metadata metadata
} }
pub async fn assert_response_contains(&mut self, text: &str) -> &mut Self { pub fn assert_response_contains(&mut self, text: &str) -> &mut Self {
let result = if let Some(ref response) = self.last_response { let result = if let Some(ref response) = self.last_response {
if response.content.contains(text) { if response.content.contains(text) {
AssertionResult::pass(&format!("Response contains '{text}'")) AssertionResult::pass(&format!("Response contains '{text}'"))
@ -325,7 +325,7 @@ impl ConversationTest {
self self
} }
pub async fn assert_response_equals(&mut self, text: &str) -> &mut Self { pub fn assert_response_equals(&mut self, text: &str) -> &mut Self {
let result = if let Some(ref response) = self.last_response { let result = if let Some(ref response) = self.last_response {
if response.content == text { if response.content == text {
AssertionResult::pass(&format!("Response equals '{text}'")) AssertionResult::pass(&format!("Response equals '{text}'"))
@ -344,7 +344,7 @@ impl ConversationTest {
self self
} }
pub async fn assert_response_matches(&mut self, pattern: &str) -> &mut Self { pub fn assert_response_matches(&mut self, pattern: &str) -> &mut Self {
let result = if let Some(ref response) = self.last_response { let result = if let Some(ref response) = self.last_response {
match regex::Regex::new(pattern) { match regex::Regex::new(pattern) {
Ok(re) => { Ok(re) => {
@ -372,7 +372,7 @@ impl ConversationTest {
self self
} }
pub async fn assert_response_not_contains(&mut self, text: &str) -> &mut Self { pub fn assert_response_not_contains(&mut self, text: &str) -> &mut Self {
let result = if let Some(ref response) = self.last_response { let result = if let Some(ref response) = self.last_response {
if response.content.contains(text) { if response.content.contains(text) {
AssertionResult::fail( AssertionResult::fail(
@ -391,16 +391,13 @@ impl ConversationTest {
self self
} }
pub async fn assert_transferred_to_human(&mut self) -> &mut Self { pub fn assert_transferred_to_human(&mut self) -> &mut Self {
let is_transferred = self.state == ConversationState::Transferred let is_transferred = self.state == ConversationState::Transferred
|| self || self.last_response.as_ref().is_some_and(|r| {
.last_response r.content.to_lowercase().contains("transfer")
.as_ref() || r.content.to_lowercase().contains("human")
.is_some_and(|r| { || r.content.to_lowercase().contains("agent")
r.content.to_lowercase().contains("transfer") });
|| r.content.to_lowercase().contains("human")
|| r.content.to_lowercase().contains("agent")
});
let result = if is_transferred { let result = if is_transferred {
self.state = ConversationState::Transferred; self.state = ConversationState::Transferred;
@ -417,7 +414,7 @@ impl ConversationTest {
self self
} }
pub async fn assert_queue_position(&mut self, expected: usize) -> &mut Self { pub fn assert_queue_position(&mut self, expected: usize) -> &mut Self {
let actual = self let actual = self
.context .context
.get("queue_position") .get("queue_position")
@ -438,7 +435,7 @@ impl ConversationTest {
self self
} }
pub async fn assert_response_within(&mut self, max_duration: Duration) -> &mut Self { pub fn assert_response_within(&mut self, max_duration: Duration) -> &mut Self {
let result = if let Some(latency) = self.last_latency { let result = if let Some(latency) = self.last_latency {
if latency <= max_duration { if latency <= max_duration {
AssertionResult::pass(&format!("Response within {max_duration:?}")) AssertionResult::pass(&format!("Response within {max_duration:?}"))
@ -461,7 +458,7 @@ impl ConversationTest {
self self
} }
pub async fn assert_response_count(&mut self, expected: usize) -> &mut Self { pub fn assert_response_count(&mut self, expected: usize) -> &mut Self {
let actual = self.responses.len(); let actual = self.responses.len();
let result = if actual == expected { let result = if actual == expected {
@ -478,7 +475,7 @@ impl ConversationTest {
self self
} }
pub async fn assert_response_type(&mut self, expected: ResponseContentType) -> &mut Self { pub fn assert_response_type(&mut self, expected: ResponseContentType) -> &mut Self {
let result = if let Some(ref response) = self.last_response { let result = if let Some(ref response) = self.last_response {
if response.content_type == expected { if response.content_type == expected {
AssertionResult::pass(&format!("Response type is {expected:?}")) AssertionResult::pass(&format!("Response type is {expected:?}"))
@ -510,7 +507,7 @@ impl ConversationTest {
self.context.get(key) self.context.get(key)
} }
pub async fn end(&mut self) -> &mut Self { pub fn end(&mut self) -> &mut Self {
self.state = ConversationState::Ended; self.state = ConversationState::Ended;
self.record.ended_at = Some(Utc::now()); self.record.ended_at = Some(Utc::now());
self self
@ -583,7 +580,7 @@ mod tests {
async fn test_assert_response_contains() { async fn test_assert_response_contains() {
let mut conv = ConversationTest::new("test-bot"); let mut conv = ConversationTest::new("test-bot");
conv.user_says("test").await; conv.user_says("test").await;
conv.assert_response_contains("Response").await; conv.assert_response_contains("Response");
assert!(conv.all_passed()); assert!(conv.all_passed());
} }
@ -592,7 +589,7 @@ mod tests {
async fn test_assert_response_not_contains() { async fn test_assert_response_not_contains() {
let mut conv = ConversationTest::new("test-bot"); let mut conv = ConversationTest::new("test-bot");
conv.user_says("test").await; conv.user_says("test").await;
conv.assert_response_not_contains("nonexistent").await; conv.assert_response_not_contains("nonexistent");
assert!(conv.all_passed()); assert!(conv.all_passed());
} }
@ -632,7 +629,7 @@ mod tests {
async fn test_end_conversation() { async fn test_end_conversation() {
let mut conv = ConversationTest::new("test-bot"); let mut conv = ConversationTest::new("test-bot");
conv.user_says("bye").await; conv.user_says("bye").await;
conv.end().await; conv.end();
assert_eq!(conv.state(), ConversationState::Ended); assert_eq!(conv.state(), ConversationState::Ended);
assert!(conv.record().ended_at.is_some()); assert!(conv.record().ended_at.is_some());
@ -642,7 +639,7 @@ mod tests {
async fn test_failed_assertions() { async fn test_failed_assertions() {
let mut conv = ConversationTest::new("test-bot"); let mut conv = ConversationTest::new("test-bot");
conv.user_says("test").await; conv.user_says("test").await;
conv.assert_response_equals("this will not match").await; conv.assert_response_equals("this will not match");
assert!(!conv.all_passed()); assert!(!conv.all_passed());
assert_eq!(conv.failed_assertions().len(), 1); assert_eq!(conv.failed_assertions().len(), 1);
@ -672,13 +669,13 @@ mod tests {
let mut conv = ConversationTest::new("support-bot"); let mut conv = ConversationTest::new("support-bot");
conv.user_says("Hi").await; conv.user_says("Hi").await;
conv.assert_response_contains("Response").await; conv.assert_response_contains("Response");
conv.user_says("I need help").await; conv.user_says("I need help").await;
conv.assert_response_contains("Response").await; conv.assert_response_contains("Response");
conv.user_says("Thanks, bye").await; conv.user_says("Thanks, bye").await;
conv.end().await; conv.end();
assert_eq!(conv.sent_messages().len(), 3); assert_eq!(conv.sent_messages().len(), 3);
assert_eq!(conv.responses().len(), 3); assert_eq!(conv.responses().len(), 3);
@ -689,7 +686,7 @@ mod tests {
async fn test_response_time_assertion() { async fn test_response_time_assertion() {
let mut conv = ConversationTest::new("test-bot"); let mut conv = ConversationTest::new("test-bot");
conv.user_says("quick test").await; conv.user_says("quick test").await;
conv.assert_response_within(Duration::from_secs(5)).await; conv.assert_response_within(Duration::from_secs(5));
assert!(conv.all_passed()); assert!(conv.all_passed());
} }
@ -699,7 +696,7 @@ mod tests {
let mut conv = ConversationTest::new("test-bot"); let mut conv = ConversationTest::new("test-bot");
conv.user_says("one").await; conv.user_says("one").await;
conv.user_says("two").await; conv.user_says("two").await;
conv.assert_response_count(2).await; conv.assert_response_count(2);
assert!(conv.all_passed()); assert!(conv.all_passed());
} }

View file

@ -254,6 +254,7 @@ pub struct Screenshot {
impl Screenshot { impl Screenshot {
pub fn save(&self, path: impl Into<PathBuf>) -> Result<()> { pub fn save(&self, path: impl Into<PathBuf>) -> Result<()> {
let _ = (&self.data, self.width, self.height);
let path = path.into(); let path = path.into();
anyhow::bail!("Screenshot save not yet implemented: {}", path.display()) anyhow::bail!("Screenshot save not yet implemented: {}", path.display())
} }

View file

@ -1,4 +1,3 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::collections::HashMap; use std::collections::HashMap;
@ -334,6 +333,7 @@ pub fn sample_faqs() -> Vec<FAQ> {
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::upper_case_acronyms)]
pub struct FAQ { pub struct FAQ {
pub id: u32, pub id: u32,
pub question: String, pub question: String,
@ -425,10 +425,12 @@ mod tests {
fn test_whatsapp_text_message() { fn test_whatsapp_text_message() {
let payload = whatsapp_text_message("15551234567", "Hello"); let payload = whatsapp_text_message("15551234567", "Hello");
assert_eq!(payload["object"], "whatsapp_business_account"); assert_eq!(payload["object"], "whatsapp_business_account");
assert!(payload["entry"][0]["changes"][0]["value"]["messages"][0]["text"]["body"] assert!(
.as_str() payload["entry"][0]["changes"][0]["value"]["messages"][0]["text"]["body"]
.unwrap() .as_str()
.contains("Hello")); .unwrap()
.contains("Hello")
);
} }
#[test] #[test]
@ -453,7 +455,9 @@ mod tests {
fn test_sample_kb_entries() { fn test_sample_kb_entries() {
let entries = sample_kb_entries(); let entries = sample_kb_entries();
assert!(!entries.is_empty()); assert!(!entries.is_empty());
assert!(entries.iter().any(|e| e.category == Some("products".to_string()))); assert!(entries
.iter()
.any(|e| e.category == Some("products".to_string())));
} }
#[test] #[test]

View file

@ -1,4 +1,3 @@
pub mod data; pub mod data;
pub mod scripts; pub mod scripts;
@ -7,7 +6,6 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User { pub struct User {
pub id: Uuid, pub id: Uuid,
@ -79,6 +77,7 @@ impl Default for Customer {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
#[allow(clippy::upper_case_acronyms)]
pub enum Channel { pub enum Channel {
WhatsApp, WhatsApp,
Teams, Teams,
@ -278,7 +277,6 @@ impl Default for QueueStatus {
} }
} }
#[must_use] #[must_use]
pub fn admin_user() -> User { pub fn admin_user() -> User {
User { User {

View file

@ -493,14 +493,18 @@ impl BotServerInstance {
if !stack_path.exists() { if !stack_path.exists() {
anyhow::bail!( anyhow::bail!(
"Main botserver-stack not found at {stack_path:?}.\n\ "Main botserver-stack not found at {}.\n\
Run botserver once to initialize: cd ../botserver && cargo run" Run botserver once to initialize: cd ../botserver && cargo run",
stack_path.display()
); );
} }
log::info!("Starting botserver with MAIN stack at {stack_path:?}"); log::info!(
"Starting botserver with MAIN stack at {}",
stack_path.display()
);
println!("🚀 Starting BotServer with main stack..."); println!("🚀 Starting BotServer with main stack...");
println!(" Stack: {stack_path:?}"); println!(" Stack: {}", stack_path.display());
let process = std::process::Command::new(&botserver_bin_path) let process = std::process::Command::new(&botserver_bin_path)
.current_dir(&botserver_dir) .current_dir(&botserver_dir)
@ -606,7 +610,7 @@ impl BotUIInstance {
log::info!("Starting botui from: {botui_bin} on port {port}"); log::info!("Starting botui from: {botui_bin} on port {port}");
log::info!(" BOTUI_PORT={port}"); log::info!(" BOTUI_PORT={port}");
log::info!(" BOTSERVER_URL={botserver_url}"); log::info!(" BOTSERVER_URL={botserver_url}");
log::info!(" Working directory: {botui_dir:?}"); log::info!(" Working directory: {}", botui_dir.display());
let process = std::process::Command::new(&botui_bin_path) let process = std::process::Command::new(&botui_bin_path)
.current_dir(&botui_dir) .current_dir(&botui_dir)
@ -672,7 +676,7 @@ impl BotServerInstance {
let stack_path = ctx.data_dir.join("botserver-stack"); let stack_path = ctx.data_dir.join("botserver-stack");
std::fs::create_dir_all(&stack_path)?; std::fs::create_dir_all(&stack_path)?;
let stack_path = stack_path.canonicalize().unwrap_or(stack_path); let stack_path = stack_path.canonicalize().unwrap_or(stack_path);
log::info!("Created clean test stack at: {stack_path:?}"); log::info!("Created clean test stack at: {}", stack_path.display());
let botserver_bin = std::env::var("BOTSERVER_BIN") let botserver_bin = std::env::var("BOTSERVER_BIN")
.unwrap_or_else(|_| "../botserver/target/debug/botserver".to_string()); .unwrap_or_else(|_| "../botserver/target/debug/botserver".to_string());
@ -701,12 +705,12 @@ impl BotServerInstance {
.unwrap_or_else(|_| PathBuf::from("../botserver")) .unwrap_or_else(|_| PathBuf::from("../botserver"))
}); });
log::info!("Botserver working directory: {botserver_dir:?}"); log::info!("Botserver working directory: {}", botserver_dir.display());
log::info!("Stack path (absolute): {stack_path:?}"); log::info!("Stack path (absolute): {}", stack_path.display());
let installers_path = botserver_dir.join("botserver-installers"); let installers_path = botserver_dir.join("botserver-installers");
let installers_path = installers_path.canonicalize().unwrap_or(installers_path); let installers_path = installers_path.canonicalize().unwrap_or(installers_path);
log::info!("Using installers from: {installers_path:?}"); log::info!("Using installers from: {}", installers_path.display());
let process = std::process::Command::new(&botserver_bin_path) let process = std::process::Command::new(&botserver_bin_path)
.current_dir(&botserver_dir) .current_dir(&botserver_dir)
@ -764,7 +768,7 @@ impl BotServerInstance {
self.process.is_some() self.process.is_some()
} }
fn setup_test_stack_config(stack_path: &PathBuf, ctx: &TestContext) -> Result<()> { fn setup_test_stack_config(stack_path: &std::path::Path, ctx: &TestContext) -> Result<()> {
let directory_conf = stack_path.join("conf/directory"); let directory_conf = stack_path.join("conf/directory");
std::fs::create_dir_all(&directory_conf)?; std::fs::create_dir_all(&directory_conf)?;
@ -800,7 +804,7 @@ ExternalPort: {}
Ok(()) Ok(())
} }
fn generate_test_certificates(certs_dir: &PathBuf) -> Result<()> { fn generate_test_certificates(certs_dir: &std::path::Path) -> Result<()> {
use std::process::Command; use std::process::Command;
let api_dir = certs_dir.join("api"); let api_dir = certs_dir.join("api");
@ -922,7 +926,8 @@ impl TestHarness {
}; };
log::info!( log::info!(
"Test {test_id} allocated ports: {ports:?}, data_dir: {data_dir:?}, use_existing_stack: {use_existing_stack}" "Test {test_id} allocated ports: {ports:?}, data_dir: {}, use_existing_stack: {use_existing_stack}",
data_dir.display()
); );
let data_dir_str = data_dir.to_str().unwrap().to_string(); let data_dir_str = data_dir.to_str().unwrap().to_string();

View file

@ -35,6 +35,7 @@ pub mod prelude {
mod tests { mod tests {
#[test] #[test]
fn test_library_loads() { fn test_library_loads() {
assert!(true); let version = env!("CARGO_PKG_VERSION");
assert!(!version.is_empty());
} }
} }

View file

@ -308,7 +308,7 @@ async fn download_file(url: &str, dest: &PathBuf) -> Result<()> {
Ok(()) Ok(())
} }
async fn extract_zip(zip_path: &PathBuf, dest_dir: &PathBuf) -> Result<()> { fn extract_zip(zip_path: &PathBuf, dest_dir: &PathBuf) -> Result<()> {
info!("Extracting: {:?} to {:?}", zip_path, dest_dir); info!("Extracting: {:?} to {:?}", zip_path, dest_dir);
let file = std::fs::File::open(zip_path)?; let file = std::fs::File::open(zip_path)?;
@ -390,7 +390,7 @@ async fn setup_chromedriver(browser_path: &str) -> Result<PathBuf> {
let zip_path = cache_dir.join("chromedriver.zip"); let zip_path = cache_dir.join("chromedriver.zip");
download_file(&chromedriver_url, &zip_path).await?; download_file(&chromedriver_url, &zip_path).await?;
extract_zip(&zip_path, &cache_dir).await?; extract_zip(&zip_path, &cache_dir)?;
let extracted_driver = cache_dir.join("chromedriver-linux64").join("chromedriver"); let extracted_driver = cache_dir.join("chromedriver-linux64").join("chromedriver");
let final_path = get_chromedriver_path(&major_version); let final_path = get_chromedriver_path(&major_version);
@ -439,7 +439,7 @@ async fn setup_chrome_for_testing() -> Result<PathBuf> {
let zip_path = cache_dir.join("chrome.zip"); let zip_path = cache_dir.join("chrome.zip");
download_file(&chrome_url, &zip_path).await?; download_file(&chrome_url, &zip_path).await?;
extract_zip(&zip_path, &cache_dir).await?; extract_zip(&zip_path, &cache_dir)?;
std::fs::remove_file(&zip_path).ok(); std::fs::remove_file(&zip_path).ok();
@ -494,12 +494,11 @@ async fn start_chromedriver(chromedriver_path: &PathBuf, port: u16) -> Result<st
async fn check_webdriver_available(port: u16) -> bool { async fn check_webdriver_available(port: u16) -> bool {
let url = format!("http://localhost:{port}/status"); let url = format!("http://localhost:{port}/status");
let client = match reqwest::Client::builder() let Ok(client) = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(2)) .timeout(std::time::Duration::from_secs(2))
.build() .build()
{ else {
Ok(c) => c, return false;
Err(_) => return false,
}; };
client.get(&url).send().await.is_ok() client.get(&url).send().await.is_ok()
@ -641,7 +640,7 @@ fn run_cargo_test(
Ok((passed, failed, skipped)) Ok((passed, failed, skipped))
} }
async fn run_unit_tests(config: &RunnerConfig) -> Result<TestResults> { fn run_unit_tests(config: &RunnerConfig) -> Result<TestResults> {
info!("Running unit tests..."); info!("Running unit tests...");
let mut results = TestResults::new("unit"); let mut results = TestResults::new("unit");
@ -998,8 +997,8 @@ async fn main() -> ExitCode {
match setup_test_dependencies().await { match setup_test_dependencies().await {
Ok((chromedriver, chrome)) => { Ok((chromedriver, chrome)) => {
println!("\n✅ Dependencies installed successfully!"); println!("\n✅ Dependencies installed successfully!");
println!(" ChromeDriver: {chromedriver:?}"); println!(" ChromeDriver: {}", chromedriver.display());
println!(" Browser: {chrome:?}"); println!(" Browser: {}", chrome.display());
return ExitCode::SUCCESS; return ExitCode::SUCCESS;
} }
Err(e) => { Err(e) => {
@ -1029,11 +1028,11 @@ async fn main() -> ExitCode {
let mut all_results = Vec::new(); let mut all_results = Vec::new();
let result = match config.suite { let result = match config.suite {
TestSuite::Unit => run_unit_tests(&config).await, TestSuite::Unit => run_unit_tests(&config),
TestSuite::Integration => run_integration_tests(&config).await, TestSuite::Integration => run_integration_tests(&config).await,
TestSuite::E2E => run_e2e_tests(&config).await, TestSuite::E2E => run_e2e_tests(&config).await,
TestSuite::All => { TestSuite::All => {
let unit = run_unit_tests(&config).await; let unit = run_unit_tests(&config);
let integration = run_integration_tests(&config).await; let integration = run_integration_tests(&config).await;
let e2e = run_e2e_tests(&config).await; let e2e = run_e2e_tests(&config).await;

View file

@ -1,6 +1,7 @@
use super::{new_expectation_store, Expectation, ExpectationStore}; use super::{new_expectation_store, Expectation, ExpectationStore};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt::Write;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
@ -89,6 +90,7 @@ struct ChatChoice {
} }
#[derive(Serialize)] #[derive(Serialize)]
#[allow(clippy::struct_field_names)]
struct Usage { struct Usage {
prompt_tokens: u32, prompt_tokens: u32,
completion_tokens: u32, completion_tokens: u32,
@ -219,11 +221,13 @@ impl MockLLM {
.unwrap() .unwrap()
.push(expectation.clone()); .push(expectation.clone());
let mut store = self.expectations.lock().unwrap(); {
store.insert( let mut store = self.expectations.lock().unwrap();
format!("completion:{prompt_contains}"), store.insert(
Expectation::new(&format!("completion containing '{prompt_contains}'")), format!("completion:{prompt_contains}"),
); Expectation::new(&format!("completion containing '{prompt_contains}'")),
);
}
let response_text = response.to_string(); let response_text = response.to_string();
let model = self.default_model.clone(); let model = self.default_model.clone();
@ -253,7 +257,8 @@ impl MockLLM {
let mut template = ResponseTemplate::new(200).set_body_json(&response_body); let mut template = ResponseTemplate::new(200).set_body_json(&response_body);
if let Some(delay) = *latency.lock().unwrap() { let latency_value = *latency.lock().unwrap();
if let Some(delay) = latency_value {
template = template.set_delay(delay); template = template.set_delay(delay);
} }
@ -303,10 +308,11 @@ impl MockLLM {
finish_reason: None, finish_reason: None,
}], }],
}; };
sse_body.push_str(&format!( let _ = writeln!(
"data: {}\n\n", sse_body,
"data: {}\n",
serde_json::to_string(&first_chunk).unwrap() serde_json::to_string(&first_chunk).unwrap()
)); );
for chunk_text in &chunks { for chunk_text in &chunks {
let chunk = StreamChunk { let chunk = StreamChunk {
@ -323,10 +329,11 @@ impl MockLLM {
finish_reason: None, finish_reason: None,
}], }],
}; };
sse_body.push_str(&format!( let _ = writeln!(
"data: {}\n\n", sse_body,
"data: {}\n",
serde_json::to_string(&chunk).unwrap() serde_json::to_string(&chunk).unwrap()
)); );
} }
let final_chunk = StreamChunk { let final_chunk = StreamChunk {
@ -343,10 +350,11 @@ impl MockLLM {
finish_reason: Some("stop".to_string()), finish_reason: Some("stop".to_string()),
}], }],
}; };
sse_body.push_str(&format!( let _ = writeln!(
"data: {}\n\n", sse_body,
"data: {}\n",
serde_json::to_string(&final_chunk).unwrap() serde_json::to_string(&final_chunk).unwrap()
)); );
sse_body.push_str("data: [DONE]\n\n"); sse_body.push_str("data: [DONE]\n\n");
let template = ResponseTemplate::new(200) let template = ResponseTemplate::new(200)
@ -596,10 +604,7 @@ impl MockLLM {
} }
pub async fn call_count(&self) -> usize { pub async fn call_count(&self) -> usize {
self.server self.server.received_requests().await.map_or(0, |r| r.len())
.received_requests()
.await
.map_or(0, |r| r.len())
} }
pub async fn assert_called_times(&self, expected: usize) { pub async fn assert_called_times(&self, expected: usize) {
@ -649,7 +654,7 @@ mod tests {
let response = ChatCompletionResponse { let response = ChatCompletionResponse {
id: "test-id".to_string(), id: "test-id".to_string(),
object: "chat.completion".to_string(), object: "chat.completion".to_string(),
created: 1234567890, created: 1_234_567_890,
model: "gpt-4".to_string(), model: "gpt-4".to_string(),
choices: vec![ChatChoice { choices: vec![ChatChoice {
index: 0, index: 0,

View file

@ -1,4 +1,3 @@
use super::{new_expectation_store, ExpectationStore}; use super::{new_expectation_store, ExpectationStore};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -22,6 +21,7 @@ pub struct MockTeams {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[allow(clippy::struct_field_names)]
pub struct Activity { pub struct Activity {
#[serde(rename = "type")] #[serde(rename = "type")]
pub activity_type: String, pub activity_type: String,
@ -124,6 +124,7 @@ pub struct Attachment {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[allow(clippy::struct_field_names)]
pub struct Entity { pub struct Entity {
#[serde(rename = "type")] #[serde(rename = "type")]
pub entity_type: String, pub entity_type: String,
@ -318,9 +319,7 @@ impl MockTeams {
sent_activities.lock().unwrap().push(activity.clone()); sent_activities.lock().unwrap().push(activity.clone());
let response = ResourceResponse { let response = ResourceResponse { id: activity.id };
id: activity.id,
};
ResponseTemplate::new(200).set_body_json(&response) ResponseTemplate::new(200).set_body_json(&response)
}) })

View file

@ -1,4 +1,3 @@
use super::{new_expectation_store, ExpectationStore}; use super::{new_expectation_store, ExpectationStore};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -213,7 +212,8 @@ pub struct ConversationOrigin {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pricing { pub struct Pricing {
pub billable: bool, pub billable: bool,
pub pricing_model: String, #[serde(alias = "pricing_model")]
pub model: String,
pub category: String, pub category: String,
} }
@ -354,7 +354,6 @@ impl MockWhatsApp {
async fn setup_default_routes(&self) { async fn setup_default_routes(&self) {
let sent_messages = self.sent_messages.clone(); let sent_messages = self.sent_messages.clone();
let _phone_id = self.phone_number_id.clone();
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path_regex(r"/v\d+\.\d+/\d+/messages")) .and(path_regex(r"/v\d+\.\d+/\d+/messages"))
@ -413,7 +412,6 @@ impl MockWhatsApp {
id: message_id.clone(), id: message_id.clone(),
to: to.to_string(), to: to.to_string(),
message_type: match msg_type { message_type: match msg_type {
"text" => MessageType::Text,
"template" => MessageType::Template, "template" => MessageType::Template,
"image" => MessageType::Image, "image" => MessageType::Image,
"document" => MessageType::Document, "document" => MessageType::Document,
@ -483,6 +481,7 @@ impl MockWhatsApp {
#[must_use] #[must_use]
pub fn expect_send_message(&self, to: &str) -> MessageExpectation { pub fn expect_send_message(&self, to: &str) -> MessageExpectation {
let _ = self;
MessageExpectation { MessageExpectation {
to: to.to_string(), to: to.to_string(),
message_type: None, message_type: None,
@ -492,6 +491,7 @@ impl MockWhatsApp {
#[must_use] #[must_use]
pub fn expect_send_template(&self, name: &str) -> TemplateExpectation { pub fn expect_send_template(&self, name: &str) -> TemplateExpectation {
let _ = self;
TemplateExpectation { TemplateExpectation {
name: name.to_string(), name: name.to_string(),
to: None, to: None,
@ -707,7 +707,7 @@ impl MockWhatsApp {
}), }),
pricing: Some(Pricing { pricing: Some(Pricing {
billable: true, billable: true,
pricing_model: "CBP".to_string(), model: "CBP".to_string(),
category: "business_initiated".to_string(), category: "business_initiated".to_string(),
}), }),
}]), }]),

View file

@ -1,4 +1,3 @@
use super::{new_expectation_store, ExpectationStore}; use super::{new_expectation_store, ExpectationStore};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -405,10 +404,7 @@ impl MockZitadel {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/oidc/v1/userinfo")) .and(path("/oidc/v1/userinfo"))
.and(header( .and(header("authorization", format!("Bearer {token}").as_str()))
"authorization",
format!("Bearer {token}").as_str(),
))
.respond_with(ResponseTemplate::new(200).set_body_json(&response)) .respond_with(ResponseTemplate::new(200).set_body_json(&response))
.mount(&self.server) .mount(&self.server)
.await; .await;
@ -663,8 +659,8 @@ mod tests {
client_id: Some("client".to_string()), client_id: Some("client".to_string()),
username: Some("user@test.com".to_string()), username: Some("user@test.com".to_string()),
token_type: Some("Bearer".to_string()), token_type: Some("Bearer".to_string()),
exp: Some(1234567890), exp: Some(1_234_567_890),
iat: Some(1234567800), iat: Some(1_234_567_800),
sub: Some("user-id".to_string()), sub: Some("user-id".to_string()),
aud: Some("audience".to_string()), aud: Some("audience".to_string()),
iss: Some("issuer".to_string()), iss: Some("issuer".to_string()),

View file

@ -1,4 +1,3 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use log::{info, warn}; use log::{info, warn};
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
@ -169,6 +168,7 @@ impl BrowserService {
self.port self.port
} }
#[allow(clippy::unused_async)]
pub async fn stop(&mut self) -> Result<()> { pub async fn stop(&mut self) -> Result<()> {
if let Some(mut process) = self.process.take() { if let Some(mut process) = self.process.take() {
info!("Stopping browser"); info!("Stopping browser");

View file

@ -1,4 +1,3 @@
use super::{check_tcp_port, ensure_dir, wait_for, HEALTH_CHECK_INTERVAL, HEALTH_CHECK_TIMEOUT}; use super::{check_tcp_port, ensure_dir, wait_for, HEALTH_CHECK_INTERVAL, HEALTH_CHECK_TIMEOUT};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use nix::sys::signal::{kill, Signal}; use nix::sys::signal::{kill, Signal};
@ -28,7 +27,10 @@ impl MinioService {
if let Ok(stack_path) = std::env::var("BOTSERVER_STACK_PATH") { if let Ok(stack_path) = std::env::var("BOTSERVER_STACK_PATH") {
let minio_path = PathBuf::from(&stack_path).join("bin/drive/minio"); let minio_path = PathBuf::from(&stack_path).join("bin/drive/minio");
if minio_path.exists() { if minio_path.exists() {
log::info!("Using MinIO from BOTSERVER_STACK_PATH: {minio_path:?}"); log::info!(
"Using MinIO from BOTSERVER_STACK_PATH: {}",
minio_path.display()
);
return Ok(minio_path); return Ok(minio_path);
} }
} }
@ -43,7 +45,7 @@ impl MinioService {
for rel_path in &relative_paths { for rel_path in &relative_paths {
let minio_path = cwd.join(rel_path); let minio_path = cwd.join(rel_path);
if minio_path.exists() { if minio_path.exists() {
log::info!("Using MinIO from botserver-stack: {minio_path:?}"); log::info!("Using MinIO from botserver-stack: {}", minio_path.display());
return Ok(minio_path); return Ok(minio_path);
} }
} }
@ -58,13 +60,13 @@ impl MinioService {
for path in &system_paths { for path in &system_paths {
let minio_path = PathBuf::from(path); let minio_path = PathBuf::from(path);
if minio_path.exists() { if minio_path.exists() {
log::info!("Using system MinIO from: {minio_path:?}"); log::info!("Using system MinIO from: {}", minio_path.display());
return Ok(minio_path); return Ok(minio_path);
} }
} }
if let Ok(minio_path) = which::which("minio") { if let Ok(minio_path) = which::which("minio") {
log::info!("Using MinIO from PATH: {minio_path:?}"); log::info!("Using MinIO from PATH: {}", minio_path.display());
return Ok(minio_path); return Ok(minio_path);
} }
@ -73,7 +75,7 @@ impl MinioService {
pub async fn start(api_port: u16, data_dir: &str) -> Result<Self> { pub async fn start(api_port: u16, data_dir: &str) -> Result<Self> {
let bin_path = Self::find_minio_binary()?; let bin_path = Self::find_minio_binary()?;
log::info!("Using MinIO from: {bin_path:?}"); log::info!("Using MinIO from: {}", bin_path.display());
let data_path = PathBuf::from(data_dir).join("minio"); let data_path = PathBuf::from(data_dir).join("minio");
ensure_dir(&data_path)?; ensure_dir(&data_path)?;
@ -90,7 +92,7 @@ impl MinioService {
secret_key: Self::DEFAULT_SECRET_KEY.to_string(), secret_key: Self::DEFAULT_SECRET_KEY.to_string(),
}; };
service.start_server().await?; service.start_server()?;
service.wait_ready().await?; service.wait_ready().await?;
Ok(service) Ok(service)
@ -103,7 +105,7 @@ impl MinioService {
secret_key: &str, secret_key: &str,
) -> Result<Self> { ) -> Result<Self> {
let bin_path = Self::find_minio_binary()?; let bin_path = Self::find_minio_binary()?;
log::info!("Using MinIO from: {bin_path:?}"); log::info!("Using MinIO from: {}", bin_path.display());
let data_path = PathBuf::from(data_dir).join("minio"); let data_path = PathBuf::from(data_dir).join("minio");
ensure_dir(&data_path)?; ensure_dir(&data_path)?;
@ -120,13 +122,13 @@ impl MinioService {
secret_key: secret_key.to_string(), secret_key: secret_key.to_string(),
}; };
service.start_server().await?; service.start_server()?;
service.wait_ready().await?; service.wait_ready().await?;
Ok(service) Ok(service)
} }
async fn start_server(&mut self) -> Result<()> { fn start_server(&mut self) -> Result<()> {
log::info!( log::info!(
"Starting MinIO on port {} (console: {})", "Starting MinIO on port {} (console: {})",
self.api_port, self.api_port,
@ -192,11 +194,7 @@ impl MinioService {
.output(); .output();
let output = Command::new(&mc) let output = Command::new(&mc)
.args([ .args(["mb", "--ignore-existing", &format!("{alias_name}/{name}")])
"mb",
"--ignore-existing",
&format!("{alias_name}/{name}"),
])
.output()?; .output()?;
if !output.status.success() { if !output.status.success() {

View file

@ -1,4 +1,3 @@
use super::{check_tcp_port, ensure_dir, wait_for, HEALTH_CHECK_INTERVAL, HEALTH_CHECK_TIMEOUT}; use super::{check_tcp_port, ensure_dir, wait_for, HEALTH_CHECK_INTERVAL, HEALTH_CHECK_TIMEOUT};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use nix::sys::signal::{kill, Signal}; use nix::sys::signal::{kill, Signal};
@ -50,6 +49,7 @@ impl RedisService {
Ok(service) Ok(service)
} }
#[allow(clippy::unused_async)]
async fn start_server(&mut self) -> Result<()> { async fn start_server(&mut self) -> Result<()> {
log::info!("Starting Redis on port {}", self.port); log::info!("Starting Redis on port {}", self.port);
@ -125,6 +125,7 @@ impl RedisService {
Ok(()) Ok(())
} }
#[allow(clippy::unused_async)]
pub async fn execute(&self, args: &[&str]) -> Result<String> { pub async fn execute(&self, args: &[&str]) -> Result<String> {
let redis_cli = Self::find_cli_binary()?; let redis_cli = Self::find_cli_binary()?;
@ -182,7 +183,10 @@ impl RedisService {
if result.is_empty() || result == "(empty list or set)" { if result.is_empty() || result == "(empty list or set)" {
Ok(Vec::new()) Ok(Vec::new())
} else { } else {
Ok(result.lines().map(std::string::ToString::to_string).collect()) Ok(result
.lines()
.map(std::string::ToString::to_string)
.collect())
} }
} }
@ -193,10 +197,7 @@ impl RedisService {
pub async fn publish(&self, channel: &str, message: &str) -> Result<i64> { pub async fn publish(&self, channel: &str, message: &str) -> Result<i64> {
let result = self.execute(&["PUBLISH", channel, message]).await?; let result = self.execute(&["PUBLISH", channel, message]).await?;
let count = result let count = result.replace("(integer) ", "").parse::<i64>().unwrap_or(0);
.replace("(integer) ", "")
.parse::<i64>()
.unwrap_or(0);
Ok(count) Ok(count)
} }
@ -230,10 +231,7 @@ impl RedisService {
pub async fn llen(&self, key: &str) -> Result<i64> { pub async fn llen(&self, key: &str) -> Result<i64> {
let result = self.execute(&["LLEN", key]).await?; let result = self.execute(&["LLEN", key]).await?;
let len = result let len = result.replace("(integer) ", "").parse::<i64>().unwrap_or(0);
.replace("(integer) ", "")
.parse::<i64>()
.unwrap_or(0);
Ok(len) Ok(len)
} }
@ -271,19 +269,13 @@ impl RedisService {
pub async fn incr(&self, key: &str) -> Result<i64> { pub async fn incr(&self, key: &str) -> Result<i64> {
let result = self.execute(&["INCR", key]).await?; let result = self.execute(&["INCR", key]).await?;
let val = result let val = result.replace("(integer) ", "").parse::<i64>().unwrap_or(0);
.replace("(integer) ", "")
.parse::<i64>()
.unwrap_or(0);
Ok(val) Ok(val)
} }
pub async fn decr(&self, key: &str) -> Result<i64> { pub async fn decr(&self, key: &str) -> Result<i64> {
let result = self.execute(&["DECR", key]).await?; let result = self.execute(&["DECR", key]).await?;
let val = result let val = result.replace("(integer) ", "").parse::<i64>().unwrap_or(0);
.replace("(integer) ", "")
.parse::<i64>()
.unwrap_or(0);
Ok(val) Ok(val)
} }

View file

@ -1,4 +1,3 @@
pub mod browser; pub mod browser;
pub mod pages; pub mod pages;
@ -108,6 +107,7 @@ impl Locator {
} }
#[must_use] #[must_use]
#[allow(clippy::match_same_arms)]
pub fn to_css_selector(&self) -> String { pub fn to_css_selector(&self) -> String {
match self { match self {
Self::Css(s) => s.clone(), Self::Css(s) => s.clone(),

View file

@ -156,11 +156,9 @@ async fn perform_logout(browser: &Browser, base_url: &str) -> Result<bool, Strin
for selector in &logout_selectors { for selector in &logout_selectors {
let locator = Locator::css(selector); let locator = Locator::css(selector);
if browser.exists(locator.clone()).await { if browser.exists(locator.clone()).await && browser.click(locator).await.is_ok() {
if browser.click(locator).await.is_ok() { tokio::time::sleep(Duration::from_secs(1)).await;
tokio::time::sleep(Duration::from_secs(1)).await; break;
break;
}
} }
} }
@ -171,20 +169,19 @@ async fn perform_logout(browser: &Browser, base_url: &str) -> Result<bool, Strin
for selector in &logout_selectors { for selector in &logout_selectors {
let locator = Locator::css(selector); let locator = Locator::css(selector);
if browser.exists(locator.clone()).await { if browser.exists(locator.clone()).await && browser.click(locator).await.is_ok() {
if browser.click(locator).await.is_ok() { tokio::time::sleep(Duration::from_secs(1)).await;
tokio::time::sleep(Duration::from_secs(1)).await; break;
break;
}
} }
} }
} }
let current_url = browser.current_url().await.unwrap_or_default(); let current_url = browser.current_url().await.unwrap_or_default();
let base_url_with_slash = format!("{base_url}/");
let logged_out = current_url.contains("/login") let logged_out = current_url.contains("/login")
|| current_url.contains("/logout") || current_url.contains("/logout")
|| current_url == format!("{}/", base_url) || current_url == base_url_with_slash
|| current_url == base_url.to_string(); || current_url == base_url;
if logged_out { if logged_out {
return Ok(true); return Ok(true);
@ -442,10 +439,10 @@ async fn test_session_persistence() {
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
let current_url = browser.current_url().await.unwrap_or_default(); let current_url = browser.current_url().await.unwrap_or_default();
if !current_url.contains("/login") { if current_url.contains("/login") {
println!("✓ Session persisted after page refresh");
} else {
eprintln!("✗ Session lost after refresh"); eprintln!("✗ Session lost after refresh");
} else {
println!("✓ Session persisted after page refresh");
} }
} }
@ -454,10 +451,10 @@ async fn test_session_persistence() {
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
let current_url = browser.current_url().await.unwrap_or_default(); let current_url = browser.current_url().await.unwrap_or_default();
if !current_url.contains("/login") { if current_url.contains("/login") {
println!("✓ Session maintained across navigation");
} else {
eprintln!("✗ Session lost during navigation"); eprintln!("✗ Session lost during navigation");
} else {
println!("✓ Session maintained across navigation");
} }
} }
} else { } else {

View file

@ -1,41 +1,36 @@
use super::{should_run_e2e_tests, E2ETestContext}; use super::{should_run_e2e_tests, E2ETestContext};
use anyhow::{bail, Result};
use bottest::prelude::*; use bottest::prelude::*;
use bottest::web::Locator; use bottest::web::Locator;
#[tokio::test] #[tokio::test]
async fn test_chat_hi() { async fn test_chat_hi() -> Result<()> {
if !should_run_e2e_tests() { if !should_run_e2e_tests() {
eprintln!("Skipping: E2E tests disabled"); eprintln!("Skipping: E2E tests disabled");
return; return Ok(());
} }
let ctx = match E2ETestContext::setup_with_browser().await { let ctx = E2ETestContext::setup_with_browser().await?;
Ok(ctx) => ctx,
Err(e) => {
eprintln!("Test failed: {}", e);
panic!("Failed to setup E2E context: {}", e);
}
};
if !ctx.has_browser() { if !ctx.has_browser() {
ctx.close().await; ctx.close().await;
panic!("Browser not available - cannot run E2E test"); bail!("Browser not available - cannot run E2E test");
} }
if ctx.ui.is_none() { if ctx.ui.is_none() {
ctx.close().await; ctx.close().await;
panic!("BotUI not available - chat tests require botui running on port 3000"); bail!("BotUI not available - chat tests require botui running on port 3000");
} }
let browser = ctx.browser.as_ref().unwrap(); let browser = ctx.browser.as_ref().unwrap();
let ui_url = ctx.ui.as_ref().unwrap().url.clone(); let ui_url = ctx.ui.as_ref().unwrap().url.clone();
let chat_url = format!("{}/#chat", ui_url); let chat_url = format!("{}/#chat", ui_url);
println!("🌐 Navigating to: {}", chat_url); println!("🌐 Navigating to: {chat_url}");
if let Err(e) = browser.goto(&chat_url).await { if let Err(e) = browser.goto(&chat_url).await {
ctx.close().await; ctx.close().await;
panic!("Failed to navigate to chat: {}", e); bail!("Failed to navigate to chat: {e}");
} }
println!("⏳ Waiting for page to load..."); println!("⏳ Waiting for page to load...");
@ -47,10 +42,10 @@ async fn test_chat_hi() {
for attempt in 1..=10 { for attempt in 1..=10 {
if browser.exists(input.clone()).await { if browser.exists(input.clone()).await {
found_input = true; found_input = true;
println!("✓ Chat input found (attempt {})", attempt); println!("✓ Chat input found (attempt {attempt})");
break; break;
} }
println!(" ... waiting for chat input (attempt {}/10)", attempt); println!(" ... waiting for chat input (attempt {attempt}/10)");
tokio::time::sleep(std::time::Duration::from_secs(1)).await; tokio::time::sleep(std::time::Duration::from_secs(1)).await;
} }
@ -61,27 +56,25 @@ async fn test_chat_hi() {
} }
if let Ok(source) = browser.page_source().await { if let Ok(source) = browser.page_source().await {
let preview: String = source.chars().take(2000).collect(); let preview: String = source.chars().take(2000).collect();
println!("Page source preview:\n{}", preview); println!("Page source preview:\n{preview}");
} }
ctx.close().await; ctx.close().await;
panic!("Chat input not found after 10 attempts"); bail!("Chat input not found after 10 attempts");
} }
println!("⌨️ Typing 'hi'..."); println!("⌨️ Typing 'hi'...");
if let Err(e) = browser.type_text(input.clone(), "hi").await { if let Err(e) = browser.type_text(input.clone(), "hi").await {
ctx.close().await; ctx.close().await;
panic!("Failed to type: {}", e); bail!("Failed to type: {e}");
} }
let send_btn = Locator::css("#sendBtn, #ai-send, .ai-send, button[type='submit']"); let send_btn = Locator::css("#sendBtn, #ai-send, .ai-send, button[type='submit']");
match browser.click(send_btn).await { match browser.click(send_btn).await {
Ok(_) => println!("✓ Message sent (click)"), Ok(()) => println!("✓ Message sent (click)"),
Err(_) => { Err(_) => match browser.press_key(input, "Enter").await {
match browser.press_key(input, "Enter").await { Ok(()) => println!("✓ Message sent (Enter key)"),
Ok(_) => println!("✓ Message sent (Enter key)"), Err(e) => println!("⚠ Send may have failed: {e}"),
Err(e) => println!("⚠ Send may have failed: {}", e), },
}
}
} }
println!("⏳ Waiting for bot response..."); println!("⏳ Waiting for bot response...");
@ -105,29 +98,25 @@ async fn test_chat_hi() {
ctx.close().await; ctx.close().await;
println!("✅ Chat test complete!"); println!("✅ Chat test complete!");
Ok(())
} }
#[tokio::test] #[tokio::test]
async fn test_chat_page_loads() { async fn test_chat_page_loads() -> Result<()> {
if !should_run_e2e_tests() { if !should_run_e2e_tests() {
return; return Ok(());
} }
let ctx = match E2ETestContext::setup_with_browser().await { let ctx = E2ETestContext::setup_with_browser().await?;
Ok(ctx) => ctx,
Err(e) => {
panic!("Setup failed: {}", e);
}
};
if !ctx.has_browser() { if !ctx.has_browser() {
ctx.close().await; ctx.close().await;
panic!("Browser not available"); bail!("Browser not available");
} }
if ctx.ui.is_none() { if ctx.ui.is_none() {
ctx.close().await; ctx.close().await;
panic!("BotUI not available - chat tests require botui. Start it with: cd ../botui && cargo run"); bail!("BotUI not available - chat tests require botui. Start it with: cd ../botui && cargo run");
} }
let browser = ctx.browser.as_ref().unwrap(); let browser = ctx.browser.as_ref().unwrap();
@ -136,7 +125,7 @@ async fn test_chat_page_loads() {
if let Err(e) = browser.goto(&chat_url).await { if let Err(e) = browser.goto(&chat_url).await {
ctx.close().await; ctx.close().await;
panic!("Navigation failed: {}", e); bail!("Navigation failed: {e}");
} }
tokio::time::sleep(std::time::Duration::from_secs(1)).await; tokio::time::sleep(std::time::Duration::from_secs(1)).await;
@ -149,9 +138,10 @@ async fn test_chat_page_loads() {
let _ = std::fs::write("/tmp/bottest-fail.png", &s); let _ = std::fs::write("/tmp/bottest-fail.png", &s);
} }
ctx.close().await; ctx.close().await;
panic!("Chat not loaded: {}", e); bail!("Chat not loaded: {e}");
} }
} }
ctx.close().await; ctx.close().await;
Ok(())
} }

View file

@ -773,7 +773,10 @@ async fn test_with_fixtures() {
match ctx.ctx.insert_user(&user).await { match ctx.ctx.insert_user(&user).await {
Ok(_) => println!("Inserted test user: {}", user.email), Ok(_) => println!("Inserted test user: {}", user.email),
Err(e) => eprintln!("Could not insert user (DB may not be directly accessible): {}", e), Err(e) => eprintln!(
"Could not insert user (DB may not be directly accessible): {}",
e
),
} }
match ctx.ctx.insert_bot(&bot).await { match ctx.ctx.insert_bot(&bot).await {
@ -822,12 +825,10 @@ async fn test_mock_services_available() {
Ok(_pool) => println!("✓ Connected to existing PostgreSQL"), Ok(_pool) => println!("✓ Connected to existing PostgreSQL"),
Err(e) => eprintln!("Could not connect to existing PostgreSQL: {}", e), Err(e) => eprintln!("Could not connect to existing PostgreSQL: {}", e),
} }
} else if ctx.ctx.postgres().is_some() {
println!("✓ PostgreSQL is managed by harness");
} else { } else {
if ctx.ctx.postgres().is_some() { eprintln!("PostgreSQL should be started in fresh stack mode");
println!("✓ PostgreSQL is managed by harness");
} else {
eprintln!("PostgreSQL should be started in fresh stack mode");
}
} }
ctx.close().await; ctx.close().await;

View file

@ -23,7 +23,7 @@ async fn is_service_running(url: &str) -> bool {
.build() .build()
.unwrap_or_default(); .unwrap_or_default();
if let Ok(resp) = client.get(&format!("{}/health", url)).send().await { if let Ok(resp) = client.get(format!("{url}/health")).send().await {
if resp.status().is_success() { if resp.status().is_success() {
return true; return true;
} }

View file

@ -36,25 +36,6 @@ async fn get_test_server() -> Option<(Option<TestContext>, String)> {
} }
} }
fn is_server_available_sync() -> bool {
if std::env::var("SKIP_INTEGRATION_TESTS").is_ok() {
return false;
}
if let Some(url) = external_server_url() {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.ok();
if let Some(client) = client {
return client.get(&url).send().is_ok();
}
}
false
}
macro_rules! skip_if_no_server { macro_rules! skip_if_no_server {
($base_url:expr) => { ($base_url:expr) => {
if $base_url.is_none() { if $base_url.is_none() {
@ -658,7 +639,7 @@ async fn test_mock_llm_assertions() {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let _ = client let _ = client
.post(&format!("{}/v1/chat/completions", mock_llm.url())) .post(format!("{}/v1/chat/completions", mock_llm.url()))
.json(&serde_json::json!({ .json(&serde_json::json!({
"model": "gpt-4", "model": "gpt-4",
"messages": [{"role": "user", "content": "test"}] "messages": [{"role": "user", "content": "test"}]
@ -685,7 +666,7 @@ async fn test_mock_llm_error_simulation() {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let response = client let response = client
.post(&format!("{}/v1/chat/completions", mock_llm.url())) .post(format!("{}/v1/chat/completions", mock_llm.url()))
.json(&serde_json::json!({ .json(&serde_json::json!({
"model": "gpt-4", "model": "gpt-4",
"messages": [{"role": "user", "content": "test"}] "messages": [{"role": "user", "content": "test"}]

View file

@ -1,7 +1,6 @@
use rhai::Engine; use rhai::Engine;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
fn create_basic_engine() -> Engine { fn create_basic_engine() -> Engine {
let mut engine = Engine::new(); let mut engine = Engine::new();
@ -135,9 +134,8 @@ impl InputProvider {
fn create_conversation_engine(output: OutputCollector, input: InputProvider) -> Engine { fn create_conversation_engine(output: OutputCollector, input: InputProvider) -> Engine {
let mut engine = create_basic_engine(); let mut engine = create_basic_engine();
let output_clone = output.clone();
engine.register_fn("TALK", move |msg: &str| { engine.register_fn("TALK", move |msg: &str| {
output_clone.add_message(msg.to_string()); output.add_message(msg.to_string());
}); });
engine.register_fn("HEAR", move || -> String { input.next_input() }); engine.register_fn("HEAR", move || -> String { input.next_input() });
@ -145,7 +143,6 @@ fn create_conversation_engine(output: OutputCollector, input: InputProvider) ->
engine engine
} }
#[test] #[test]
fn test_string_concatenation_in_engine() { fn test_string_concatenation_in_engine() {
let engine = create_basic_engine(); let engine = create_basic_engine();
@ -208,7 +205,6 @@ fn test_replace_function() {
assert_eq!(result, "bbb"); assert_eq!(result, "bbb");
} }
#[test] #[test]
fn test_math_operations_chain() { fn test_math_operations_chain() {
let engine = create_basic_engine(); let engine = create_basic_engine();
@ -264,14 +260,13 @@ fn test_val_function() {
let result: f64 = engine.eval(r#"VAL("42")"#).unwrap(); let result: f64 = engine.eval(r#"VAL("42")"#).unwrap();
assert!((result - 42.0).abs() < f64::EPSILON); assert!((result - 42.0).abs() < f64::EPSILON);
let result: f64 = engine.eval(r#"VAL("3.14")"#).unwrap(); let result: f64 = engine.eval(r#"VAL("3.5")"#).unwrap();
assert!((result - 3.14).abs() < f64::EPSILON); assert!((result - 3.5).abs() < f64::EPSILON);
let result: f64 = engine.eval(r#"VAL("invalid")"#).unwrap(); let result: f64 = engine.eval(r#"VAL("invalid")"#).unwrap();
assert!((result - 0.0).abs() < f64::EPSILON); assert!((result - 0.0).abs() < f64::EPSILON);
} }
#[test] #[test]
fn test_talk_output() { fn test_talk_output() {
let output = OutputCollector::new(); let output = OutputCollector::new();
@ -393,19 +388,18 @@ fn test_keyword_detection() {
assert_eq!(messages[0], "I can help you! What do you need?"); assert_eq!(messages[0], "I can help you! What do you need?");
} }
#[test] #[test]
fn test_variable_assignment() { fn test_variable_assignment() {
let engine = create_basic_engine(); let engine = create_basic_engine();
let result: i64 = engine let result: i64 = engine
.eval( .eval(
r#" r"
let x = 10; let x = 10;
let y = 20; let y = 20;
let z = x + y; let z = x + y;
z z
"#, ",
) )
.unwrap(); .unwrap();
assert_eq!(result, 30); assert_eq!(result, 30);
@ -442,7 +436,6 @@ fn test_numeric_expressions() {
assert_eq!(result, 12); assert_eq!(result, 12);
} }
#[test] #[test]
fn test_for_loop() { fn test_for_loop() {
let output = OutputCollector::new(); let output = OutputCollector::new();
@ -472,7 +465,7 @@ fn test_while_loop() {
let result: i64 = engine let result: i64 = engine
.eval( .eval(
r#" r"
let count = 0; let count = 0;
let sum = 0; let sum = 0;
while count < 5 { while count < 5 {
@ -480,21 +473,19 @@ fn test_while_loop() {
count = count + 1; count = count + 1;
} }
sum sum
"#, ",
) )
.unwrap(); .unwrap();
assert_eq!(result, 10); assert_eq!(result, 10);
} }
#[test] #[test]
fn test_division_by_zero() { fn test_division_by_zero() {
let engine = create_basic_engine(); let engine = create_basic_engine();
let result = engine.eval::<f64>("10.0 / 0.0"); let result = engine.eval::<f64>("10.0 / 0.0");
match result { if let Ok(val) = result {
Ok(val) => assert!(val.is_infinite() || val.is_nan()), assert!(val.is_infinite() || val.is_nan());
Err(_) => (),
} }
} }
@ -514,7 +505,6 @@ fn test_type_mismatch() {
assert!(result.is_err()); assert!(result.is_err());
} }
#[test] #[test]
fn test_greeting_script_logic() { fn test_greeting_script_logic() {
let output = OutputCollector::new(); let output = OutputCollector::new();
@ -608,7 +598,6 @@ fn test_echo_bot_logic() {
assert_eq!(messages[2], "You said: How are you?"); assert_eq!(messages[2], "You said: How are you?");
} }
#[test] #[test]
fn test_order_lookup_simulation() { fn test_order_lookup_simulation() {
let output = OutputCollector::new(); let output = OutputCollector::new();

View file

@ -154,37 +154,37 @@ async fn test_query_result_types() {
use diesel::sql_query; use diesel::sql_query;
#[derive(QueryableByName)] #[derive(QueryableByName)]
struct TypeTestResult { struct TypeTestRow {
#[diesel(sql_type = diesel::sql_types::Integer)] #[diesel(sql_type = diesel::sql_types::Integer)]
int_val: i32, integer: i32,
#[diesel(sql_type = diesel::sql_types::BigInt)] #[diesel(sql_type = diesel::sql_types::BigInt)]
bigint_val: i64, bigint: i64,
#[diesel(sql_type = diesel::sql_types::Text)] #[diesel(sql_type = diesel::sql_types::Text)]
text_val: String, text: String,
#[diesel(sql_type = diesel::sql_types::Bool)] #[diesel(sql_type = diesel::sql_types::Bool)]
bool_val: bool, flag: bool,
#[diesel(sql_type = diesel::sql_types::Double)] #[diesel(sql_type = diesel::sql_types::Double)]
float_val: f64, decimal: f64,
} }
let mut conn = pool.get().expect("Failed to get connection"); let mut conn = pool.get().expect("Failed to get connection");
let result: Vec<TypeTestResult> = sql_query( let result: Vec<TypeTestRow> = sql_query(
"SELECT "SELECT
42 as int_val, 42 as integer,
9223372036854775807::bigint as bigint_val, 9223372036854775807::bigint as bigint,
'hello' as text_val, 'hello' as text,
true as bool_val, true as flag,
3.125 as float_val", 3.125 as decimal",
) )
.load(&mut conn) .load(&mut conn)
.expect("Query failed"); .expect("Query failed");
assert_eq!(result.len(), 1); assert_eq!(result.len(), 1);
assert_eq!(result[0].int_val, 42); assert_eq!(result[0].integer, 42);
assert_eq!(result[0].bigint_val, 9223372036854775807_i64); assert_eq!(result[0].bigint, 9_223_372_036_854_775_807_i64);
assert_eq!(result[0].text_val, "hello"); assert_eq!(result[0].text, "hello");
assert!(result[0].bool_val); assert!(result[0].flag);
assert!((result[0].float_val - 3.125).abs() < 0.0001); assert!((result[0].decimal - 3.125).abs() < 0.0001);
} }
#[tokio::test] #[tokio::test]

View file

@ -29,16 +29,6 @@ pub fn should_run_integration_tests() -> bool {
true true
} }
#[macro_export]
macro_rules! skip_if_no_services {
() => {
if !crate::integration::should_run_integration_tests() {
eprintln!("Skipping integration test: SKIP_INTEGRATION_TESTS is set");
return;
}
};
}
#[tokio::test] #[tokio::test]
async fn test_harness_database_only() { async fn test_harness_database_only() {
if !should_run_integration_tests() { if !should_run_integration_tests() {

View file

@ -1,6 +1,5 @@
#[test] #[test]
fn test_unit_module_loads() { fn test_unit_module_loads() {
// Unit tests are now inline in botserver source files let module_name = module_path!();
// This module is kept for integration test infrastructure assert!(module_name.contains("unit"));
assert!(true);
} }