Ctrl K
ComunidadGuías…

tests/cli/helpers.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::time::Duration;

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,
}

/// Single shared test server for all CLI tests
static TEST_SERVER: Lazy<Mutex<Option<SharedServer>>> = Lazy::new(|| Mutex::new(None));

#[allow(clippy::expect_used)]
fn test_webauthn() -> Arc<Webauthn> {
    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.
///
/// 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,
        Err(poisoned) => poisoned.into_inner(),
    };

    if server.is_none() {
        eprintln!("Starting test server...");

        // 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());

        // Pick a random port
        let port = portpicker::pick_unused_port().ok_or("No ports available")?;
        let address = format!("http://127.0.0.1:{port}");
        let bind_address = format!("127.0.0.1:{port}");

        eprintln!("Starting server on {address}");

        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 (stats_tx, _stats_rx) = tokio::sync::mpsc::channel(1);
                let (timeline_tx, timeline_rx) = tokio::sync::mpsc::channel(32);
                let state = AppState::from_database(
                    &database,
                    AppStateConfig {
                        webauthn: test_webauthn(),
                        insecure_cookies: true,
                        foursquare_url: brewlog::infrastructure::foursquare::FOURSQUARE_SEARCH_URL
                            .to_string(),
                        foursquare_api_key: String::new(),
                        inference_provider:
                            brewlog::infrastructure::ai::InferenceProvider::OpenRouter {
                                url: brewlog::infrastructure::ai::OPENROUTER_URL.to_string(),
                                api_key: String::new(),
                                model: "openrouter/free".to_string(),
                            },
                        stats_invalidator: brewlog::application::services::StatsInvalidator::new(
                            stats_tx,
                        ),
                        timeline_invalidator:
                            brewlog::application::services::TimelineInvalidator::new(timeline_tx),
                    },
                );

                // Spawn background timeline rebuild task
                use brewlog::application::services::timeline_refresh::{
                    TimelineRebuilder, timeline_rebuild_task,
                };
                let rebuilder = TimelineRebuilder {
                    timeline_repo: state.timeline_repo.clone(),
                    roaster_repo: state.roaster_repo.clone(),
                    roast_repo: state.roast_repo.clone(),
                    bag_repo: state.bag_repo.clone(),
                    brew_repo: state.brew_repo.clone(),
                    cup_repo: state.cup_repo.clone(),
                    gear_repo: state.gear_repo.clone(),
                    cafe_repo: state.cafe_repo.clone(),
                };
                tokio::spawn(timeline_rebuild_task(
                    timeline_rx,
                    rebuilder,
                    std::time::Duration::from_millis(100),
                ));

                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
        rx.recv_timeout(Duration::from_secs(30))
            .map_err(|e| format!("Server failed to start within timeout: {e}"))?;

        eprintln!("Server ready on {address}");

        *server = Some(SharedServer {
            address: address.clone(),
            db_url: db_url.clone(),
            _temp_dir: temp_dir,
        });
    }

    #[allow(clippy::unwrap_used)]
    let srv = server.as_ref().unwrap();
    Ok((srv.address.clone(), srv.db_url.clone()))
}

/// Get the shared server address and database URL
pub fn server_info() -> (String, String) {
    ensure_server_started().expect("Failed to start test server")
}

/// Create a token for testing by directly inserting into the database
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'))",
        )
        .bind("admin")
        .bind(&uuid)
        .execute(&pool)
        .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")
                .fetch_one(&pool)
                .await
                .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'))",
        )
        .bind(user_id)
        .bind(&token_hash)
        .bind(name)
        .execute(&pool)
        .await
        .expect("Failed to insert token");

        token_value
    })
}

/// Run a brewlog CLI command and return the output
pub fn run_brewlog(args: &[&str], env: &[(&str, &str)]) -> std::process::Output {
    let (address, _) = ensure_server_started().expect("Failed to start test server");

    let mut cmd = Command::new(brewlog_bin());
    cmd.args(args);
    cmd.env("BREWLOG_URL", &address);

    for (key, value) in env {
        cmd.env(key, value);
    }

    cmd.output().expect("Failed to run brewlog command")
}

/// Generic helper: run a CLI command with auth, assert success, parse JSON, extract ID.
fn create_entity_cli(args: &[&str], token: &str, label: &str) -> String {
    let output = run_brewlog(args, &[("BREWLOG_TOKEN", token)]);
    if !output.status.success() {
        panic!(
            "Failed to create {label}: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }
    let stdout = String::from_utf8_lossy(&output.stdout);
    let value: serde_json::Value = serde_json::from_str(&stdout).expect("Should output valid JSON");
    value["id"]
        .as_i64()
        .unwrap_or_else(|| panic!("{label} id should be numeric"))
        .to_string()
}

pub fn create_roaster(name: &str, token: &str) -> String {
    create_entity_cli(
        &["roaster", "add", "--name", name, "--country", "UK"],
        token,
        "roaster",
    )
}

pub fn create_roast(roaster_id: &str, name: &str, token: &str) -> String {
    create_entity_cli(
        &[
            "roast",
            "add",
            "--roaster-id",
            roaster_id,
            "--name",
            name,
            "--origin",
            "Kenya",
            "--region",
            "Nyeri",
            "--producer",
            "Coop",
            "--process",
            "Washed",
            "--tasting-notes",
            "Blackcurrant",
        ],
        token,
        "roast",
    )
}

pub fn create_cafe(
    name: &str,
    city: &str,
    country: &str,
    latitude: &str,
    longitude: &str,
    token: &str,
) -> String {
    create_entity_cli(
        &[
            "cafe",
            "add",
            "--name",
            name,
            "--city",
            city,
            "--country",
            country,
            "--latitude",
            latitude,
            "--longitude",
            longitude,
        ],
        token,
        "cafe",
    )
}

pub fn create_bag(roast_id: &str, token: &str) -> String {
    create_entity_cli(
        &["bag", "add", "--roast-id", roast_id, "--amount", "250"],
        token,
        "bag",
    )
}

pub fn create_gear(category: &str, make: &str, model: &str, token: &str) -> String {
    create_entity_cli(
        &[
            "gear",
            "add",
            "--category",
            category,
            "--make",
            make,
            "--model",
            model,
        ],
        token,
        "gear",
    )
}

pub fn create_brew(bag_id: &str, grinder_id: &str, brewer_id: &str, token: &str) -> String {
    create_entity_cli(
        &[
            "brew",
            "add",
            "--bag-id",
            bag_id,
            "--grinder-id",
            grinder_id,
            "--brewer-id",
            brewer_id,
        ],
        token,
        "brew",
    )
}

pub fn create_cup(roast_id: &str, cafe_id: &str, token: &str) -> String {
    create_entity_cli(
        &["cup", "add", "--roast-id", roast_id, "--cafe-id", cafe_id],
        token,
        "cup",
    )
}