diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/integration/cache.rs | 2 | ||||
| -rw-r--r-- | tests/integration/cleanup.rs | 3 | ||||
| -rw-r--r-- | tests/integration/cli_cleanup.rs | 341 | ||||
| -rw-r--r-- | tests/integration/cli_run.rs | 16 | ||||
| -rw-r--r-- | tests/integration/cli_status.rs | 544 | ||||
| -rw-r--r-- | tests/integration/cli_switch.rs | 330 | ||||
| -rw-r--r-- | tests/integration/cli_validate.rs | 137 | ||||
| -rw-r--r-- | tests/integration/concurrent.rs | 8 | ||||
| -rw-r--r-- | tests/integration/env_vars.rs | 4 | ||||
| -rw-r--r-- | tests/integration/harness.rs | 77 | ||||
| -rw-r--r-- | tests/integration/hooks.rs | 148 | ||||
| -rw-r--r-- | tests/integration/main.rs | 4 | ||||
| -rw-r--r-- | tests/integration/polling.rs | 1 | ||||
| -rw-r--r-- | tests/integration/rate_limit.rs | 114 | ||||
| -rw-r--r-- | tests/integration/sighup.rs | 34 |
15 files changed, 1469 insertions, 294 deletions
diff --git a/tests/integration/cache.rs b/tests/integration/cache.rs index 42d2a15..cc20cdd 100644 --- a/tests/integration/cache.rs +++ b/tests/integration/cache.rs @@ -81,6 +81,8 @@ async fn cache_dir_persists_across_builds() { .state .build_scheduler .in_progress + .lock() + .unwrap() .contains("cache-site") { break; diff --git a/tests/integration/cleanup.rs b/tests/integration/cleanup.rs index e0cc902..a8bc84f 100644 --- a/tests/integration/cleanup.rs +++ b/tests/integration/cleanup.rs @@ -31,7 +31,6 @@ async fn old_builds_cleaned_up() { base_dir: base_dir.clone(), log_dir: base_dir.join("logs"), log_level: "debug".to_owned(), - rate_limit_per_minute: 100, max_builds_to_keep: 2, git_timeout: None, sites: vec![site], @@ -62,6 +61,8 @@ async fn old_builds_cleaned_up() { .state .build_scheduler .in_progress + .lock() + .unwrap() .contains("cleanup-site") { break; diff --git a/tests/integration/cli_cleanup.rs b/tests/integration/cli_cleanup.rs new file mode 100644 index 0000000..822c7bc --- /dev/null +++ b/tests/integration/cli_cleanup.rs @@ -0,0 +1,341 @@ +use std::fmt::Write as _; +use std::process::Stdio; +use tempfile::TempDir; +use tokio::process::Command; + +fn witryna_bin() -> std::path::PathBuf { + let mut path = std::path::PathBuf::from(env!("CARGO_BIN_EXE_witryna")); + if !path.exists() { + path = std::path::PathBuf::from("target/debug/witryna"); + } + path +} + +async fn write_cleanup_config( + dir: &std::path::Path, + sites: &[&str], + max_builds: u32, +) -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) { + let base_dir = dir.join("data"); + let log_dir = dir.join("logs"); + tokio::fs::create_dir_all(&base_dir).await.unwrap(); + tokio::fs::create_dir_all(&log_dir).await.unwrap(); + + let mut sites_toml = String::new(); + for name in sites { + write!( + sites_toml, + r#" +[[sites]] +name = "{name}" +repo_url = "https://example.com/{name}.git" +branch = "main" +"# + ) + .unwrap(); + } + + let config_path = dir.join("witryna.toml"); + let config = format!( + r#"listen_address = "127.0.0.1:0" +container_runtime = "podman" +base_dir = "{base_dir}" +log_dir = "{log_dir}" +log_level = "info" +max_builds_to_keep = {max_builds} +{sites_toml}"#, + base_dir = base_dir.display(), + log_dir = log_dir.display(), + ); + tokio::fs::write(&config_path, config).await.unwrap(); + (config_path, base_dir, log_dir) +} + +async fn create_fake_builds( + base_dir: &std::path::Path, + log_dir: &std::path::Path, + site: &str, + timestamps: &[&str], +) { + let builds_dir = base_dir.join("builds").join(site); + let site_log_dir = log_dir.join(site); + tokio::fs::create_dir_all(&builds_dir).await.unwrap(); + tokio::fs::create_dir_all(&site_log_dir).await.unwrap(); + + for ts in timestamps { + tokio::fs::create_dir_all(builds_dir.join(ts)) + .await + .unwrap(); + tokio::fs::write(site_log_dir.join(format!("{ts}.log")), "build log") + .await + .unwrap(); + } +} + +// --------------------------------------------------------------------------- +// Tier 1: no container runtime / git needed +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn cli_cleanup_unknown_site() { + let tempdir = TempDir::new().unwrap(); + let (config_path, _, _) = write_cleanup_config(tempdir.path(), &["real-site"], 5).await; + + let output = Command::new(witryna_bin()) + .args([ + "cleanup", + "--config", + config_path.to_str().unwrap(), + "nonexistent", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(!output.status.success(), "should exit non-zero"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("not found"), + "should mention 'not found', got: {stderr}" + ); +} + +#[tokio::test] +async fn cli_cleanup_keep_zero_refused() { + let tempdir = TempDir::new().unwrap(); + let (config_path, _, _) = write_cleanup_config(tempdir.path(), &["my-site"], 5).await; + + let output = Command::new(witryna_bin()) + .args([ + "cleanup", + "--config", + config_path.to_str().unwrap(), + "--keep", + "0", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(!output.status.success(), "should exit non-zero"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--keep 0 would delete all builds"), + "should refuse --keep 0, got: {stderr}" + ); +} + +#[tokio::test] +async fn cli_cleanup_disabled_when_max_zero() { + let tempdir = TempDir::new().unwrap(); + let (config_path, _, _) = write_cleanup_config(tempdir.path(), &["my-site"], 0).await; + + let output = Command::new(witryna_bin()) + .args(["cleanup", "--config", config_path.to_str().unwrap()]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!( + output.status.success(), + "should exit 0, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("cleanup disabled"), + "should say 'cleanup disabled', got: {stderr}" + ); +} + +#[tokio::test] +async fn cli_cleanup_removes_old_builds() { + let tempdir = TempDir::new().unwrap(); + let (config_path, base_dir, log_dir) = + write_cleanup_config(tempdir.path(), &["site-a", "site-b"], 5).await; + + let timestamps = &[ + "20260126-100000-000001", + "20260126-100000-000002", + "20260126-100000-000003", + "20260126-100000-000004", + ]; + + create_fake_builds(&base_dir, &log_dir, "site-a", timestamps).await; + create_fake_builds(&base_dir, &log_dir, "site-b", timestamps).await; + + let output = Command::new(witryna_bin()) + .args([ + "cleanup", + "--config", + config_path.to_str().unwrap(), + "--keep", + "2", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!( + output.status.success(), + "should exit 0, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("site-a: removed"), + "should report site-a removals, got: {stderr}" + ); + assert!( + stderr.contains("site-b: removed"), + "should report site-b removals, got: {stderr}" + ); + assert!( + stderr.contains("total:"), + "should print total summary for multi-site, got: {stderr}" + ); + + // Verify filesystem: oldest 2 gone, newest 2 remain for each site + for site in &["site-a", "site-b"] { + let builds = base_dir.join("builds").join(site); + assert!(!builds.join("20260126-100000-000001").exists()); + assert!(!builds.join("20260126-100000-000002").exists()); + assert!(builds.join("20260126-100000-000003").exists()); + assert!(builds.join("20260126-100000-000004").exists()); + + let logs = log_dir.join(site); + assert!(!logs.join("20260126-100000-000001.log").exists()); + assert!(!logs.join("20260126-100000-000002.log").exists()); + assert!(logs.join("20260126-100000-000003.log").exists()); + assert!(logs.join("20260126-100000-000004.log").exists()); + } +} + +#[tokio::test] +async fn cli_cleanup_single_site_filter() { + let tempdir = TempDir::new().unwrap(); + let (config_path, base_dir, log_dir) = + write_cleanup_config(tempdir.path(), &["site-a", "site-b"], 5).await; + + let timestamps = &[ + "20260126-100000-000001", + "20260126-100000-000002", + "20260126-100000-000003", + "20260126-100000-000004", + ]; + + create_fake_builds(&base_dir, &log_dir, "site-a", timestamps).await; + create_fake_builds(&base_dir, &log_dir, "site-b", timestamps).await; + + let output = Command::new(witryna_bin()) + .args([ + "cleanup", + "--config", + config_path.to_str().unwrap(), + "--keep", + "2", + "site-a", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!( + output.status.success(), + "should exit 0, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + + // site-a should be cleaned + assert!( + stderr.contains("site-a: removed"), + "should report site-a removals, got: {stderr}" + ); + + // site-b should be untouched — not mentioned in output + assert!( + !stderr.contains("site-b"), + "site-b should not appear in output, got: {stderr}" + ); + + // No total line for single-site cleanup + assert!( + !stderr.contains("total:"), + "should not print total for single site, got: {stderr}" + ); + + // Verify site-b filesystem is untouched + let site_b_builds = base_dir.join("builds").join("site-b"); + assert!(site_b_builds.join("20260126-100000-000001").exists()); + assert!(site_b_builds.join("20260126-100000-000004").exists()); +} + +#[tokio::test] +async fn cli_cleanup_keep_overrides_config() { + let tempdir = TempDir::new().unwrap(); + // Config says max_builds_to_keep = 1 + let (config_path, base_dir, log_dir) = + write_cleanup_config(tempdir.path(), &["my-site"], 1).await; + + let timestamps = &[ + "20260126-100000-000001", + "20260126-100000-000002", + "20260126-100000-000003", + "20260126-100000-000004", + ]; + + create_fake_builds(&base_dir, &log_dir, "my-site", timestamps).await; + + // --keep 3 should override config's max_builds_to_keep=1 + let output = Command::new(witryna_bin()) + .args([ + "cleanup", + "--config", + config_path.to_str().unwrap(), + "--keep", + "3", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!( + output.status.success(), + "should exit 0, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // With --keep 3 and 4 builds: only 1 should be removed + let builds = base_dir.join("builds").join("my-site"); + assert!( + !builds.join("20260126-100000-000001").exists(), + "oldest should be removed" + ); + assert!( + builds.join("20260126-100000-000002").exists(), + "second should remain" + ); + assert!( + builds.join("20260126-100000-000003").exists(), + "third should remain" + ); + assert!( + builds.join("20260126-100000-000004").exists(), + "newest should remain" + ); +} diff --git a/tests/integration/cli_run.rs b/tests/integration/cli_run.rs index 0ea8d20..e12beb5 100644 --- a/tests/integration/cli_run.rs +++ b/tests/integration/cli_run.rs @@ -80,10 +80,10 @@ sites = [] let output = Command::new(witryna_bin()) .args([ - "--config", - config_path.to_str().unwrap(), "run", "nonexistent", + "--config", + config_path.to_str().unwrap(), ]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -130,10 +130,10 @@ async fn cli_run_build_failure_exits_nonzero() { let output = Command::new(witryna_bin()) .args([ - "--config", - config_path.to_str().unwrap(), "run", "fail-site", + "--config", + config_path.to_str().unwrap(), ]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -193,10 +193,10 @@ async fn cli_run_builds_site_successfully() { let output = Command::new(witryna_bin()) .args([ - "--config", - config_path.to_str().unwrap(), "run", "test-site", + "--config", + config_path.to_str().unwrap(), ]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -254,11 +254,11 @@ async fn cli_run_verbose_shows_build_output() { let output = Command::new(witryna_bin()) .args([ - "--config", - config_path.to_str().unwrap(), "run", "verbose-site", "--verbose", + "--config", + config_path.to_str().unwrap(), ]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) diff --git a/tests/integration/cli_status.rs b/tests/integration/cli_status.rs index 25135fb..4eb50a4 100644 --- a/tests/integration/cli_status.rs +++ b/tests/integration/cli_status.rs @@ -1,3 +1,4 @@ +use std::fmt::Write as _; use std::process::Stdio; use tempfile::TempDir; use tokio::process::Command; @@ -12,17 +13,16 @@ fn witryna_bin() -> std::path::PathBuf { } /// Write a minimal witryna.toml config for status tests. -async fn write_status_config( - dir: &std::path::Path, - sites: &[&str], - log_dir: &std::path::Path, -) -> std::path::PathBuf { +async fn write_status_config(dir: &std::path::Path, sites: &[&str]) -> std::path::PathBuf { let base_dir = dir.join("data"); + let log_dir = dir.join("logs"); tokio::fs::create_dir_all(&base_dir).await.unwrap(); + tokio::fs::create_dir_all(&log_dir).await.unwrap(); let mut sites_toml = String::new(); for name in sites { - sites_toml.push_str(&format!( + write!( + sites_toml, r#" [[sites]] name = "{name}" @@ -30,7 +30,8 @@ repo_url = "https://example.com/{name}.git" branch = "main" webhook_token = "unused" "# - )); + ) + .unwrap(); } let config_path = dir.join("witryna.toml"); @@ -48,63 +49,43 @@ log_level = "info" config_path } -/// Write a fake build log with a valid header. -async fn write_test_build_log( - log_dir: &std::path::Path, +/// Write a state.json for a site with the new format. +async fn write_state_json( + base_dir: &std::path::Path, site_name: &str, - timestamp: &str, - status: &str, - commit: &str, - image: &str, - duration: &str, + current: &str, + builds: &[serde_json::Value], ) { - let site_log_dir = log_dir.join(site_name); - tokio::fs::create_dir_all(&site_log_dir).await.unwrap(); - - let content = format!( - "=== BUILD LOG ===\n\ - Site: {site_name}\n\ - Timestamp: {timestamp}\n\ - Git Commit: {commit}\n\ - Image: {image}\n\ - Duration: {duration}\n\ - Status: {status}\n\ - \n\ - === STDOUT ===\n\ - build output\n\ - \n\ - === STDERR ===\n" - ); + let state_dir = base_dir.join("builds").join(site_name); + tokio::fs::create_dir_all(&state_dir).await.unwrap(); + + let content = serde_json::json!({ + "current": current, + "builds": builds, + }); - let log_file = site_log_dir.join(format!("{timestamp}.log")); - tokio::fs::write(&log_file, content).await.unwrap(); + let state_path = state_dir.join("state.json"); + tokio::fs::write(&state_path, content.to_string()) + .await + .unwrap(); } -/// Write a fake hook log with a valid header. -async fn write_test_hook_log( - log_dir: &std::path::Path, - site_name: &str, - timestamp: &str, +/// Create a build entry JSON value. +fn build_entry( status: &str, -) { - let site_log_dir = log_dir.join(site_name); - tokio::fs::create_dir_all(&site_log_dir).await.unwrap(); - - let content = format!( - "=== HOOK LOG ===\n\ - Site: {site_name}\n\ - Timestamp: {timestamp}\n\ - Command: hook-cmd\n\ - Duration: 1s\n\ - Status: {status}\n\ - \n\ - === STDOUT ===\n\ - \n\ - === STDERR ===\n" - ); - - let log_file = site_log_dir.join(format!("{timestamp}-hook.log")); - tokio::fs::write(&log_file, content).await.unwrap(); + timestamp: &str, + git_commit: &str, + duration: &str, + log: &str, +) -> serde_json::Value { + serde_json::json!({ + "status": status, + "timestamp": timestamp, + "started_at": "2026-02-10T12:00:00Z", + "git_commit": git_commit, + "duration": duration, + "log": log, + }) } // --------------------------------------------------------------------------- @@ -114,13 +95,10 @@ async fn write_test_hook_log( #[tokio::test] async fn cli_status_no_builds() { let tempdir = TempDir::new().unwrap(); - let log_dir = tempdir.path().join("logs"); - tokio::fs::create_dir_all(&log_dir).await.unwrap(); - - let config_path = write_status_config(tempdir.path(), &["empty-site"], &log_dir).await; + let config_path = write_status_config(tempdir.path(), &["empty-site"]).await; let output = Command::new(witryna_bin()) - .args(["--config", config_path.to_str().unwrap(), "status"]) + .args(["status", "--config", config_path.to_str().unwrap()]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() @@ -139,24 +117,25 @@ async fn cli_status_no_builds() { #[tokio::test] async fn cli_status_single_build() { let tempdir = TempDir::new().unwrap(); - let log_dir = tempdir.path().join("logs"); - tokio::fs::create_dir_all(&log_dir).await.unwrap(); + let base_dir = tempdir.path().join("data"); + let config_path = write_status_config(tempdir.path(), &["my-site"]).await; - write_test_build_log( - &log_dir, + write_state_json( + &base_dir, "my-site", "20260126-143000-123456", - "success", - "abc123d", - "node:20-alpine", - "45s", + &[build_entry( + "success", + "20260126-143000-123456", + "abc123d", + "45s", + "/logs/my-site/20260126-143000-123456.log", + )], ) .await; - let config_path = write_status_config(tempdir.path(), &["my-site"], &log_dir).await; - let output = Command::new(witryna_bin()) - .args(["--config", config_path.to_str().unwrap(), "status"]) + .args(["status", "--config", config_path.to_str().unwrap()]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() @@ -174,28 +153,29 @@ async fn cli_status_single_build() { #[tokio::test] async fn cli_status_json_output() { let tempdir = TempDir::new().unwrap(); - let log_dir = tempdir.path().join("logs"); - tokio::fs::create_dir_all(&log_dir).await.unwrap(); + let base_dir = tempdir.path().join("data"); + let config_path = write_status_config(tempdir.path(), &["json-site"]).await; - write_test_build_log( - &log_dir, + write_state_json( + &base_dir, "json-site", "20260126-143000-123456", - "success", - "abc123d", - "node:20-alpine", - "45s", + &[build_entry( + "success", + "20260126-143000-123456", + "abc123d", + "45s", + "/logs/json-site/20260126-143000-123456.log", + )], ) .await; - let config_path = write_status_config(tempdir.path(), &["json-site"], &log_dir).await; - let output = Command::new(witryna_bin()) .args([ - "--config", - config_path.to_str().unwrap(), "status", "--json", + "--config", + config_path.to_str().unwrap(), ]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -217,41 +197,43 @@ async fn cli_status_json_output() { #[tokio::test] async fn cli_status_site_filter() { let tempdir = TempDir::new().unwrap(); - let log_dir = tempdir.path().join("logs"); - tokio::fs::create_dir_all(&log_dir).await.unwrap(); + let base_dir = tempdir.path().join("data"); + let config_path = write_status_config(tempdir.path(), &["site-a", "site-b"]).await; - // Create logs for two sites - write_test_build_log( - &log_dir, + write_state_json( + &base_dir, "site-a", "20260126-143000-000000", - "success", - "aaa1111", - "alpine:latest", - "10s", + &[build_entry( + "success", + "20260126-143000-000000", + "aaa1111", + "10s", + "/logs/a.log", + )], ) .await; - write_test_build_log( - &log_dir, + write_state_json( + &base_dir, "site-b", "20260126-150000-000000", - "success", - "bbb2222", - "alpine:latest", - "20s", + &[build_entry( + "success", + "20260126-150000-000000", + "bbb2222", + "20s", + "/logs/b.log", + )], ) .await; - let config_path = write_status_config(tempdir.path(), &["site-a", "site-b"], &log_dir).await; - let output = Command::new(witryna_bin()) .args([ - "--config", - config_path.to_str().unwrap(), "status", - "--site", "site-a", + "--config", + config_path.to_str().unwrap(), ]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -271,33 +253,340 @@ async fn cli_status_site_filter() { #[tokio::test] async fn cli_status_hook_failed() { let tempdir = TempDir::new().unwrap(); - let log_dir = tempdir.path().join("logs"); - tokio::fs::create_dir_all(&log_dir).await.unwrap(); + let base_dir = tempdir.path().join("data"); + let config_path = write_status_config(tempdir.path(), &["hook-site"]).await; - // Build succeeded, but hook failed - write_test_build_log( - &log_dir, + write_state_json( + &base_dir, "hook-site", "20260126-143000-123456", - "success", - "abc123d", - "alpine:latest", - "12s", + &[build_entry( + "hook failed", + "20260126-143000-123456", + "abc123d", + "12s", + "/logs/hook-site/20260126-143000-123456.log", + )], ) .await; - write_test_hook_log( - &log_dir, - "hook-site", + let output = Command::new(witryna_bin()) + .args(["status", "--config", config_path.to_str().unwrap()]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(output.status.success(), "should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("hook failed"), + "should show 'hook failed', got: {stdout}" + ); +} + +#[tokio::test] +async fn cli_status_building_shows_in_progress() { + let tempdir = TempDir::new().unwrap(); + let base_dir = tempdir.path().join("data"); + let config_path = write_status_config(tempdir.path(), &["building-site"]).await; + + let started_at = witryna::time::format_rfc3339(std::time::SystemTime::now()); + + let state_dir = base_dir.join("builds").join("building-site"); + tokio::fs::create_dir_all(&state_dir).await.unwrap(); + let content = serde_json::json!({ + "current": "", + "builds": [{ + "status": "building", + "timestamp": "20260210-120000-000000", + "started_at": started_at, + "git_commit": "", + "duration": "", + "log": "/logs/building-site/20260210-120000-000000.log", + }], + }); + tokio::fs::write(state_dir.join("state.json"), content.to_string()) + .await + .unwrap(); + + let output = Command::new(witryna_bin()) + .args(["status", "--config", config_path.to_str().unwrap()]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(output.status.success(), "should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("building"), + "should show 'building' status, got: {stdout}" + ); +} + +#[tokio::test] +async fn cli_status_json_includes_building_state() { + let tempdir = TempDir::new().unwrap(); + let base_dir = tempdir.path().join("data"); + let config_path = write_status_config(tempdir.path(), &["json-building"]).await; + + let started_at = witryna::time::format_rfc3339(std::time::SystemTime::now()); + + let state_dir = base_dir.join("builds").join("json-building"); + tokio::fs::create_dir_all(&state_dir).await.unwrap(); + let content = serde_json::json!({ + "current": "", + "builds": [{ + "status": "building", + "timestamp": "20260210-120000-000000", + "started_at": started_at, + "git_commit": "", + "duration": "", + "log": "/logs/json-building/20260210-120000-000000.log", + }], + }); + tokio::fs::write(state_dir.join("state.json"), content.to_string()) + .await + .unwrap(); + + let output = Command::new(witryna_bin()) + .args([ + "status", + "--json", + "--config", + config_path.to_str().unwrap(), + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(output.status.success(), "should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["site_name"], "json-building"); + assert_eq!(arr[0]["status"], "building"); +} + +// --------------------------------------------------------------------------- +// current_build / "+" marker tests +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn cli_status_marks_current_build() { + let tempdir = TempDir::new().unwrap(); + let base_dir = tempdir.path().join("data"); + let config_path = write_status_config(tempdir.path(), &["my-site"]).await; + + write_state_json( + &base_dir, + "my-site", + "20260126-143000-123456", + &[build_entry( + "success", + "20260126-143000-123456", + "abc123d", + "45s", + "/logs/my-site/20260126-143000-123456.log", + )], + ) + .await; + + let output = Command::new(witryna_bin()) + .args(["status", "--config", config_path.to_str().unwrap()]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(output.status.success(), "should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + + let data_line = stdout + .lines() + .find(|l| l.contains("my-site")) + .expect("should have my-site row"); + assert!( + data_line.starts_with('+'), + "current build row should start with '+', got: {data_line}" + ); +} + +#[tokio::test] +async fn cli_status_no_current_no_marker() { + let tempdir = TempDir::new().unwrap(); + let base_dir = tempdir.path().join("data"); + let config_path = write_status_config(tempdir.path(), &["my-site"]).await; + + write_state_json( + &base_dir, + "my-site", + "", // no current + &[build_entry( + "success", + "20260126-143000-123456", + "abc123d", + "45s", + "/logs/my-site/20260126-143000-123456.log", + )], + ) + .await; + + let output = Command::new(witryna_bin()) + .args(["status", "--config", config_path.to_str().unwrap()]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(output.status.success(), "should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + + for line in stdout.lines().skip(1) { + assert!( + !line.starts_with('+'), + "no row should have '+' without current, got: {line}" + ); + } +} + +#[tokio::test] +async fn cli_status_json_includes_current_build() { + let tempdir = TempDir::new().unwrap(); + let base_dir = tempdir.path().join("data"); + let config_path = write_status_config(tempdir.path(), &["json-site"]).await; + + write_state_json( + &base_dir, + "json-site", + "20260126-143000-123456", + &[build_entry( + "success", + "20260126-143000-123456", + "abc123d", + "45s", + "/logs/json-site/20260126-143000-123456.log", + )], + ) + .await; + + let output = Command::new(witryna_bin()) + .args([ + "status", + "--json", + "--config", + config_path.to_str().unwrap(), + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(output.status.success(), "should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!( + arr[0]["current_build"], "20260126-143000-123456", + "JSON should include current_build field" + ); +} + +#[tokio::test] +async fn cli_status_single_site_shows_all_builds() { + let tempdir = TempDir::new().unwrap(); + let base_dir = tempdir.path().join("data"); + let config_path = write_status_config(tempdir.path(), &["my-site"]).await; + + write_state_json( + &base_dir, + "my-site", "20260126-143000-123456", - "failed (exit code 1)", + &[ + build_entry( + "failed", + "20260126-150000-000000", + "def4567", + "30s", + "/logs/2.log", + ), + build_entry( + "success", + "20260126-143000-123456", + "abc123d", + "45s", + "/logs/1.log", + ), + ], ) .await; - let config_path = write_status_config(tempdir.path(), &["hook-site"], &log_dir).await; + // Single-site view + let output = Command::new(witryna_bin()) + .args([ + "status", + "my-site", + "--config", + config_path.to_str().unwrap(), + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(output.status.success(), "should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + + let data_lines: Vec<&str> = stdout.lines().skip(1).collect(); + assert_eq!(data_lines.len(), 2, "should have 2 build rows"); + + // First row: failed (no marker — current points to a different timestamp) + assert!( + !data_lines[0].starts_with('+'), + "failed build should not have '+': {}", + data_lines[0] + ); + // Second row: success (has marker — matches current) + assert!( + data_lines[1].starts_with('+'), + "current build should have '+': {}", + data_lines[1] + ); +} + +#[tokio::test] +async fn cli_status_failed_build_shows_failed() { + let tempdir = TempDir::new().unwrap(); + let base_dir = tempdir.path().join("data"); + let config_path = write_status_config(tempdir.path(), &["fail-site"]).await; + + write_state_json( + &base_dir, + "fail-site", + "", + &[build_entry( + "failed", + "20260126-160000-000000", + "def4567", + "2m 0s", + "/logs/fail-site/20260126-160000-000000.log", + )], + ) + .await; let output = Command::new(witryna_bin()) - .args(["--config", config_path.to_str().unwrap(), "status"]) + .args(["status", "--config", config_path.to_str().unwrap()]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() @@ -307,7 +596,16 @@ async fn cli_status_hook_failed() { assert!(output.status.success(), "should exit 0"); let stdout = String::from_utf8_lossy(&output.stdout); assert!( - stdout.contains("hook failed"), - "should show 'hook failed', got: {stdout}" + stdout.contains("failed"), + "should show 'failed' (not a long error), got: {stdout}" + ); + // Verify that status column is clean (no long error string breaking the table) + let data_line = stdout + .lines() + .find(|l| l.contains("fail-site")) + .expect("should have fail-site row"); + assert!( + data_line.contains("def4567"), + "should show commit in correct column, got: {data_line}" ); } diff --git a/tests/integration/cli_switch.rs b/tests/integration/cli_switch.rs new file mode 100644 index 0000000..fcbced6 --- /dev/null +++ b/tests/integration/cli_switch.rs @@ -0,0 +1,330 @@ +use std::fmt::Write as _; +use std::process::Stdio; +use tempfile::TempDir; +use tokio::process::Command; + +fn witryna_bin() -> std::path::PathBuf { + let mut path = std::path::PathBuf::from(env!("CARGO_BIN_EXE_witryna")); + if !path.exists() { + path = std::path::PathBuf::from("target/debug/witryna"); + } + path +} + +async fn write_switch_config( + dir: &std::path::Path, + sites: &[&str], +) -> (std::path::PathBuf, std::path::PathBuf) { + let base_dir = dir.join("data"); + tokio::fs::create_dir_all(&base_dir).await.unwrap(); + + let mut sites_toml = String::new(); + for name in sites { + write!( + sites_toml, + r#" +[[sites]] +name = "{name}" +repo_url = "https://example.com/{name}.git" +branch = "main" +webhook_token = "unused" +"# + ) + .unwrap(); + } + + let config_path = dir.join("witryna.toml"); + let config = format!( + r#"listen_address = "127.0.0.1:0" +container_runtime = "podman" +base_dir = "{base_dir}" +log_dir = "{log_dir}" +log_level = "info" +{sites_toml}"#, + base_dir = base_dir.display(), + log_dir = dir.join("logs").display(), + ); + tokio::fs::write(&config_path, config).await.unwrap(); + (config_path, base_dir) +} + +// --------------------------------------------------------------------------- +// Tier 1: no container runtime / git needed +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn cli_switch_unknown_site_exits_nonzero() { + let tempdir = TempDir::new().unwrap(); + let (config_path, _) = write_switch_config(tempdir.path(), &["real-site"]).await; + + let output = Command::new(witryna_bin()) + .args([ + "switch", + "--config", + config_path.to_str().unwrap(), + "nonexistent", + "20260126-143000-123456", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(!output.status.success(), "should exit non-zero"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("not found"), + "should mention 'not found', got: {stderr}" + ); +} + +#[tokio::test] +async fn cli_switch_nonexistent_build_exits_nonzero() { + let tempdir = TempDir::new().unwrap(); + let (config_path, base_dir) = write_switch_config(tempdir.path(), &["my-site"]).await; + + // Create builds dir with one existing build + let builds_dir = base_dir.join("builds").join("my-site"); + tokio::fs::create_dir_all(builds_dir.join("20260126-100000-000001")) + .await + .unwrap(); + + let output = Command::new(witryna_bin()) + .args([ + "switch", + "--config", + config_path.to_str().unwrap(), + "my-site", + "20260126-999999-999999", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(!output.status.success(), "should exit non-zero"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("not found"), + "should mention 'not found', got: {stderr}" + ); + assert!( + stderr.contains("20260126-100000-000001"), + "should list available builds, got: {stderr}" + ); +} + +#[tokio::test] +async fn cli_switch_invalid_timestamp_format_exits_nonzero() { + let tempdir = TempDir::new().unwrap(); + let (config_path, base_dir) = write_switch_config(tempdir.path(), &["my-site"]).await; + + // Create builds dir so the "no builds dir" check doesn't fire first + let builds_dir = base_dir.join("builds").join("my-site"); + tokio::fs::create_dir_all(&builds_dir).await.unwrap(); + + let output = Command::new(witryna_bin()) + .args([ + "switch", + "--config", + config_path.to_str().unwrap(), + "my-site", + "not-a-timestamp", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(!output.status.success(), "should exit non-zero"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("not a valid build timestamp"), + "should mention invalid timestamp, got: {stderr}" + ); +} + +#[tokio::test] +async fn cli_switch_updates_symlink() { + let tempdir = TempDir::new().unwrap(); + let (config_path, base_dir) = write_switch_config(tempdir.path(), &["my-site"]).await; + + let builds_dir = base_dir.join("builds").join("my-site"); + let build1 = builds_dir.join("20260126-100000-000001"); + let build2 = builds_dir.join("20260126-100000-000002"); + tokio::fs::create_dir_all(&build1).await.unwrap(); + tokio::fs::create_dir_all(&build2).await.unwrap(); + + // Point current at build1 + let current = builds_dir.join("current"); + tokio::fs::symlink(&build1, ¤t).await.unwrap(); + + // Switch to build2 + let output = Command::new(witryna_bin()) + .args([ + "switch", + "--config", + config_path.to_str().unwrap(), + "my-site", + "20260126-100000-000002", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!( + output.status.success(), + "should exit 0, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Verify symlink now points to build2 + let target = tokio::fs::read_link(¤t).await.unwrap(); + assert_eq!(target, build2, "symlink should point to build2"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("switched my-site to build 20260126-100000-000002"), + "should confirm switch, got: {stderr}" + ); +} + +#[tokio::test] +async fn cli_switch_preserves_builds() { + let tempdir = TempDir::new().unwrap(); + let (config_path, base_dir) = write_switch_config(tempdir.path(), &["my-site"]).await; + + let builds_dir = base_dir.join("builds").join("my-site"); + let build1 = builds_dir.join("20260126-100000-000001"); + let build2 = builds_dir.join("20260126-100000-000002"); + tokio::fs::create_dir_all(&build1).await.unwrap(); + tokio::fs::create_dir_all(&build2).await.unwrap(); + + // Point current at build1 + let current = builds_dir.join("current"); + tokio::fs::symlink(&build1, ¤t).await.unwrap(); + + // Switch to build2 + let output = Command::new(witryna_bin()) + .args([ + "switch", + "--config", + config_path.to_str().unwrap(), + "my-site", + "20260126-100000-000002", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(output.status.success(), "should exit 0"); + + // Both builds should still exist + assert!(build1.exists(), "build1 should still exist after switch"); + assert!(build2.exists(), "build2 should still exist after switch"); +} + +#[tokio::test] +async fn cli_switch_updates_state_json() { + let tempdir = TempDir::new().unwrap(); + let (config_path, base_dir) = write_switch_config(tempdir.path(), &["my-site"]).await; + + let builds_dir = base_dir.join("builds").join("my-site"); + let build1 = builds_dir.join("20260126-100000-000001"); + let build2 = builds_dir.join("20260126-100000-000002"); + tokio::fs::create_dir_all(&build1).await.unwrap(); + tokio::fs::create_dir_all(&build2).await.unwrap(); + + // Write initial state.json with current pointing to build1 + let state_json = serde_json::json!({ + "current": "20260126-100000-000001", + "builds": [ + { + "status": "success", + "timestamp": "20260126-100000-000002", + "started_at": "2026-01-26T10:00:00Z", + "git_commit": "bbb2222", + "duration": "20s", + "log": "/logs/2.log", + }, + { + "status": "success", + "timestamp": "20260126-100000-000001", + "started_at": "2026-01-26T10:00:00Z", + "git_commit": "aaa1111", + "duration": "10s", + "log": "/logs/1.log", + } + ], + }); + tokio::fs::write(builds_dir.join("state.json"), state_json.to_string()) + .await + .unwrap(); + + // Create symlink + let current = builds_dir.join("current"); + tokio::fs::symlink(&build1, ¤t).await.unwrap(); + + // Switch to build2 + let output = Command::new(witryna_bin()) + .args([ + "switch", + "--config", + config_path.to_str().unwrap(), + "my-site", + "20260126-100000-000002", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(output.status.success(), "should exit 0"); + + // Verify state.json was updated + let state_content = tokio::fs::read_to_string(builds_dir.join("state.json")) + .await + .unwrap(); + let state: serde_json::Value = serde_json::from_str(&state_content).unwrap(); + assert_eq!( + state["current"], "20260126-100000-000002", + "state.json current should be updated after switch" + ); +} + +#[tokio::test] +async fn cli_switch_no_builds_dir_exits_nonzero() { + let tempdir = TempDir::new().unwrap(); + let (config_path, _) = write_switch_config(tempdir.path(), &["my-site"]).await; + + // Don't create builds directory at all + + let output = Command::new(witryna_bin()) + .args([ + "switch", + "--config", + config_path.to_str().unwrap(), + "my-site", + "20260126-100000-000001", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(!output.status.success(), "should exit non-zero"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("no builds found"), + "should mention no builds, got: {stderr}" + ); +} diff --git a/tests/integration/cli_validate.rs b/tests/integration/cli_validate.rs new file mode 100644 index 0000000..c8a62c8 --- /dev/null +++ b/tests/integration/cli_validate.rs @@ -0,0 +1,137 @@ +use std::process::Stdio; +use tempfile::TempDir; +use tokio::process::Command; + +fn witryna_bin() -> std::path::PathBuf { + let mut path = std::path::PathBuf::from(env!("CARGO_BIN_EXE_witryna")); + if !path.exists() { + path = std::path::PathBuf::from("target/debug/witryna"); + } + path +} + +async fn write_validate_config(dir: &std::path::Path, content: &str) -> std::path::PathBuf { + let config_path = dir.join("witryna.toml"); + tokio::fs::write(&config_path, content).await.unwrap(); + config_path +} + +// --------------------------------------------------------------------------- +// Tier 1: no container runtime / git needed +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn cli_validate_valid_config() { + let tempdir = TempDir::new().unwrap(); + let base_dir = tempdir.path().join("data"); + tokio::fs::create_dir_all(&base_dir).await.unwrap(); + + let config = format!( + r#"listen_address = "127.0.0.1:8080" +container_runtime = "podman" +base_dir = "{base_dir}" +log_level = "info" + +[[sites]] +name = "site-a" +repo_url = "https://example.com/a.git" +branch = "main" + +[[sites]] +name = "site-b" +repo_url = "https://example.com/b.git" +branch = "main" +"#, + base_dir = base_dir.display(), + ); + let config_path = write_validate_config(tempdir.path(), &config).await; + + let output = Command::new(witryna_bin()) + .args(["validate", "--config", config_path.to_str().unwrap()]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!( + output.status.success(), + "should exit 0, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Configuration valid:"), + "should say 'Configuration valid:', got: {stderr}" + ); + assert!( + stderr.contains("127.0.0.1:8080"), + "should show listen address, got: {stderr}" + ); + assert!( + stderr.contains("podman"), + "should show runtime, got: {stderr}" + ); + assert!( + stderr.contains("Sites: 2"), + "should show site count, got: {stderr}" + ); + assert!( + stderr.contains("site-a"), + "should list site-a, got: {stderr}" + ); + assert!( + stderr.contains("site-b"), + "should list site-b, got: {stderr}" + ); +} + +#[tokio::test] +async fn cli_validate_missing_config_file() { + let output = Command::new(witryna_bin()) + .args(["validate", "--config", "/nonexistent/witryna.toml"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(!output.status.success(), "should exit non-zero"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("config file not found"), + "should mention 'config file not found', got: {stderr}" + ); +} + +#[tokio::test] +async fn cli_validate_invalid_config() { + let tempdir = TempDir::new().unwrap(); + + // listen_address = "" is invalid — config validation rejects it + let config = r#"listen_address = "" +container_runtime = "podman" +base_dir = "/tmp/witryna" +log_level = "info" + +[[sites]] +name = "test" +repo_url = "https://example.com/test.git" +branch = "main" +"#; + let config_path = write_validate_config(tempdir.path(), config).await; + + let output = Command::new(witryna_bin()) + .args(["validate", "--config", config_path.to_str().unwrap()]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!( + !output.status.success(), + "should exit non-zero for invalid config" + ); +} diff --git a/tests/integration/concurrent.rs b/tests/integration/concurrent.rs index e7f2b64..da09ac9 100644 --- a/tests/integration/concurrent.rs +++ b/tests/integration/concurrent.rs @@ -11,6 +11,8 @@ async fn concurrent_build_gets_queued() { .state .build_scheduler .in_progress + .lock() + .unwrap() .insert("my-site".to_owned()); let resp = TestServer::client() @@ -37,11 +39,15 @@ async fn concurrent_build_queue_collapse() { .state .build_scheduler .in_progress + .lock() + .unwrap() .insert("my-site".to_owned()); server .state .build_scheduler .queued + .lock() + .unwrap() .insert("my-site".to_owned()); // Third request should collapse (202, no body) @@ -97,6 +103,8 @@ async fn build_in_progress_checked_after_auth() { .state .build_scheduler .in_progress + .lock() + .unwrap() .insert("my-site".to_owned()); // Request with wrong token should return 401 (auth checked before build status) diff --git a/tests/integration/env_vars.rs b/tests/integration/env_vars.rs index 44f74fa..5638149 100644 --- a/tests/integration/env_vars.rs +++ b/tests/integration/env_vars.rs @@ -159,4 +159,8 @@ async fn env_vars_passed_to_post_deploy_hook() { content.contains("WITRYNA_BUILD_TIMESTAMP="), "WITRYNA_BUILD_TIMESTAMP should be set" ); + assert!( + content.contains("WITRYNA_BUILD_STATUS=success"), + "WITRYNA_BUILD_STATUS should be set to success" + ); } diff --git a/tests/integration/harness.rs b/tests/integration/harness.rs index c015fa8..b985971 100644 --- a/tests/integration/harness.rs +++ b/tests/integration/harness.rs @@ -1,11 +1,8 @@ -use governor::{Quota, RateLimiter}; use std::collections::HashMap; -use std::num::NonZeroU32; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use tempfile::TempDir; -use tokio::net::TcpListener; -use tokio::sync::{RwLock, oneshot}; +use tiny_http::Server; use witryna::build_guard::BuildScheduler; use witryna::config::{BuildOverrides, Config, SiteConfig}; use witryna::polling::PollingManager; @@ -18,18 +15,14 @@ pub struct TestServer { /// Kept alive for RAII cleanup of the config file written during startup. #[allow(dead_code)] pub tempdir: TempDir, - shutdown_tx: Option<oneshot::Sender<()>>, + server: Arc<Server>, + server_thread: Option<std::thread::JoinHandle<()>>, } impl TestServer { /// Start a new test server with the given config. /// Binds to `127.0.0.1:0` (OS-assigned port). - pub async fn start(config: Config) -> Self { - Self::start_with_rate_limit(config, 1000).await - } - - /// Start a new test server with a specific rate limit. - pub async fn start_with_rate_limit(mut config: Config, rate_limit: u32) -> Self { + pub async fn start(mut config: Config) -> Self { let tempdir = TempDir::new().expect("failed to create temp dir"); let config_path = tempdir.path().join("witryna.toml"); @@ -44,38 +37,50 @@ impl TestServer { .await .expect("failed to resolve secrets"); - let quota = Quota::per_minute(NonZeroU32::new(rate_limit).expect("rate limit must be > 0")); - let state = AppState { config: Arc::new(RwLock::new(config)), config_path: Arc::new(config_path), build_scheduler: Arc::new(BuildScheduler::new()), - rate_limiter: Arc::new(RateLimiter::dashmap(quota)), polling_manager: Arc::new(PollingManager::new()), }; - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("failed to bind to random port"); - let port = listener.local_addr().unwrap().port(); + let server = Arc::new(Server::http("127.0.0.1:0").expect("failed to bind")); + let port = match server.server_addr() { + tiny_http::ListenAddr::IP(addr) => addr.port(), + _ => unreachable!("expected IP address"), + }; let base_url = format!("http://127.0.0.1:{port}"); - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - - let server_state = state.clone(); - tokio::spawn(async move { - witryna::test_support::run_server(server_state, listener, async { - let _ = shutdown_rx.await; - }) - .await - .expect("server failed"); - }); + // Shutdown uses server.unblock() directly — no oneshot needed. + // We pass a future that never resolves; shutdown is triggered + // by calling server.unblock() from TestServer::shutdown(). + let server_thread = witryna::test_support::run_server( + state.clone(), + server.clone(), + std::future::pending(), + ); + + // Readiness probe: wait for server to accept connections + let client = reqwest::Client::new(); + for _ in 0..50 { + if client + .get(format!("{base_url}/health")) + .send() + .await + .map(|r| r.status().as_u16() == 200) + .unwrap_or(false) + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } Self { base_url, state, tempdir, - shutdown_tx: Some(shutdown_tx), + server, + server_thread: Some(server_thread), } } @@ -91,8 +96,11 @@ impl TestServer { /// Shut down the server gracefully. pub fn shutdown(&mut self) { - if let Some(tx) = self.shutdown_tx.take() { - let _ = tx.send(()); + // Unblock the HTTP request loop directly — no async channel needed + self.server.unblock(); + // Join the HTTP thread to ensure clean teardown + if let Some(handle) = self.server_thread.take() { + let _ = handle.join(); } } } @@ -112,7 +120,6 @@ pub fn test_config(base_dir: PathBuf) -> Config { base_dir, log_dir, log_level: "debug".to_owned(), - rate_limit_per_minute: 10, max_builds_to_keep: 5, git_timeout: None, sites: vec![], @@ -128,7 +135,6 @@ pub fn test_config_with_site(base_dir: PathBuf, site: SiteConfig) -> Config { base_dir, log_dir, log_level: "debug".to_owned(), - rate_limit_per_minute: 10, max_builds_to_keep: 5, git_timeout: None, sites: vec![site], @@ -144,7 +150,6 @@ pub fn test_config_with_sites(base_dir: PathBuf, sites: Vec<SiteConfig>) -> Conf base_dir, log_dir, log_level: "debug".to_owned(), - rate_limit_per_minute: 10, max_builds_to_keep: 5, git_timeout: None, sites, @@ -286,7 +291,6 @@ fn build_config_toml(config: &Config) -> String { {}base_dir = "{}" log_dir = "{}" log_level = "{}" -rate_limit_per_minute = {} max_builds_to_keep = {} "#, config.listen_address, @@ -294,7 +298,6 @@ max_builds_to_keep = {} config.base_dir.display(), config.log_dir.display(), config.log_level, - config.rate_limit_per_minute, config.max_builds_to_keep, ); diff --git a/tests/integration/hooks.rs b/tests/integration/hooks.rs index 86684cc..d8b4fa3 100644 --- a/tests/integration/hooks.rs +++ b/tests/integration/hooks.rs @@ -1,6 +1,7 @@ use crate::git_helpers::create_local_repo; use crate::harness::{SiteBuilder, TestServer, test_config_with_site}; use crate::runtime::{skip_without_git, skip_without_runtime}; +use std::path::Path; use std::time::Duration; // --------------------------------------------------------------------------- @@ -135,3 +136,150 @@ async fn post_deploy_hook_failure_nonfatal() { } assert!(found_hook_log, "hook log should exist for failed hook"); } + +#[tokio::test] +async fn post_deploy_hook_runs_on_build_failure() { + skip_without_git!(); + skip_without_runtime!(); + + let tempdir = tempfile::tempdir().unwrap(); + let base_dir = tempdir.path().to_path_buf(); + + let repo_dir = tempdir.path().join("repos"); + tokio::fs::create_dir_all(&repo_dir).await.unwrap(); + let repo_url = create_local_repo(&repo_dir, "main").await; + + // Build command fails (exit 1); hook writes WITRYNA_BUILD_STATUS to a file in clone dir + let site = SiteBuilder::new("hook-on-fail", &repo_url, "test-token") + .overrides("alpine:latest", "exit 1", "out") + .post_deploy(vec![ + "sh".to_owned(), + "-c".to_owned(), + "echo \"$WITRYNA_BUILD_STATUS\" > hook-status.txt".to_owned(), + ]) + .build(); + + let server = TestServer::start(test_config_with_site(base_dir.clone(), site)).await; + + let resp = TestServer::client() + .post(server.url("/hook-on-fail")) + .header("Authorization", "Bearer test-token") + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 202); + + // Wait for state.json to show "failed" (no current symlink on build failure) + let state_path = base_dir.join("builds/hook-on-fail/state.json"); + let max_wait = Duration::from_secs(120); + let start = std::time::Instant::now(); + + loop { + assert!(start.elapsed() <= max_wait, "build timed out"); + if state_path.exists() { + let content = tokio::fs::read_to_string(&state_path) + .await + .unwrap_or_default(); + if content.contains("\"failed\"") { + // Give the hook a moment to finish writing + tokio::time::sleep(Duration::from_secs(2)).await; + break; + } + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + + // Verify hook ran and received build_status=failed + let clone_dir = base_dir.join("clones/hook-on-fail"); + let hook_status_path = clone_dir.join("hook-status.txt"); + assert!( + hook_status_path.exists(), + "hook should have created hook-status.txt in clone dir" + ); + let status = tokio::fs::read_to_string(&hook_status_path).await.unwrap(); + assert_eq!( + status.trim(), + "failed", + "hook should receive build_status=failed" + ); + + // Verify state.json says "failed" (not "hook failed") + let state_content = tokio::fs::read_to_string(&state_path).await.unwrap(); + assert!( + state_content.contains("\"failed\""), + "state.json should show failed status" + ); + + // No current symlink should exist (build failed) + assert!( + !Path::new(&base_dir.join("builds/hook-on-fail/current")).is_symlink(), + "current symlink should not exist on build failure" + ); +} + +#[tokio::test] +async fn post_deploy_hook_receives_success_status() { + skip_without_git!(); + skip_without_runtime!(); + + let tempdir = tempfile::tempdir().unwrap(); + let base_dir = tempdir.path().to_path_buf(); + + let repo_dir = tempdir.path().join("repos"); + tokio::fs::create_dir_all(&repo_dir).await.unwrap(); + let repo_url = create_local_repo(&repo_dir, "main").await; + + // Successful build; hook writes WITRYNA_BUILD_STATUS to build dir + let site = SiteBuilder::new("hook-success-status", &repo_url, "test-token") + .overrides( + "alpine:latest", + "mkdir -p out && echo test > out/index.html", + "out", + ) + .post_deploy(vec![ + "sh".to_owned(), + "-c".to_owned(), + "echo \"$WITRYNA_BUILD_STATUS\" > \"$WITRYNA_BUILD_DIR/build-status.txt\"".to_owned(), + ]) + .build(); + + let server = TestServer::start(test_config_with_site(base_dir.clone(), site)).await; + + let resp = TestServer::client() + .post(server.url("/hook-success-status")) + .header("Authorization", "Bearer test-token") + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 202); + + // Wait for current symlink + let builds_dir = base_dir.join("builds/hook-success-status"); + let max_wait = Duration::from_secs(120); + let start = std::time::Instant::now(); + + loop { + assert!(start.elapsed() <= max_wait, "build timed out"); + if builds_dir.join("current").is_symlink() { + tokio::time::sleep(Duration::from_secs(3)).await; + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + + // Read build-status.txt from build dir + let current_target = tokio::fs::read_link(builds_dir.join("current")) + .await + .expect("current symlink should exist"); + let status_path = current_target.join("build-status.txt"); + assert!( + status_path.exists(), + "hook should have created build-status.txt" + ); + let status = tokio::fs::read_to_string(&status_path).await.unwrap(); + assert_eq!( + status.trim(), + "success", + "hook should receive build_status=success" + ); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 7ee422e..be0d316 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -13,8 +13,11 @@ mod runtime; mod auth; mod cache; mod cleanup; +mod cli_cleanup; mod cli_run; mod cli_status; +mod cli_switch; +mod cli_validate; mod concurrent; mod deploy; mod edge_cases; @@ -26,6 +29,5 @@ mod not_found; mod overrides; mod packaging; mod polling; -mod rate_limit; mod secrets; mod sighup; diff --git a/tests/integration/polling.rs b/tests/integration/polling.rs index a4447cc..4bf8a05 100644 --- a/tests/integration/polling.rs +++ b/tests/integration/polling.rs @@ -53,7 +53,6 @@ async fn polling_triggers_build_on_new_commits() { base_dir: base_dir.clone(), log_dir: base_dir.join("logs"), log_level: "debug".to_owned(), - rate_limit_per_minute: 100, max_builds_to_keep: 5, git_timeout: None, sites: vec![site], diff --git a/tests/integration/rate_limit.rs b/tests/integration/rate_limit.rs deleted file mode 100644 index 81378a2..0000000 --- a/tests/integration/rate_limit.rs +++ /dev/null @@ -1,114 +0,0 @@ -use crate::harness::{SiteBuilder, TestServer, test_config_with_site, test_config_with_sites}; - -#[tokio::test] -async fn rate_limit_exceeded_returns_429() { - let dir = tempfile::tempdir().unwrap().keep(); - let site = SiteBuilder::new("my-site", "https://example.com/repo.git", "secret-token").build(); - let config = test_config_with_site(dir, site); - - // Rate limit of 2 per minute - let server = TestServer::start_with_rate_limit(config, 2).await; - - // First request — accepted (or 202) - let resp1 = TestServer::client() - .post(server.url("/my-site")) - .header("Authorization", "Bearer secret-token") - .send() - .await - .unwrap(); - let status1 = resp1.status().as_u16(); - assert!( - status1 == 202 || status1 == 409, - "expected 202 or 409, got {status1}" - ); - - // Second request - let resp2 = TestServer::client() - .post(server.url("/my-site")) - .header("Authorization", "Bearer secret-token") - .send() - .await - .unwrap(); - let status2 = resp2.status().as_u16(); - assert!( - status2 == 202 || status2 == 409, - "expected 202 or 409, got {status2}" - ); - - // Third request should hit rate limit - let resp3 = TestServer::client() - .post(server.url("/my-site")) - .header("Authorization", "Bearer secret-token") - .send() - .await - .unwrap(); - assert_eq!(resp3.status().as_u16(), 429); - let body = resp3.text().await.unwrap(); - let json: serde_json::Value = serde_json::from_str(&body).unwrap(); - assert_eq!(json["error"], "rate_limit_exceeded"); -} - -#[tokio::test] -async fn rate_limit_different_tokens_independent() { - let dir = tempfile::tempdir().unwrap().keep(); - let sites = vec![ - SiteBuilder::new("site-one", "https://example.com/one.git", "token-one").build(), - SiteBuilder::new("site-two", "https://example.com/two.git", "token-two").build(), - ]; - let config = test_config_with_sites(dir, sites); - - // Rate limit of 1 per minute - let server = TestServer::start_with_rate_limit(config, 1).await; - - // token-one: first request succeeds - let resp1 = TestServer::client() - .post(server.url("/site-one")) - .header("Authorization", "Bearer token-one") - .send() - .await - .unwrap(); - assert_eq!(resp1.status().as_u16(), 202); - - // token-one: second request hits rate limit - let resp2 = TestServer::client() - .post(server.url("/site-one")) - .header("Authorization", "Bearer token-one") - .send() - .await - .unwrap(); - assert_eq!(resp2.status().as_u16(), 429); - - // token-two: still has its own budget - let resp3 = TestServer::client() - .post(server.url("/site-two")) - .header("Authorization", "Bearer token-two") - .send() - .await - .unwrap(); - assert_eq!(resp3.status().as_u16(), 202); -} - -#[tokio::test] -async fn rate_limit_checked_after_auth() { - let dir = tempfile::tempdir().unwrap().keep(); - let site = SiteBuilder::new("my-site", "https://example.com/repo.git", "secret-token").build(); - let config = test_config_with_site(dir, site); - let server = TestServer::start_with_rate_limit(config, 1).await; - - // Exhaust rate limit - let _ = TestServer::client() - .post(server.url("/my-site")) - .header("Authorization", "Bearer secret-token") - .send() - .await - .unwrap(); - - // Wrong token should get 401, not 429 - let resp = TestServer::client() - .post(server.url("/my-site")) - .header("Authorization", "Bearer wrong-token") - .send() - .await - .unwrap(); - assert_eq!(resp.status().as_u16(), 401); -} diff --git a/tests/integration/sighup.rs b/tests/integration/sighup.rs index 23c0dfd..0474f1d 100644 --- a/tests/integration/sighup.rs +++ b/tests/integration/sighup.rs @@ -116,8 +116,20 @@ repo_url = "https://example.com/new.git" branch = "main" webhook_token = "new-token" "#, - server.state.config.read().await.base_dir.display(), - server.state.config.read().await.log_dir.display(), + server + .state + .config + .read() + .expect("config lock poisoned") + .base_dir + .display(), + server + .state + .config + .read() + .expect("config lock poisoned") + .log_dir + .display(), ); tokio::fs::write(config_path, &new_toml).await.unwrap(); @@ -134,16 +146,20 @@ webhook_token = "new-token" assert_eq!(resp.status().as_u16(), 200); // Verify the reloadable field (sites) was updated - let config = server.state.config.read().await; - assert_eq!(config.sites.len(), 2, "sites should have been reloaded"); - assert!( - config.find_site("new-site").is_some(), - "new-site should exist after reload" - ); + let (sites_len, has_new_site, listen_addr) = { + let config = server.state.config.read().expect("config lock poisoned"); + ( + config.sites.len(), + config.find_site("new-site").is_some(), + config.listen_address.clone(), + ) + }; + assert_eq!(sites_len, 2, "sites should have been reloaded"); + assert!(has_new_site, "new-site should exist after reload"); // Verify non-reloadable field was preserved (not overwritten with "127.0.0.1:19999") assert_ne!( - config.listen_address, "127.0.0.1:19999", + listen_addr, "127.0.0.1:19999", "listen_address should be preserved from original config" ); } |
