diff --git a/tests/cli/helpers.rs b/tests/cli/helpers.rs index 9a4bc7b..3803132 100644 --- a/tests/cli/helpers.rs +++ b/tests/cli/helpers.rs @@ -1,30 +1,51 @@ -use once_cell::sync::Lazy; -use std::process::{Command, Stdio}; -use std::sync::Mutex; +use std::process::Command; +use std::sync::{Arc, Mutex}; use std::time::Duration; -use tempfile::TempDir; -/// Shared test server state -/// Fields prefixed with `_` are kept alive to prevent cleanup: -/// - `_temp_dir`: keeps temporary database file from being deleted -/// - `_process`: keeps server process running +use once_cell::sync::Lazy; +use tempfile::TempDir; +use tokio::net::TcpListener; +use webauthn_rs::prelude::*; + +use brewlog::application::routes::app_router; +use brewlog::application::state::{AppState, AppStateConfig}; +use brewlog::infrastructure::database::Database; + +/// Shared test server state. +/// `_temp_dir` is kept alive to prevent the temporary database file from being deleted. +/// The server itself runs on a background thread and dies when the test binary exits. struct SharedServer { address: String, db_url: String, _temp_dir: TempDir, - _process: std::process::Child, } /// Single shared test server for all CLI tests static TEST_SERVER: Lazy>> = Lazy::new(|| Mutex::new(None)); +#[allow(clippy::expect_used)] +fn test_webauthn() -> Arc { + let rp_origin = url::Url::parse("http://localhost:0").expect("valid URL"); + Arc::new( + WebauthnBuilder::new("localhost", &rp_origin) + .expect("valid RP config") + .rp_name("Brewlog Test") + .build() + .expect("valid WebAuthn"), + ) +} + /// Get path to the brewlog binary pub fn brewlog_bin() -> String { // Cargo sets this for integration tests - works for both debug and release env!("CARGO_BIN_EXE_brewlog").to_string() } -/// Get or start the shared test server - handles mutex poisoning gracefully +/// Get or start the shared test server - handles mutex poisoning gracefully. +/// +/// Spawns the server in-process on a background thread (not as a child process), +/// so the server dies automatically when the test binary exits. +#[allow(clippy::too_many_lines)] fn ensure_server_started() -> Result<(String, String), String> { let mut server = match TEST_SERVER.lock() { Ok(guard) => guard, @@ -34,74 +55,79 @@ fn ensure_server_started() -> Result<(String, String), String> { if server.is_none() { eprintln!("Starting test server..."); - // Create temporary database - let temp_dir = TempDir::new().map_err(|e| format!("Failed to create temp dir: {}", e))?; + // Create temporary database (file-based so create_token() can connect independently) + let temp_dir = TempDir::new().map_err(|e| format!("Failed to create temp dir: {e}"))?; let db_path = temp_dir.path().join("test.db"); let db_url = format!("sqlite:{}", db_path.display()); - // Start server on a random port + // Pick a random port let port = portpicker::pick_unused_port().ok_or("No ports available")?; - let address = format!("http://localhost:{}", port); + let address = format!("http://127.0.0.1:{port}"); + let bind_address = format!("127.0.0.1:{port}"); - eprintln!("Starting server on {}", address); + eprintln!("Starting server on {address}"); - let bind_address = format!("127.0.0.1:{}", port); - let process = Command::new(brewlog_bin()) - .args(&[ - "serve", - "--bind-address", - &bind_address, - "--database-url", - &db_url, - ]) - .env("BREWLOG_RP_ID", "localhost") - .env("BREWLOG_RP_ORIGIN", &address) - .env("BREWLOG_OPENROUTER_API_KEY", "test-key") - .env("BREWLOG_FOURSQUARE_API_KEY", "test-key") - .env("RUST_LOG", "error") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| format!("Failed to start brewlog server: {}", e))?; + let db_url_for_thread = db_url.clone(); + let (tx, rx) = std::sync::mpsc::sync_channel::<()>(1); + + // Start server on a background thread with its own tokio runtime. + // The thread (and server) live until the test binary exits — no cleanup needed. + std::thread::spawn(move || { + #[allow(clippy::expect_used)] + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to create tokio runtime"); + + rt.block_on(async move { + #[allow(clippy::expect_used)] + let database = Database::connect(&db_url_for_thread) + .await + .expect("Failed to connect to test database"); + + let state = AppState::from_database( + &database, + AppStateConfig { + webauthn: test_webauthn(), + foursquare_url: brewlog::infrastructure::foursquare::FOURSQUARE_SEARCH_URL + .to_string(), + foursquare_api_key: String::new(), + openrouter_url: brewlog::infrastructure::ai::OPENROUTER_URL.to_string(), + openrouter_api_key: String::new(), + openrouter_model: "openrouter/free".to_string(), + }, + ); + + let app = app_router(state); + + #[allow(clippy::expect_used)] + let listener = TcpListener::bind(&bind_address) + .await + .expect("Failed to bind to port"); + + // Signal readiness to the main thread + let _ = tx.send(()); + + // Run server until the process exits + #[allow(clippy::expect_used)] + axum::serve(listener, app).await.expect("Server failed"); + }); + }); // Wait for server to be ready - let client = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(3)) - .build() - .map_err(|e| format!("Failed to create HTTP client: {}", e))?; - let health_url = format!("{}/api/v1/roasters", address); + rx.recv_timeout(Duration::from_secs(30)) + .map_err(|e| format!("Server failed to start within timeout: {e}"))?; - let mut server_ready = false; - for attempt in 0..100 { - match client.get(&health_url).send() { - Ok(_) => { - eprintln!("Server ready after {} attempts", attempt + 1); - server_ready = true; - break; - } - Err(_) => { - if attempt == 99 { - return Err( - "Server failed to start after 100 attempts (10 seconds)".to_string() - ); - } - std::thread::sleep(Duration::from_millis(100)); - } - } - } - - if !server_ready { - return Err("Server never became ready".to_string()); - } + eprintln!("Server ready on {address}"); *server = Some(SharedServer { address: address.clone(), db_url: db_url.clone(), _temp_dir: temp_dir, - _process: process, }); } + #[allow(clippy::unwrap_used)] let srv = server.as_ref().unwrap(); Ok((srv.address.clone(), srv.db_url.clone())) } @@ -115,18 +141,21 @@ pub fn server_info() -> (String, String) { pub fn create_token(name: &str) -> String { let (_, db_url) = ensure_server_started().expect("Failed to start test server"); + #[allow(clippy::expect_used)] let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("Failed to create tokio runtime"); rt.block_on(async { + #[allow(clippy::expect_used)] let pool = sqlx::SqlitePool::connect(&db_url) .await .expect("Failed to connect to test database"); // Ensure a user exists (INSERT OR IGNORE to handle concurrent test threads) let uuid = uuid::Uuid::new_v4().to_string(); + #[allow(clippy::expect_used)] sqlx::query( "INSERT OR IGNORE INTO users (username, uuid, created_at) VALUES (?, ?, datetime('now'))", ) @@ -136,6 +165,7 @@ pub fn create_token(name: &str) -> String { .await .expect("Failed to ensure test user"); + #[allow(clippy::expect_used)] let user_id: i64 = sqlx::query_scalar::<_, i64>("SELECT id FROM users WHERE username = ?") .bind("admin") @@ -144,10 +174,12 @@ pub fn create_token(name: &str) -> String { .expect("Failed to query test user"); // Generate and insert a bearer token + #[allow(clippy::expect_used)] let token_value = brewlog::infrastructure::auth::generate_token().expect("Failed to generate token"); let token_hash = brewlog::infrastructure::auth::hash_token(&token_value); + #[allow(clippy::expect_used)] sqlx::query( "INSERT INTO tokens (user_id, token_hash, name, created_at) VALUES (?, ?, ?, datetime('now'))", ) diff --git a/tests/server/helpers.rs b/tests/server/helpers.rs index a9382e1..a668523 100644 --- a/tests/server/helpers.rs +++ b/tests/server/helpers.rs @@ -1,34 +1,15 @@ use std::sync::Arc; use brewlog::application::routes::app_router; -use brewlog::application::server::AppState; -use brewlog::application::services::{ - BagService, BrewService, CafeService, CupService, GearService, RoastService, RoasterService, -}; +use brewlog::application::state::{AppState, AppStateConfig}; use brewlog::domain::cafes::{Cafe, NewCafe}; use brewlog::domain::repositories::{ - BagRepository, BrewRepository, CafeRepository, CupRepository, GearRepository, - PasskeyCredentialRepository, RegistrationTokenRepository, RoastRepository, RoasterRepository, - SessionRepository, TimelineEventRepository, TokenRepository, UserRepository, + CafeRepository, RoastRepository, RoasterRepository, SessionRepository, TimelineEventRepository, + TokenRepository, UserRepository, }; use brewlog::domain::roasters::{NewRoaster, Roaster}; use brewlog::domain::users::NewUser; -use brewlog::infrastructure::backup::BackupService; use brewlog::infrastructure::database::Database; -use brewlog::infrastructure::repositories::bags::SqlBagRepository; -use brewlog::infrastructure::repositories::brews::SqlBrewRepository; -use brewlog::infrastructure::repositories::cafes::SqlCafeRepository; -use brewlog::infrastructure::repositories::cups::SqlCupRepository; -use brewlog::infrastructure::repositories::gear::SqlGearRepository; -use brewlog::infrastructure::repositories::passkey_credentials::SqlPasskeyCredentialRepository; -use brewlog::infrastructure::repositories::registration_tokens::SqlRegistrationTokenRepository; -use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository; -use brewlog::infrastructure::repositories::roasts::SqlRoastRepository; -use brewlog::infrastructure::repositories::sessions::SqlSessionRepository; -use brewlog::infrastructure::repositories::timeline_events::SqlTimelineEventRepository; -use brewlog::infrastructure::repositories::tokens::SqlTokenRepository; -use brewlog::infrastructure::repositories::users::SqlUserRepository; -use brewlog::infrastructure::webauthn::ChallengeStore; use reqwest::Client; use tokio::net::TcpListener; use tokio::task::AbortHandle; @@ -83,160 +64,42 @@ fn test_webauthn() -> Arc { } pub async fn spawn_app() -> TestApp { - // Use in-memory SQLite database for testing let database = Database::connect("sqlite::memory:") .await .expect("Failed to connect to in-memory database"); - // Run migrations - database - .migrate() - .await - .expect("Failed to migrate database"); - - // Create repositories - let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool())); - let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool())); - let bag_repo = Arc::new(SqlBagRepository::new(database.clone_pool())); - let gear_repo = Arc::new(SqlGearRepository::new(database.clone_pool())); - let brew_repo = Arc::new(SqlBrewRepository::new(database.clone_pool())); - let cafe_repo = Arc::new(SqlCafeRepository::new(database.clone_pool())); - let cup_repo = Arc::new(SqlCupRepository::new(database.clone_pool())); - let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool())); - let user_repo: Arc = - Arc::new(SqlUserRepository::new(database.clone_pool())); - let token_repo: Arc = - Arc::new(SqlTokenRepository::new(database.clone_pool())); - let session_repo: Arc = - Arc::new(SqlSessionRepository::new(database.clone_pool())); - let passkey_repo: Arc = - Arc::new(SqlPasskeyCredentialRepository::new(database.clone_pool())); - let registration_token_repo: Arc = - Arc::new(SqlRegistrationTokenRepository::new(database.clone_pool())); - - spawn_app_inner( - database, - roaster_repo, - roast_repo, - bag_repo, - gear_repo, - brew_repo, - cafe_repo, - cup_repo, - timeline_repo, - user_repo, - token_repo, - session_repo, - passkey_repo, - registration_token_repo, - brewlog::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(), - String::new(), - brewlog::infrastructure::ai::OPENROUTER_URL.to_string(), - None, - ) - .await + spawn_app_inner(database, test_state_config(), None).await } -#[allow(clippy::too_many_arguments)] -async fn spawn_app_inner( - _database: Database, - roaster_repo: Arc, - roast_repo: Arc, - bag_repo: Arc, - gear_repo: Arc, - brew_repo: Arc, - cafe_repo: Arc, - cup_repo: Arc, - timeline_repo: Arc, - user_repo: Arc, - token_repo: Arc, - session_repo: Arc, - passkey_repo: Arc, - registration_token_repo: Arc, - foursquare_url: String, - foursquare_api_key: String, - openrouter_url: String, - mock_server: Option, -) -> TestApp { - let backup_service = Arc::new(BackupService::new(_database.clone_pool())); - - let session_repo_clone: Arc = session_repo.clone(); - - // Create services - let roaster_service = RoasterService::new( - roaster_repo.clone() as Arc, - timeline_repo.clone() as Arc, - ); - let roast_service = RoastService::new( - roast_repo.clone() as Arc, - roaster_repo.clone() as Arc, - timeline_repo.clone() as Arc, - ); - let bag_service = BagService::new( - bag_repo.clone() as Arc, - roast_repo.clone() as Arc, - roaster_repo.clone() as Arc, - timeline_repo.clone() as Arc, - ); - let brew_service = BrewService::new( - brew_repo.clone() as Arc, - timeline_repo.clone() as Arc, - ); - let gear_service = GearService::new( - gear_repo.clone() as Arc, - timeline_repo.clone() as Arc, - ); - let cafe_service = CafeService::new( - cafe_repo.clone() as Arc, - timeline_repo.clone() as Arc, - ); - let cup_service = CupService::new( - cup_repo.clone() as Arc, - timeline_repo.clone() as Arc, - ); - - // Create application state - let state = AppState { - roaster_repo: roaster_repo.clone(), - roast_repo: roast_repo.clone(), - bag_repo: bag_repo.clone(), - gear_repo: gear_repo.clone(), - brew_repo: brew_repo.clone(), - cafe_repo: cafe_repo.clone(), - cup_repo: cup_repo.clone(), - timeline_repo: timeline_repo.clone(), - user_repo: user_repo.clone(), - token_repo: token_repo.clone(), - session_repo, - passkey_repo, - registration_token_repo, - ai_usage_repo: Arc::new( - brewlog::infrastructure::repositories::ai_usage::SqlAiUsageRepository::new( - _database.clone_pool(), - ), - ), +fn test_state_config() -> AppStateConfig { + AppStateConfig { webauthn: test_webauthn(), - challenge_store: Arc::new(ChallengeStore::new()), - http_client: reqwest::Client::new(), - foursquare_url, - foursquare_api_key, - openrouter_url, + foursquare_url: brewlog::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(), + foursquare_api_key: String::new(), + openrouter_url: brewlog::infrastructure::ai::OPENROUTER_URL.to_string(), openrouter_api_key: String::new(), openrouter_model: "openrouter/free".to_string(), - backup_service, - roaster_service, - roast_service, - bag_service, - brew_service, - gear_service, - cafe_service, - cup_service, - }; + } +} + +async fn spawn_app_inner( + database: Database, + config: AppStateConfig, + mock_server: Option, +) -> TestApp { + let state = AppState::from_database(&database, config); + + // Clone repos we need for TestApp before consuming state in the router + let roaster_repo = state.roaster_repo.clone(); + let roast_repo = state.roast_repo.clone(); + let cafe_repo = state.cafe_repo.clone(); + let timeline_repo = state.timeline_repo.clone(); + let user_repo = state.user_repo.clone(); + let token_repo = state.token_repo.clone(); + let session_repo = state.session_repo.clone(); - // Create router let app = app_router(state); - // Bind to a random port let listener = TcpListener::bind("127.0.0.1:0") .await .expect("Failed to bind to random port"); @@ -244,7 +107,6 @@ async fn spawn_app_inner( let local_addr = listener.local_addr().expect("Failed to get local address"); let address = format!("http://{}", local_addr); - // Spawn the server in a background task let server_handle = tokio::spawn(async move { axum::serve(listener, app) .await @@ -260,7 +122,7 @@ async fn spawn_app_inner( timeline_repo, user_repo: Some(user_repo), token_repo: Some(token_repo), - session_repo: Some(session_repo_clone), + session_repo: Some(session_repo), auth_token: None, mock_server, server_handle, @@ -280,48 +142,13 @@ pub async fn spawn_app_with_foursquare_mock() -> TestApp { .await .expect("Failed to connect to in-memory database"); - database - .migrate() - .await - .expect("Failed to migrate database"); - - let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool())); - let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool())); - let bag_repo = Arc::new(SqlBagRepository::new(database.clone_pool())); - let gear_repo = Arc::new(SqlGearRepository::new(database.clone_pool())); - let brew_repo = Arc::new(SqlBrewRepository::new(database.clone_pool())); - let cafe_repo = Arc::new(SqlCafeRepository::new(database.clone_pool())); - let cup_repo = Arc::new(SqlCupRepository::new(database.clone_pool())); - let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool())); - let user_repo: Arc = - Arc::new(SqlUserRepository::new(database.clone_pool())); - let token_repo: Arc = - Arc::new(SqlTokenRepository::new(database.clone_pool())); - let session_repo: Arc = - Arc::new(SqlSessionRepository::new(database.clone_pool())); - let passkey_repo: Arc = - Arc::new(SqlPasskeyCredentialRepository::new(database.clone_pool())); - let registration_token_repo: Arc = - Arc::new(SqlRegistrationTokenRepository::new(database.clone_pool())); - let app = spawn_app_inner( database, - roaster_repo, - roast_repo, - bag_repo, - gear_repo, - brew_repo, - cafe_repo, - cup_repo, - timeline_repo, - user_repo, - token_repo, - session_repo, - passkey_repo, - registration_token_repo, - foursquare_url, - "test-api-key".to_string(), - brewlog::infrastructure::ai::OPENROUTER_URL.to_string(), + AppStateConfig { + foursquare_url, + foursquare_api_key: "test-api-key".to_string(), + ..test_state_config() + }, Some(mock_server), ) .await; @@ -628,48 +455,12 @@ pub async fn spawn_app_with_openrouter_mock() -> TestApp { .await .expect("Failed to connect to in-memory database"); - database - .migrate() - .await - .expect("Failed to migrate database"); - - let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool())); - let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool())); - let bag_repo = Arc::new(SqlBagRepository::new(database.clone_pool())); - let gear_repo = Arc::new(SqlGearRepository::new(database.clone_pool())); - let brew_repo = Arc::new(SqlBrewRepository::new(database.clone_pool())); - let cafe_repo = Arc::new(SqlCafeRepository::new(database.clone_pool())); - let cup_repo = Arc::new(SqlCupRepository::new(database.clone_pool())); - let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool())); - let user_repo: Arc = - Arc::new(SqlUserRepository::new(database.clone_pool())); - let token_repo: Arc = - Arc::new(SqlTokenRepository::new(database.clone_pool())); - let session_repo: Arc = - Arc::new(SqlSessionRepository::new(database.clone_pool())); - let passkey_repo: Arc = - Arc::new(SqlPasskeyCredentialRepository::new(database.clone_pool())); - let registration_token_repo: Arc = - Arc::new(SqlRegistrationTokenRepository::new(database.clone_pool())); - let app = spawn_app_inner( database, - roaster_repo, - roast_repo, - bag_repo, - gear_repo, - brew_repo, - cafe_repo, - cup_repo, - timeline_repo, - user_repo, - token_repo, - session_repo, - passkey_repo, - registration_token_repo, - brewlog::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(), - String::new(), - openrouter_url, + AppStateConfig { + openrouter_url, + ..test_state_config() + }, Some(mock_server), ) .await;