This commit is contained in:
Tristan Michael 2026-03-17 06:31:19 -07:00
parent c85e192eb8
commit b602f2cbd1
3 changed files with 601 additions and 0 deletions

View file

@ -83,3 +83,122 @@ impl AppConfig {
config_dir.config_dir().join("config.json")
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_get_current_workspace_none_set() {
let config = AppConfig::new();
let result = config.get_current_workspace();
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::WorkspaceNotFound(_)));
}
#[test]
fn test_get_current_workspace_name_points_to_removed_workspace() {
let mut config = AppConfig::new();
config.add_workspace("test".to_string(), WorkspaceConfig::new(PathBuf::from("/tmp")));
config.current_workspace = Some("test".to_string());
config.workspaces.remove("test");
let result = config.get_current_workspace();
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::WorkspaceNotFound(_)));
}
#[test]
fn test_set_current_workspace_nonexistent() {
let mut config = AppConfig::new();
let result = config.set_current_workspace("ghost".to_string());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::WorkspaceNotFound(_)));
}
#[test]
fn test_set_current_workspace_valid() {
let mut config = AppConfig::new();
config.add_workspace("real".to_string(), WorkspaceConfig::new(PathBuf::from("/tmp")));
assert!(config.set_current_workspace("real".to_string()).is_ok());
assert_eq!(config.current_workspace.as_deref(), Some("real"));
}
#[test]
fn test_remove_current_workspace_clears_current() {
let mut config = AppConfig::new();
config.add_workspace("ws".to_string(), WorkspaceConfig::new(PathBuf::from("/tmp")));
config.set_current_workspace("ws".to_string()).unwrap();
config.remove_workspace("ws");
assert!(config.current_workspace.is_none());
assert!(config.get_workspace("ws").is_none());
}
#[test]
fn test_remove_noncurrent_workspace_keeps_current() {
let mut config = AppConfig::new();
config.add_workspace("a".to_string(), WorkspaceConfig::new(PathBuf::from("/a")));
config.add_workspace("b".to_string(), WorkspaceConfig::new(PathBuf::from("/b")));
config.set_current_workspace("a".to_string()).unwrap();
config.remove_workspace("b");
assert_eq!(config.current_workspace.as_deref(), Some("a"));
}
#[test]
fn test_save_and_load_roundtrip() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.json");
let mut config = AppConfig::new();
config.add_workspace("ws1".to_string(), WorkspaceConfig::new(PathBuf::from("/path/one")));
config.add_workspace("ws2".to_string(), WorkspaceConfig::new(PathBuf::from("/path/two")));
config.set_current_workspace("ws1".to_string()).unwrap();
config.save_to_file(&config_path).unwrap();
let loaded = AppConfig::load_from_file(&config_path).unwrap();
assert_eq!(loaded.current_workspace.as_deref(), Some("ws1"));
assert_eq!(loaded.workspaces.len(), 2);
assert_eq!(loaded.get_workspace("ws1").unwrap().path, PathBuf::from("/path/one"));
assert_eq!(loaded.get_workspace("ws2").unwrap().path, PathBuf::from("/path/two"));
}
#[test]
fn test_load_missing_file_returns_default() {
let config = AppConfig::load_from_file(&PathBuf::from("/nonexistent/config.json")).unwrap();
assert!(config.workspaces.is_empty());
assert!(config.current_workspace.is_none());
}
#[test]
fn test_load_corrupt_file() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.json");
std::fs::write(&config_path, "not valid json {{{").unwrap();
let result = AppConfig::load_from_file(&config_path);
assert!(result.is_err());
}
#[test]
fn test_save_creates_parent_dirs() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("nested").join("dir").join("config.json");
let config = AppConfig::new();
assert!(config.save_to_file(&config_path).is_ok());
assert!(config_path.exists());
}
#[test]
fn test_add_workspace_overwrites_existing() {
let mut config = AppConfig::new();
config.add_workspace("ws".to_string(), WorkspaceConfig::new(PathBuf::from("/old")));
config.add_workspace("ws".to_string(), WorkspaceConfig::new(PathBuf::from("/new")));
assert_eq!(config.get_workspace("ws").unwrap().path, PathBuf::from("/new"));
assert_eq!(config.workspaces.len(), 1);
}
}

View file

