From de6334720a9a8a9009f1e7ba06e7fbe6f7112464 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Feb 2026 13:52:19 +0000 Subject: [PATCH] =?UTF-8?q?WIP:=20Batch=202=20=E2=80=94=20backfill=20tests?= =?UTF-8?q?=20for=20fs,=20shell,=20and=20http/workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - io/fs.rs: 20 tests (path resolution, project open/close/get, known projects, model prefs, file read/write, list dir, validate path, scaffold) - io/shell.rs: 4 new tests (allowlist, command execution, stdout capture, exit codes) - http/workflow.rs: 8 tests (parse_test_status, to_test_case, to_review_story) Coverage: 28.6% → 48.1% Co-Authored-By: Claude Opus 4.6 --- server/src/http/workflow.rs | 105 ++++++++++++++ server/src/io/fs.rs | 270 ++++++++++++++++++++++++++++++++++++ server/src/io/shell.rs | 60 ++++++++ 3 files changed, 435 insertions(+) diff --git a/server/src/http/workflow.rs b/server/src/http/workflow.rs index b3b9e92..e1fdf76 100644 --- a/server/src/http/workflow.rs +++ b/server/src/http/workflow.rs @@ -317,3 +317,108 @@ fn parse_test_status(value: &str) -> Result { )), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus}; + + #[test] + fn parse_test_status_pass() { + assert_eq!(parse_test_status("pass").unwrap(), TestStatus::Pass); + } + + #[test] + fn parse_test_status_fail() { + assert_eq!(parse_test_status("fail").unwrap(), TestStatus::Fail); + } + + #[test] + fn parse_test_status_invalid() { + let result = parse_test_status("unknown"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid test status")); + } + + #[test] + fn to_test_case_converts_pass() { + let payload = TestCasePayload { + name: "my_test".to_string(), + status: "pass".to_string(), + details: Some("all good".to_string()), + }; + let result = to_test_case(payload).unwrap(); + assert_eq!(result.name, "my_test"); + assert_eq!(result.status, TestStatus::Pass); + assert_eq!(result.details, Some("all good".to_string())); + } + + #[test] + fn to_test_case_rejects_invalid_status() { + let payload = TestCasePayload { + name: "bad".to_string(), + status: "maybe".to_string(), + details: None, + }; + assert!(to_test_case(payload).is_err()); + } + + #[test] + fn to_review_story_all_passing() { + let results = StoryTestResults { + unit: vec![TestCaseResult { + name: "u1".to_string(), + status: TestStatus::Pass, + details: None, + }], + integration: vec![TestCaseResult { + name: "i1".to_string(), + status: TestStatus::Pass, + details: None, + }], + }; + + let review = to_review_story("story-29", &results); + assert!(review.can_accept); + assert!(review.reasons.is_empty()); + assert!(review.missing_categories.is_empty()); + assert_eq!(review.summary.total, 2); + assert_eq!(review.summary.passed, 2); + } + + #[test] + fn to_review_story_missing_integration() { + let results = StoryTestResults { + unit: vec![TestCaseResult { + name: "u1".to_string(), + status: TestStatus::Pass, + details: None, + }], + integration: vec![], + }; + + let review = to_review_story("story-29", &results); + assert!(!review.can_accept); + assert!(review.missing_categories.contains(&"integration".to_string())); + } + + #[test] + fn to_review_story_with_failures() { + let results = StoryTestResults { + unit: vec![TestCaseResult { + name: "u1".to_string(), + status: TestStatus::Fail, + details: None, + }], + integration: vec![TestCaseResult { + name: "i1".to_string(), + status: TestStatus::Pass, + details: None, + }], + }; + + let review = to_review_story("story-29", &results); + assert!(!review.can_accept); + assert_eq!(review.summary.failed, 1); + } +} diff --git a/server/src/io/fs.rs b/server/src/io/fs.rs index a776df9..ca630e9 100644 --- a/server/src/io/fs.rs +++ b/server/src/io/fs.rs @@ -696,8 +696,208 @@ pub async fn create_directory_absolute(path: String) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::store::JsonFileStore; use tempfile::tempdir; + fn make_store(dir: &tempfile::TempDir) -> JsonFileStore { + JsonFileStore::new(dir.path().join("test_store.json")).unwrap() + } + + fn make_state_with_root(path: PathBuf) -> SessionState { + let state = SessionState::default(); + { + let mut root = state.project_root.lock().unwrap(); + *root = Some(path); + } + state + } + + // --- resolve_path_impl --- + + #[test] + fn resolve_path_joins_relative_to_root() { + let root = PathBuf::from("/projects/myapp"); + let result = resolve_path_impl(root, "src/main.rs").unwrap(); + assert_eq!(result, PathBuf::from("/projects/myapp/src/main.rs")); + } + + #[test] + fn resolve_path_rejects_traversal() { + let root = PathBuf::from("/projects/myapp"); + let result = resolve_path_impl(root, "../etc/passwd"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("traversal")); + } + + // --- is_story_kit_path --- + + #[test] + fn is_story_kit_path_matches_root_and_children() { + assert!(is_story_kit_path(".story_kit")); + assert!(is_story_kit_path(".story_kit/stories/current/26.md")); + assert!(!is_story_kit_path("src/main.rs")); + assert!(!is_story_kit_path(".story_kit_other")); + } + + // --- open/close/get project --- + + #[tokio::test] + async fn open_project_sets_root_and_persists() { + let dir = tempdir().unwrap(); + let project_dir = dir.path().join("myproject"); + fs::create_dir_all(&project_dir).unwrap(); + let store = make_store(&dir); + let state = SessionState::default(); + + let result = open_project( + project_dir.to_string_lossy().to_string(), + &state, + &store, + ) + .await; + + assert!(result.is_ok()); + let root = state.get_project_root().unwrap(); + assert_eq!(root, project_dir); + } + + #[tokio::test] + async fn close_project_clears_root() { + let dir = tempdir().unwrap(); + let project_dir = dir.path().join("myproject"); + fs::create_dir_all(&project_dir).unwrap(); + let store = make_store(&dir); + let state = make_state_with_root(project_dir); + + close_project(&state, &store).unwrap(); + + let root = state.project_root.lock().unwrap(); + assert!(root.is_none()); + } + + #[tokio::test] + async fn get_current_project_returns_none_when_no_project() { + let dir = tempdir().unwrap(); + let store = make_store(&dir); + let state = SessionState::default(); + + let result = get_current_project(&state, &store).unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn get_current_project_returns_active_root() { + let dir = tempdir().unwrap(); + let store = make_store(&dir); + let state = make_state_with_root(dir.path().to_path_buf()); + + let result = get_current_project(&state, &store).unwrap(); + assert!(result.is_some()); + } + + // --- known projects --- + + #[test] + fn known_projects_empty_by_default() { + let dir = tempdir().unwrap(); + let store = make_store(&dir); + let projects = get_known_projects(&store).unwrap(); + assert!(projects.is_empty()); + } + + #[tokio::test] + async fn open_project_adds_to_known_projects() { + let dir = tempdir().unwrap(); + let project_dir = dir.path().join("proj1"); + fs::create_dir_all(&project_dir).unwrap(); + let store = make_store(&dir); + let state = SessionState::default(); + + open_project( + project_dir.to_string_lossy().to_string(), + &state, + &store, + ) + .await + .unwrap(); + + let projects = get_known_projects(&store).unwrap(); + assert_eq!(projects.len(), 1); + } + + #[test] + fn forget_known_project_removes_it() { + let dir = tempdir().unwrap(); + let store = make_store(&dir); + + store.set(KEY_KNOWN_PROJECTS, json!(["/a", "/b", "/c"])); + forget_known_project("/b".to_string(), &store).unwrap(); + + let projects = get_known_projects(&store).unwrap(); + assert_eq!(projects, vec!["/a", "/c"]); + } + + #[test] + fn forget_unknown_project_is_noop() { + let dir = tempdir().unwrap(); + let store = make_store(&dir); + + store.set(KEY_KNOWN_PROJECTS, json!(["/a"])); + forget_known_project("/nonexistent".to_string(), &store).unwrap(); + + let projects = get_known_projects(&store).unwrap(); + assert_eq!(projects, vec!["/a"]); + } + + // --- model preference --- + + #[test] + fn model_preference_none_by_default() { + let dir = tempdir().unwrap(); + let store = make_store(&dir); + assert!(get_model_preference(&store).unwrap().is_none()); + } + + #[test] + fn set_and_get_model_preference() { + let dir = tempdir().unwrap(); + let store = make_store(&dir); + set_model_preference("claude-3-sonnet".to_string(), &store).unwrap(); + assert_eq!( + get_model_preference(&store).unwrap(), + Some("claude-3-sonnet".to_string()) + ); + } + + // --- file operations --- + + #[tokio::test] + async fn read_file_impl_reads_content() { + let dir = tempdir().unwrap(); + let file = dir.path().join("test.txt"); + fs::write(&file, "hello world").unwrap(); + + let content = read_file_impl(file).await.unwrap(); + assert_eq!(content, "hello world"); + } + + #[tokio::test] + async fn read_file_impl_errors_on_missing() { + let dir = tempdir().unwrap(); + let result = read_file_impl(dir.path().join("missing.txt")).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn write_file_impl_creates_and_writes() { + let dir = tempdir().unwrap(); + let file = dir.path().join("sub").join("output.txt"); + + write_file_impl(file.clone(), "content".to_string()).await.unwrap(); + + assert_eq!(fs::read_to_string(&file).unwrap(), "content"); + } + #[tokio::test] async fn write_file_requires_approved_test_plan() { let dir = tempdir().expect("tempdir"); @@ -715,4 +915,74 @@ mod tests { "expected write to be blocked when test plan is not approved" ); } + + // --- list directory --- + + #[tokio::test] + async fn list_directory_impl_returns_sorted_entries() { + let dir = tempdir().unwrap(); + fs::create_dir(dir.path().join("zdir")).unwrap(); + fs::create_dir(dir.path().join("adir")).unwrap(); + fs::write(dir.path().join("file.txt"), "").unwrap(); + + let entries = list_directory_impl(dir.path().to_path_buf()).await.unwrap(); + + assert_eq!(entries[0].name, "adir"); + assert_eq!(entries[0].kind, "dir"); + assert_eq!(entries[1].name, "zdir"); + assert_eq!(entries[1].kind, "dir"); + assert_eq!(entries[2].name, "file.txt"); + assert_eq!(entries[2].kind, "file"); + } + + // --- validate_project_path --- + + #[tokio::test] + async fn validate_project_path_rejects_missing() { + let result = validate_project_path(PathBuf::from("/nonexistent/path")).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn validate_project_path_rejects_file() { + let dir = tempdir().unwrap(); + let file = dir.path().join("not_a_dir.txt"); + fs::write(&file, "").unwrap(); + + let result = validate_project_path(file).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn validate_project_path_accepts_directory() { + let dir = tempdir().unwrap(); + let result = validate_project_path(dir.path().to_path_buf()).await; + assert!(result.is_ok()); + } + + // --- scaffold --- + + #[test] + fn scaffold_story_kit_creates_structure() { + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path()).unwrap(); + + assert!(dir.path().join(".story_kit/README.md").exists()); + assert!(dir.path().join(".story_kit/specs/README.md").exists()); + assert!(dir.path().join(".story_kit/specs/00_CONTEXT.md").exists()); + assert!(dir.path().join(".story_kit/specs/tech/STACK.md").exists()); + assert!(dir.path().join(".story_kit/stories/archive").is_dir()); + } + + #[test] + fn scaffold_story_kit_does_not_overwrite_existing() { + let dir = tempdir().unwrap(); + let readme = dir.path().join(".story_kit/README.md"); + fs::create_dir_all(readme.parent().unwrap()).unwrap(); + fs::write(&readme, "custom content").unwrap(); + + scaffold_story_kit(dir.path()).unwrap(); + + assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content"); + } } diff --git a/server/src/io/shell.rs b/server/src/io/shell.rs index 82c8249..f45bc73 100644 --- a/server/src/io/shell.rs +++ b/server/src/io/shell.rs @@ -106,4 +106,64 @@ mod tests { "expected shell execution to be blocked when test plan is not approved" ); } + + #[tokio::test] + async fn exec_shell_impl_rejects_disallowed_command() { + let dir = tempdir().unwrap(); + let result = exec_shell_impl( + "curl".to_string(), + vec!["https://example.com".to_string()], + dir.path().to_path_buf(), + ) + .await; + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not in the allowlist")); + } + + #[tokio::test] + async fn exec_shell_impl_runs_allowed_command() { + let dir = tempdir().unwrap(); + let result = exec_shell_impl( + "ls".to_string(), + Vec::new(), + dir.path().to_path_buf(), + ) + .await; + + assert!(result.is_ok()); + let output = result.unwrap(); + assert_eq!(output.exit_code, 0); + } + + #[tokio::test] + async fn exec_shell_impl_captures_stdout() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("hello.txt"), "").unwrap(); + + let result = exec_shell_impl( + "ls".to_string(), + Vec::new(), + dir.path().to_path_buf(), + ) + .await + .unwrap(); + + assert!(result.stdout.contains("hello.txt")); + } + + #[tokio::test] + async fn exec_shell_impl_returns_nonzero_exit_code() { + let dir = tempdir().unwrap(); + let result = exec_shell_impl( + "ls".to_string(), + vec!["nonexistent_file_xyz".to_string()], + dir.path().to_path_buf(), + ) + .await + .unwrap(); + + assert_ne!(result.exit_code, 0); + assert!(!result.stderr.is_empty()); + } }