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}" ); }