@ -205,4 +205,191 @@ mod tests {
repo.set_group_by_due_date(list.id, false).unwrap();
assert!(!repo.get_group_by_due_date(list.id).unwrap());
}
// --- Error path tests ---
#[test]
fn test_get_task_not_found() {
let temp_dir = TempDir::new().unwrap();
let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
let list = repo.create_list("Test".to_string()).unwrap();
let result = repo.get_task(list.id, Uuid::new_v4());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::TaskNotFound(_)));
}
#[test]
fn test_update_nonexistent_task() {
let temp_dir = TempDir::new().unwrap();
let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
let list = repo.create_list("Test".to_string()).unwrap();
let task = Task::new("Ghost".to_string());
let result = repo.update_task(list.id, task);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::TaskNotFound(_)));
}
#[test]
fn test_delete_nonexistent_task() {
let temp_dir = TempDir::new().unwrap();
let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
let list = repo.create_list("Test".to_string()).unwrap();
let result = repo.delete_task(list.id, Uuid::new_v4());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::TaskNotFound(_)));
}
#[test]
fn test_get_list_not_found() {
let temp_dir = TempDir::new().unwrap();
let repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
let result = repo.get_list(Uuid::new_v4());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::ListNotFound(_)));
}
#[test]
fn test_delete_nonexistent_list() {
let temp_dir = TempDir::new().unwrap();
let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
let result = repo.delete_list(Uuid::new_v4());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::ListNotFound(_)));
}
#[test]
fn test_list_tasks_nonexistent_list() {
let temp_dir = TempDir::new().unwrap();
let repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
let result = repo.list_tasks(Uuid::new_v4());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::ListNotFound(_)));
}
#[test]
fn test_reorder_task_not_in_list() {
let temp_dir = TempDir::new().unwrap();
let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
let list = repo.create_list("Test".to_string()).unwrap();
repo.create_task(list.id, Task::new("A".to_string())).unwrap();
let result = repo.reorder_task(list.id, Uuid::new_v4(), 0);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::TaskNotFound(_)));
}
#[test]
fn test_reorder_task_position_clamped() {
let temp_dir = TempDir::new().unwrap();
let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
let list = repo.create_list("Test".to_string()).unwrap();
let t1 = repo.create_task(list.id, Task::new("A".to_string())).unwrap();
let t2 = repo.create_task(list.id, Task::new("B".to_string())).unwrap();
// Position 999 should clamp to end
repo.reorder_task(list.id, t1.id, 999).unwrap();
let order = repo.get_task_order(list.id).unwrap();
assert_eq!(order[0], t2.id);
assert_eq!(order[1], t1.id);
}
#[test]
fn test_create_duplicate_list() {
let temp_dir = TempDir::new().unwrap();
let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
repo.create_list("Dupes".to_string()).unwrap();
let result = repo.create_list("Dupes".to_string());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::InvalidData(_)));
}
#[test]
fn test_get_lists_empty() {
let temp_dir = TempDir::new().unwrap();
let repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
let lists = repo.get_lists().unwrap();
assert!(lists.is_empty());
}
#[test]
fn test_delete_list_removes_from_root_metadata() {
let temp_dir = TempDir::new().unwrap();
let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
let list1 = repo.create_list("A".to_string()).unwrap();
let list2 = repo.create_list("B".to_string()).unwrap();
repo.delete_list(list1.id).unwrap();
let lists = repo.get_lists().unwrap();
assert_eq!(lists.len(), 1);
assert_eq!(lists[0].id, list2.id);
}
#[test]
fn test_new_on_nonexistent_path() {
let result = TaskRepository::new(PathBuf::from("/nonexistent/path/that/does/not/exist"));
assert!(result.is_err());
}
#[test]
fn test_task_with_description_roundtrip() {
let temp_dir = TempDir::new().unwrap();
let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
let list = repo.create_list("Test".to_string()).unwrap();
let task = Task::new("Has Description".to_string())
.with_description("Some **markdown** notes".to_string());
let created = repo.create_task(list.id, task).unwrap();
let retrieved = repo.get_task(list.id, created.id).unwrap();
assert_eq!(retrieved.description, "Some **markdown** notes");
}
#[test]
fn test_task_rename_removes_old_file() {
let temp_dir = TempDir::new().unwrap();
let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
let list = repo.create_list("Test".to_string()).unwrap();
let mut task = repo.create_task(list.id, Task::new("Old Name".to_string())).unwrap();
task.title = "New Name".to_string();
repo.update_task(list.id, task.clone()).unwrap();
// Old file should be gone, new file should exist
let tasks = repo.list_tasks(list.id).unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].title, "New Name");
// Verify old .md file no longer on disk
let old_path = temp_dir.path().join("Test").join("Old Name.md");
assert!(!old_path.exists());
}
#[test]
fn test_task_order_after_delete() {
let temp_dir = TempDir::new().unwrap();
let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
let list = repo.create_list("Test".to_string()).unwrap();
let t1 = repo.create_task(list.id, Task::new("A".to_string())).unwrap();
let t2 = repo.create_task(list.id, Task::new("B".to_string())).unwrap();
let t3 = repo.create_task(list.id, Task::new("C".to_string())).unwrap();
repo.delete_task(list.id, t2.id).unwrap();
let order = repo.get_task_order(list.id).unwrap();
assert_eq!(order.len(), 2);
assert_eq!(order[0], t1.id);
assert_eq!(order[1], t3.id);
}
}

