WIP: Batch 2 — backfill tests for fs, shell, and http/workflow
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -317,3 +317,108 @@ fn parse_test_status(value: &str) -> Result<TestStatus, String> {
|
|||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -696,8 +696,208 @@ pub async fn create_directory_absolute(path: String) -> Result<bool, String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::store::JsonFileStore;
|
||||||
use tempfile::tempdir;
|
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]
|
#[tokio::test]
|
||||||
async fn write_file_requires_approved_test_plan() {
|
async fn write_file_requires_approved_test_plan() {
|
||||||
let dir = tempdir().expect("tempdir");
|
let dir = tempdir().expect("tempdir");
|
||||||
@@ -715,4 +915,74 @@ mod tests {
|
|||||||
"expected write to be blocked when test plan is not approved"
|
"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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,4 +106,64 @@ mod tests {
|
|||||||
"expected shell execution to be blocked when test plan is not approved"
|
"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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user