diff --git a/tests/cli/helpers.rs b/tests/cli/helpers.rs index 81ddaa6..e745571 100644 --- a/tests/cli/helpers.rs +++ b/tests/cli/helpers.rs @@ -1,50 +1,47 @@ +use once_cell::sync::Lazy; use std::process::{Command, Stdio}; -use std::sync::Once; +use std::sync::Mutex; use tempfile::TempDir; -static INIT: Once = Once::new(); - -/// Initialize test environment (compile the binary, etc.) -pub fn setup() { - INIT.call_once(|| { - // Build the project before running CLI tests - let status = Command::new("cargo") - .args(&["build", "--bin", "brewlog"]) - .status() - .expect("Failed to build brewlog binary"); - - assert!(status.success(), "Failed to compile brewlog"); - }); +/// Shared test server state +struct SharedServer { + address: String, + admin_password: String, + #[allow(dead_code)] + db_url: String, + #[allow(dead_code)] + temp_dir: TempDir, + #[allow(dead_code)] + process: std::process::Child, } +/// Single shared test server for all CLI tests +static TEST_SERVER: Lazy>> = Lazy::new(|| { + // Build the binary first + let status = Command::new("cargo") + .args(&["build", "--bin", "brewlog"]) + .status() + .expect("Failed to build brewlog binary"); + assert!(status.success(), "Failed to compile brewlog"); + + Mutex::new(None) +}); + /// Get path to the brewlog binary pub fn brewlog_bin() -> String { let manifest_dir = env!("CARGO_MANIFEST_DIR"); format!("{}/target/debug/brewlog", manifest_dir) } -/// Create a temporary directory for test database -pub fn create_test_db() -> (TempDir, String) { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let db_path = temp_dir.path().join("test.db"); - let db_url = format!("sqlite:{}", db_path.display()); - (temp_dir, db_url) -} +/// Get or start the shared test server +fn get_or_start_server() -> (String, String) { + let mut server = TEST_SERVER.lock().unwrap(); -/// Start a brewlog server in the background for testing -pub struct TestServer { - pub address: String, - pub admin_password: String, - pub db_url: String, - _temp_dir: TempDir, - _process: std::process::Child, -} - -impl TestServer { - pub fn start() -> Self { - setup(); - - let (temp_dir, db_url) = create_test_db(); + if server.is_none() { + // Create temporary database + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test.db"); + let db_url = format!("sqlite:{}", db_path.display()); let admin_password = "test_admin_password"; // Start server on a random port @@ -54,94 +51,86 @@ impl TestServer { let process = Command::new(brewlog_bin()) .args(&["serve", "--port", &port.to_string(), "--database", &db_url]) .env("BREWLOG_ADMIN_PASSWORD", admin_password) - .env("RUST_LOG", "info") + .env("RUST_LOG", "error") .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .expect("Failed to start brewlog server"); - // Wait for server to be ready with health check - let client = reqwest::blocking::Client::new(); + // Wait for server to be ready + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(2)) + .build() + .unwrap(); let health_url = format!("{}/api/v1/roasters", address); - let max_attempts = 30; // 30 seconds total - let mut attempts = 0; - while attempts < max_attempts { + for _ in 0..50 { if client.get(&health_url).send().is_ok() { break; } std::thread::sleep(std::time::Duration::from_millis(100)); - attempts += 1; } - if attempts >= max_attempts { - panic!("Server failed to start within 30 seconds"); - } + // Give it a bit more time to stabilize + std::thread::sleep(std::time::Duration::from_millis(200)); - Self { - address, + *server = Some(SharedServer { + address: address.clone(), admin_password: admin_password.to_string(), db_url, - _temp_dir: temp_dir, - _process: process, - } + temp_dir, + process, + }); } - pub fn create_token(&self, name: &str) -> String { - use std::io::Write; - - let mut child = Command::new(brewlog_bin()) - .args(&["create-token", "--name", name]) - .env("BREWLOG_SERVER", &self.address) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("Failed to spawn create-token command"); - - // Write username and password to stdin - { - let stdin = child.stdin.as_mut().expect("Failed to open stdin"); - writeln!(stdin, "admin").expect("Failed to write username"); - writeln!(stdin, "{}", self.admin_password).expect("Failed to write password"); - } - - let output = child - .wait_with_output() - .expect("Failed to wait for command"); - - assert!( - output.status.success(), - "Failed to create token: {}", - String::from_utf8_lossy(&output.stderr) - ); - - // Parse the output to extract the token - // The token is on the line after "Save this token securely" - let stdout = String::from_utf8(output.stdout).expect("Invalid UTF-8 in token output"); - - for line in stdout.lines() { - let trimmed = line.trim(); - // The token line starts with a base64-looking string (long alphanumeric with possible +/=) - if trimmed.len() > 40 && !trimmed.contains(':') && !trimmed.contains("export") { - return trimmed.to_string(); - } - } - - panic!("Could not find token in output:\n{}", stdout); - } + let srv = server.as_ref().unwrap(); + (srv.address.clone(), srv.admin_password.clone()) } -impl Drop for TestServer { - fn drop(&mut self) { - // Server process will be killed when _process is dropped - } +/// Get the shared server address and admin password +pub fn server_info() -> (String, String) { + get_or_start_server() +} + +/// Create a token for testing using the API directly (avoids interactive CLI) +pub fn create_token(name: &str) -> String { + let (address, password) = server_info(); + + // Use the API directly to create a token + let client = reqwest::blocking::Client::new(); + let response = client + .post(format!("{}/api/v1/tokens", address)) + .json(&serde_json::json!({ + "username": "admin", + "password": password, + "name": name + })) + .send() + .expect("Failed to create token via API"); + + assert!( + response.status().is_success(), + "Failed to create token: status={} body={}", + response.status(), + response.text().unwrap_or_default() + ); + + let token_response: serde_json::Value = + response.json().expect("Failed to parse token response"); + + token_response["token"] + .as_str() + .expect("Token not found in response") + .to_string() } /// Run a brewlog CLI command and return the output pub fn run_brewlog(args: &[&str], env: &[(&str, &str)]) -> std::process::Output { + let (address, _) = server_info(); + let mut cmd = Command::new(brewlog_bin()); cmd.args(args); + cmd.env("BREWLOG_SERVER", &address); for (key, value) in env { cmd.env(key, value); @@ -149,10 +138,3 @@ pub fn run_brewlog(args: &[&str], env: &[(&str, &str)]) -> std::process::Output cmd.output().expect("Failed to run brewlog command") } - -/// Check if output contains expected text -pub fn output_contains(output: &std::process::Output, text: &str) -> bool { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - stdout.contains(text) || stderr.contains(text) -} diff --git a/tests/cli/roasters_cli.rs b/tests/cli/roasters_cli.rs index d729d41..a1881c3 100644 --- a/tests/cli/roasters_cli.rs +++ b/tests/cli/roasters_cli.rs @@ -1,13 +1,13 @@ -use crate::helpers::{TestServer, run_brewlog}; +use crate::helpers::{create_token, run_brewlog, server_info}; use serde_json::Value; #[test] fn test_add_roaster_requires_authentication() { - let server = TestServer::start(); + let _ = server_info(); // Ensure server is started let output = run_brewlog( &["add-roaster", "--name", "Test Roasters", "--country", "UK"], - &[("BREWLOG_SERVER", &server.address)], + &[], ); assert!( @@ -18,15 +18,11 @@ fn test_add_roaster_requires_authentication() { #[test] fn test_add_roaster_with_authentication() { - let server = TestServer::start(); - let token = server.create_token("test-token"); + let token = create_token("test-add-roaster"); let output = run_brewlog( &["add-roaster", "--name", "Test Roasters", "--country", "UK"], - &[ - ("BREWLOG_TOKEN", &token), - ("BREWLOG_SERVER", &server.address), - ], + &[("BREWLOG_TOKEN", &token)], ); assert!( @@ -35,7 +31,6 @@ fn test_add_roaster_with_authentication() { String::from_utf8_lossy(&output.stderr) ); - // Parse the JSON output let stdout = String::from_utf8_lossy(&output.stdout); let roaster: Value = serde_json::from_str(&stdout).expect(&format!("Should output valid JSON, got: {}", stdout)); @@ -47,16 +42,15 @@ fn test_add_roaster_with_authentication() { #[test] fn test_list_roasters_works_without_authentication() { - let server = TestServer::start(); + let _ = server_info(); - let output = run_brewlog(&["list-roasters"], &[("BREWLOG_SERVER", &server.address)]); + let output = run_brewlog(&["list-roasters"], &[]); assert!( output.status.success(), "list-roasters should work without auth" ); - // Parse the JSON output let stdout = String::from_utf8_lossy(&output.stdout); let roasters: Value = serde_json::from_str(&stdout).expect("Should output valid JSON array"); @@ -65,8 +59,7 @@ fn test_list_roasters_works_without_authentication() { #[test] fn test_list_roasters_shows_added_roaster() { - let server = TestServer::start(); - let token = server.create_token("test-token"); + let token = create_token("test-list-roasters"); // Add a roaster let add_output = run_brewlog( @@ -77,10 +70,7 @@ fn test_list_roasters_shows_added_roaster() { "--country", "USA", ], - &[ - ("BREWLOG_TOKEN", &token), - ("BREWLOG_SERVER", &server.address), - ], + &[("BREWLOG_TOKEN", &token)], ); assert!( @@ -89,39 +79,32 @@ fn test_list_roasters_shows_added_roaster() { String::from_utf8_lossy(&add_output.stderr) ); - // Parse the added roaster let stdout = String::from_utf8_lossy(&add_output.stdout); let added_roaster: Value = serde_json::from_str(&stdout).expect("Should output valid JSON"); let roaster_id = added_roaster["id"].as_str().unwrap(); // List roasters - let list_output = run_brewlog(&["list-roasters"], &[("BREWLOG_SERVER", &server.address)]); + let list_output = run_brewlog(&["list-roasters"], &[]); assert!(list_output.status.success()); - // Parse and verify the list let list_stdout = String::from_utf8_lossy(&list_output.stdout); let roasters: Value = serde_json::from_str(&list_stdout).expect("Should output valid JSON array"); assert!(roasters.is_array(), "Should return an array"); let roasters_array = roasters.as_array().unwrap(); - assert_eq!(roasters_array.len(), 1, "Should have exactly one roaster"); - let listed_roaster = &roasters_array[0]; - assert_eq!(listed_roaster["id"], roaster_id); - assert_eq!(listed_roaster["name"], "Example Roasters"); - assert_eq!(listed_roaster["country"], "USA"); + // Find our roaster in the list + let found = roasters_array.iter().any(|r| r["id"] == roaster_id); + assert!(found, "Should find the added roaster in the list"); } #[test] fn test_delete_roaster_requires_authentication() { - let server = TestServer::start(); + let _ = server_info(); - let output = run_brewlog( - &["delete-roaster", "--id", "some-id"], - &[("BREWLOG_SERVER", &server.address)], - ); + let output = run_brewlog(&["delete-roaster", "--id", "some-id"], &[]); assert!( !output.status.success(), @@ -131,7 +114,7 @@ fn test_delete_roaster_requires_authentication() { #[test] fn test_update_roaster_requires_authentication() { - let server = TestServer::start(); + let _ = server_info(); let output = run_brewlog( &[ @@ -141,7 +124,7 @@ fn test_update_roaster_requires_authentication() { "--name", "Updated Name", ], - &[("BREWLOG_SERVER", &server.address)], + &[], ); assert!( diff --git a/tests/cli/roasts_cli.rs b/tests/cli/roasts_cli.rs index 889a2cc..df095d1 100644 --- a/tests/cli/roasts_cli.rs +++ b/tests/cli/roasts_cli.rs @@ -1,9 +1,9 @@ -use crate::helpers::{TestServer, run_brewlog}; +use crate::helpers::{create_token, run_brewlog, server_info}; use serde_json::Value; #[test] fn test_add_roast_requires_authentication() { - let server = TestServer::start(); + let _ = server_info(); let output = run_brewlog( &[ @@ -21,7 +21,7 @@ fn test_add_roast_requires_authentication() { "--process", "Washed", ], - &[("BREWLOG_SERVER", &server.address)], + &[], ); assert!( @@ -32,21 +32,16 @@ fn test_add_roast_requires_authentication() { #[test] fn test_add_roast_with_authentication() { - let server = TestServer::start(); - let token = server.create_token("test-token"); + let token = create_token("test-add-roast"); // First create a roaster let roaster_output = run_brewlog( &["add-roaster", "--name", "Test Roasters", "--country", "UK"], - &[ - ("BREWLOG_TOKEN", &token), - ("BREWLOG_SERVER", &server.address), - ], + &[("BREWLOG_TOKEN", &token)], ); assert!(roaster_output.status.success()); - // Extract roaster ID from output let roaster_stdout = String::from_utf8_lossy(&roaster_output.stdout); let roaster: Value = serde_json::from_str(&roaster_stdout).expect("Should output valid JSON"); let roaster_id = roaster["id"].as_str().unwrap(); @@ -68,10 +63,7 @@ fn test_add_roast_with_authentication() { "--process", "Washed", ], - &[ - ("BREWLOG_TOKEN", &token), - ("BREWLOG_SERVER", &server.address), - ], + &[("BREWLOG_TOKEN", &token)], ); assert!( @@ -80,7 +72,6 @@ fn test_add_roast_with_authentication() { String::from_utf8_lossy(&output.stderr) ); - // Parse and verify the output let stdout = String::from_utf8_lossy(&output.stdout); let roast: Value = serde_json::from_str(&stdout).expect("Should output valid JSON"); @@ -91,16 +82,15 @@ fn test_add_roast_with_authentication() { #[test] fn test_list_roasts_works_without_authentication() { - let server = TestServer::start(); + let _ = server_info(); - let output = run_brewlog(&["list-roasts"], &[("BREWLOG_SERVER", &server.address)]); + let output = run_brewlog(&["list-roasts"], &[]); assert!( output.status.success(), "list-roasts should work without auth" ); - // Parse the JSON output let stdout = String::from_utf8_lossy(&output.stdout); let roasts: Value = serde_json::from_str(&stdout).expect("Should output valid JSON array"); @@ -109,16 +99,12 @@ fn test_list_roasts_works_without_authentication() { #[test] fn test_list_roasts_shows_added_roast() { - let server = TestServer::start(); - let token = server.create_token("test-token"); + let token = create_token("test-list-roasts"); // First create a roaster let roaster_output = run_brewlog( &["add-roaster", "--name", "Test Roasters", "--country", "UK"], - &[ - ("BREWLOG_TOKEN", &token), - ("BREWLOG_SERVER", &server.address), - ], + &[("BREWLOG_TOKEN", &token)], ); let roaster_stdout = String::from_utf8_lossy(&roaster_output.stdout); @@ -142,45 +128,36 @@ fn test_list_roasts_shows_added_roast() { "--process", "Natural", ], - &[ - ("BREWLOG_TOKEN", &token), - ("BREWLOG_SERVER", &server.address), - ], + &[("BREWLOG_TOKEN", &token)], ); assert!(add_output.status.success()); - // Parse the added roast let stdout = String::from_utf8_lossy(&add_output.stdout); let added_roast: Value = serde_json::from_str(&stdout).unwrap(); let roast_id = added_roast["id"].as_str().unwrap(); // List roasts - let list_output = run_brewlog(&["list-roasts"], &[("BREWLOG_SERVER", &server.address)]); + let list_output = run_brewlog(&["list-roasts"], &[]); assert!(list_output.status.success()); - // Parse and verify the list let list_stdout = String::from_utf8_lossy(&list_output.stdout); let roasts: Value = serde_json::from_str(&list_stdout).unwrap(); assert!(roasts.is_array()); let roasts_array = roasts.as_array().unwrap(); - assert_eq!(roasts_array.len(), 1, "Should have exactly one roast"); - let listed_roast = &roasts_array[0]; - assert_eq!(listed_roast["id"], roast_id); - assert_eq!(listed_roast["name"], "Colombian Supremo"); + // Find our roast in the list + let found = roasts_array.iter().any(|r| r["id"] == roast_id); + assert!(found, "Should find the added roast in the list"); } #[test] fn test_delete_roast_requires_authentication() { - let server = TestServer::start(); + let _ = server_info(); - let output = run_brewlog( - &["delete-roast", "--id", "some-id"], - &[("BREWLOG_SERVER", &server.address)], - ); + let output = run_brewlog(&["delete-roast", "--id", "some-id"], &[]); assert!( !output.status.success(), diff --git a/tests/cli/tokens_cli.rs b/tests/cli/tokens_cli.rs index 12fa9b6..0b759cf 100644 --- a/tests/cli/tokens_cli.rs +++ b/tests/cli/tokens_cli.rs @@ -1,47 +1,47 @@ -use crate::helpers::{TestServer, output_contains, run_brewlog, setup}; +use crate::helpers::{brewlog_bin, create_token, run_brewlog, server_info}; #[test] fn test_create_token_without_server_fails() { - setup(); + use std::io::Write; + use std::process::Stdio; - let output = run_brewlog( - &[ - "create-token", - "--name", - "test-token", - "--server", - "http://localhost:9999", - ], - &[], + // Don't start server, use invalid address + let mut child = std::process::Command::new(brewlog_bin()) + .args(&["create-token", "--name", "test-token"]) + .env("BREWLOG_SERVER", "http://localhost:9999") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Failed to spawn command"); + + // Provide dummy credentials + { + if let Some(stdin) = child.stdin.as_mut() { + let _ = writeln!(stdin, "admin"); + let _ = writeln!(stdin, "password"); + } + } + + let output = child.wait_with_output().expect("Failed to get output"); + assert!( + !output.status.success(), + "Should fail when server is unreachable" ); - - assert!(!output.status.success()); } #[test] fn test_create_token_with_valid_credentials() { - let server = TestServer::start(); - - let output = run_brewlog( - &[ - "create-token", - "--name", - "test-token", - "--server", - &server.address, - ], - &[], - ); - - assert!(output.status.success(), "create-token should succeed"); - assert!(!output.stdout.is_empty(), "Should output a token"); + let token = create_token("test-create-token"); + assert!(!token.is_empty(), "Should create a non-empty token"); + assert!(token.len() > 40, "Token should be reasonably long"); } #[test] fn test_list_tokens_requires_authentication() { - let server = TestServer::start(); + let _ = server_info(); - let output = run_brewlog(&["list-tokens", "--server", &server.address], &[]); + let output = run_brewlog(&["list-tokens"], &[]); assert!( !output.status.success(), @@ -51,38 +51,27 @@ fn test_list_tokens_requires_authentication() { #[test] fn test_list_tokens_with_authentication() { - let server = TestServer::start(); - let token = server.create_token("test-token"); + let token = create_token("test-list-tokens"); - let output = run_brewlog( - &["list-tokens", "--server", &server.address], - &[("BREWLOG_TOKEN", &token)], - ); + let output = run_brewlog(&["list-tokens"], &[("BREWLOG_TOKEN", &token)]); assert!( output.status.success(), "list-tokens with auth should succeed" ); + + let stdout = String::from_utf8_lossy(&output.stdout); assert!( - output_contains(&output, "test-token"), + stdout.contains("test-list-tokens"), "Should list the created token" ); } #[test] fn test_revoke_token_requires_authentication() { - let server = TestServer::start(); + let _ = server_info(); - let output = run_brewlog( - &[ - "revoke-token", - "--id", - "some-id", - "--server", - &server.address, - ], - &[], - ); + let output = run_brewlog(&["revoke-token", "--id", "some-id"], &[]); assert!( !output.status.success(), @@ -92,25 +81,29 @@ fn test_revoke_token_requires_authentication() { #[test] fn test_revoke_token_with_authentication() { - let server = TestServer::start(); - let token = server.create_token("test-token"); - - // First list tokens to get the ID - let list_output = run_brewlog( - &["list-tokens", "--server", &server.address], - &[("BREWLOG_TOKEN", &token)], - ); + let token = create_token("test-revoke-token"); + // List tokens to get the ID + let list_output = run_brewlog(&["list-tokens"], &[("BREWLOG_TOKEN", &token)]); assert!(list_output.status.success()); - // Extract token ID from output (this depends on the CLI output format) - // For now, we'll skip the actual revoke test since we need to parse the output - // Just verify that revoke command exists - let output = run_brewlog(&["revoke-token", "--help"], &[]); + let list_stdout = String::from_utf8_lossy(&list_output.stdout); + let tokens: serde_json::Value = + serde_json::from_str(&list_stdout).expect("Should parse token list as JSON"); - assert!(output.status.success(), "revoke-token --help should work"); - assert!( - output_contains(&output, "Revoke"), - "Help should mention revoke" - ); + // Find a token to revoke + let tokens_array = tokens.as_array().expect("Should be an array"); + if let Some(first_token) = tokens_array.first() { + let token_id = first_token["id"].as_str().expect("Token should have ID"); + + let revoke_output = run_brewlog( + &["revoke-token", "--id", token_id], + &[("BREWLOG_TOKEN", &token)], + ); + + assert!( + revoke_output.status.success(), + "Should be able to revoke token" + ); + } } diff --git a/tests/server/helpers.rs b/tests/server/helpers.rs index be9c313..23db6cf 100644 --- a/tests/server/helpers.rs +++ b/tests/server/helpers.rs @@ -35,42 +35,6 @@ impl TestApp { pub fn api_url(&self, path: &str) -> String { format!("{}/api/v1{}", self.address, path) } - - /// Create an authenticated POST request - pub fn post(&self, path: &str) -> reqwest::RequestBuilder { - let client = Client::new(); - let mut req = client.post(self.api_url(path)); - if let Some(token) = &self.auth_token { - req = req.bearer_auth(token); - } - req - } - - /// Create an authenticated PUT request - pub fn put(&self, path: &str) -> reqwest::RequestBuilder { - let client = Client::new(); - let mut req = client.put(self.api_url(path)); - if let Some(token) = &self.auth_token { - req = req.bearer_auth(token); - } - req - } - - /// Create an authenticated DELETE request - pub fn delete(&self, path: &str) -> reqwest::RequestBuilder { - let client = Client::new(); - let mut req = client.delete(self.api_url(path)); - if let Some(token) = &self.auth_token { - req = req.bearer_auth(token); - } - req - } - - /// Create a GET request (doesn't need auth for reads) - pub fn get(&self, path: &str) -> reqwest::RequestBuilder { - let client = Client::new(); - client.get(self.api_url(path)) - } } pub async fn spawn_app() -> TestApp {