brewlog/tests/cli/helpers.rs
Jon Seager cb28a6f8bb
fix: wire edit forms for Datastar form submission and add CLI tests
Add {contentType: 'form'} to all edit template @put() calls so form
inputs are submitted by name rather than as Datastar signals. Replace
the quick notes text input on the brew edit page with toggle pill
buttons matching the add form. Add CLI tests for brew and cup update
commands.
2026-02-10 19:42:58 +00:00

343 lines
10 KiB
Rust

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 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(),
openrouter_url: brewlog::infrastructure::ai::OPENROUTER_URL.to_string(),
openrouter_api_key: String::new(),
openrouter_model: "openrouter/free".to_string(),
stats_invalidator: brewlog::application::services::StatsInvalidator::new(
stats_tx,
),
},
);
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",
)
}