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:
@@ -696,8 +696,208 @@ pub async fn create_directory_absolute(path: String) -> Result<bool, String> {
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user