View file

@ -92,6 +92,7 @@ pub trait Storage {
fn write_list_metadata(&mut self, metadata: &ListMetadata) -> Result<()>;
}
#[derive(Debug)]
pub struct FileSystemStorage {
root_path: PathBuf,
}
@ -475,3 +476,297 @@ impl Storage for FileSystemStorage {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::Task;
use tempfile::TempDir;
fn init_storage(temp_dir: &TempDir) -> FileSystemStorage {
FileSystemStorage::init(temp_dir.path().to_path_buf()).unwrap()
}
// --- Frontmatter parsing ---
#[test]
fn test_parse_valid_frontmatter() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\n---\n\nSome description";
let (fm, desc) = storage.parse_markdown_with_frontmatter(content).unwrap();
assert_eq!(fm.id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
assert_eq!(fm.status, TaskStatus::Backlog);
assert_eq!(desc, "Some description");
}
#[test]
fn test_parse_frontmatter_no_body() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: completed\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\n---";
let (fm, desc) = storage.parse_markdown_with_frontmatter(content).unwrap();
assert_eq!(fm.status, TaskStatus::Completed);
assert!(desc.is_empty());
}
#[test]
fn test_parse_frontmatter_missing_opening_delimiter() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let result = storage.parse_markdown_with_frontmatter("no frontmatter here");
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::InvalidData(_)));
}
#[test]
fn test_parse_frontmatter_missing_closing_delimiter() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\n";
let result = storage.parse_markdown_with_frontmatter(content);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::InvalidData(_)));
}
#[test]
fn test_parse_frontmatter_empty_content() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let result = storage.parse_markdown_with_frontmatter("");
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::InvalidData(_)));
}
#[test]
fn test_parse_frontmatter_invalid_yaml() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let content = "---\n: : : not valid yaml\n---\n";
let result = storage.parse_markdown_with_frontmatter(content);
assert!(result.is_err());
}
#[test]
fn test_parse_frontmatter_with_optional_fields() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\ndue: 2026-06-15T12:00:00Z\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\nparent: 660e8400-e29b-41d4-a716-446655440001\n---\n\nNotes";
let (fm, _) = storage.parse_markdown_with_frontmatter(content).unwrap();
assert!(fm.due.is_some());
assert!(fm.parent.is_some());
}
// --- Markdown write/read roundtrip ---
#[test]
fn test_markdown_roundtrip() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let task = Task::new("Test".to_string())
.with_description("Line 1\n\nLine 3".to_string());
let markdown = storage.write_markdown_with_frontmatter(&task).unwrap();
let (fm, desc) = storage.parse_markdown_with_frontmatter(&markdown).unwrap();
assert_eq!(fm.id, task.id);
assert_eq!(fm.status, task.status);
assert_eq!(desc, "Line 1\n\nLine 3");
}
// --- FileSystemStorage init/new ---
#[test]
fn test_new_nonexistent_path() {
let result = FileSystemStorage::new(PathBuf::from("/does/not/exist"));
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::NotFound(_)));
}
#[test]
fn test_init_creates_metadata() {
let temp_dir = TempDir::new().unwrap();
let _storage = init_storage(&temp_dir);
assert!(temp_dir.path().join(".metadata.json").exists());
}
#[test]
fn test_init_idempotent() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().to_path_buf();
let mut s = FileSystemStorage::init(path.clone()).unwrap();
let list = s.create_list("Keep Me".to_string()).unwrap();
// Re-init should not destroy existing data
let s2 = FileSystemStorage::init(path).unwrap();
let lists = s2.get_lists().unwrap();
assert_eq!(lists.len(), 1);
assert_eq!(lists[0].id, list.id);
}
// --- Root metadata ---
#[test]
fn test_root_metadata_defaults_when_missing() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
// Delete the metadata file to simulate missing
fs::remove_file(temp_dir.path().join(".metadata.json")).unwrap();
let meta = storage.read_root_metadata().unwrap();
assert_eq!(meta.version, 1);
assert!(meta.list_order.is_empty());
}
// --- List operations ---
#[test]
fn test_create_list_already_exists() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
storage.create_list("Dupes".to_string()).unwrap();
let result = storage.create_list("Dupes".to_string());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::InvalidData(_)));
}
#[test]
fn test_delete_list_cleans_up_root_metadata() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("To Delete".to_string()).unwrap();
let meta_before = storage.read_root_metadata().unwrap();
assert!(meta_before.list_order.contains(&list.id));
storage.delete_list(list.id).unwrap();
let meta_after = storage.read_root_metadata().unwrap();
assert!(!meta_after.list_order.contains(&list.id));
}
#[test]
fn test_list_dir_path_nonexistent_list() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let result = storage.list_dir_path(Uuid::new_v4());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::ListNotFound(_)));
}
// --- Task file operations ---
#[test]
fn test_write_and_read_task() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("Tasks".to_string()).unwrap();
let task = Task::new("Hello".to_string());
storage.write_task(list.id, &task).unwrap();
let read_back = storage.read_task(list.id, task.id).unwrap();
assert_eq!(read_back.title, "Hello");
assert_eq!(read_back.id, task.id);
}
#[test]
fn test_read_task_nonexistent() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("Tasks".to_string()).unwrap();
let result = storage.read_task(list.id, Uuid::new_v4());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::TaskNotFound(_)));
}
#[test]
fn test_write_task_adds_to_task_order() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("Tasks".to_string()).unwrap();
let t1 = Task::new("First".to_string());
let t2 = Task::new("Second".to_string());
storage.write_task(list.id, &t1).unwrap();
storage.write_task(list.id, &t2).unwrap();
let meta = storage.read_list_metadata(list.id).unwrap();
assert_eq!(meta.task_order.len(), 2);
assert_eq!(meta.task_order[0], t1.id);
assert_eq!(meta.task_order[1], t2.id);
}
#[test]
fn test_write_task_idempotent_order() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("Tasks".to_string()).unwrap();
let task = Task::new("Once".to_string());
storage.write_task(list.id, &task).unwrap();
storage.write_task(list.id, &task).unwrap(); // Write again
let meta = storage.read_list_metadata(list.id).unwrap();
assert_eq!(meta.task_order.len(), 1); // Should not duplicate
}
#[test]
fn test_delete_task_removes_from_order() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("Tasks".to_string()).unwrap();
let task = Task::new("Bye".to_string());
storage.write_task(list.id, &task).unwrap();
storage.delete_task(list.id, task.id).unwrap();
let meta = storage.read_list_metadata(list.id).unwrap();
assert!(!meta.task_order.contains(&task.id));
}
#[test]
fn test_list_tasks_respects_order() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("Tasks".to_string()).unwrap();
let t1 = Task::new("Alpha".to_string());
let t2 = Task::new("Beta".to_string());
let t3 = Task::new("Gamma".to_string());
storage.write_task(list.id, &t1).unwrap();
storage.write_task(list.id, &t2).unwrap();
storage.write_task(list.id, &t3).unwrap();
// Rewrite metadata with reversed order
let mut meta = storage.read_list_metadata(list.id).unwrap();
meta.task_order = vec![t3.id, t1.id, t2.id];
storage.write_list_metadata(&meta).unwrap();
let tasks = storage.list_tasks(list.id).unwrap();
assert_eq!(tasks[0].id, t3.id);
assert_eq!(tasks[1].id, t1.id);
assert_eq!(tasks[2].id, t2.id);
}
#[test]
fn test_list_tasks_empty_list() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("Empty".to_string()).unwrap();
let tasks = storage.list_tasks(list.id).unwrap();
assert!(tasks.is_empty());
}
}