//! L2 tests for the project life-cycle use cases (`CreateProject`, //! `OpenProject`, `ListProjects`, `CloseProject`/`CloseTab`). //! //! Every port is faked in-memory so the use cases are exercised without any I/O: //! - [`FakeFs`] — a `Mutex>>` filesystem that records //! directories and file contents, //! - [`FakeStore`] — an in-memory `ProjectStore` (registry + workspace), //! - [`SpyBus`] — records published [`DomainEvent`]s, //! - [`SeqIds`] / [`FixedClock`] — deterministic id/time. use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::events::DomainEvent; use domain::layout::Workspace; use domain::ports::{ Clock, DirEntry, EventBus, EventStream, FileSystem, FsError, IdGenerator, ProjectStore, RemotePath, StoreError, }; use domain::{Project, ProjectId, ProjectPath, RemoteRef}; use application::{ CloseProject, CloseProjectInput, CloseTab, CloseTabInput, CreateProject, CreateProjectInput, ListProjects, OpenProject, OpenProjectInput, }; // --------------------------------------------------------------------------- // Fakes // --------------------------------------------------------------------------- #[derive(Default)] struct FakeFsInner { files: HashMap>, dirs: HashSet, } /// An in-memory [`FileSystem`] recording writes and created directories. #[derive(Default, Clone)] struct FakeFs(Arc>); impl FakeFs { fn read_file(&self, path: &str) -> Option> { self.0.lock().unwrap().files.get(path).cloned() } fn has_dir(&self, path: &str) -> bool { self.0.lock().unwrap().dirs.contains(path) } } #[async_trait] impl FileSystem for FakeFs { async fn read(&self, path: &RemotePath) -> Result, FsError> { self.0 .lock() .unwrap() .files .get(path.as_str()) .cloned() .ok_or_else(|| FsError::NotFound(path.as_str().to_owned())) } async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { self.0 .lock() .unwrap() .files .insert(path.as_str().to_owned(), data.to_vec()); Ok(()) } async fn exists(&self, path: &RemotePath) -> Result { let inner = self.0.lock().unwrap(); Ok(inner.files.contains_key(path.as_str()) || inner.dirs.contains(path.as_str())) } async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> { self.0 .lock() .unwrap() .dirs .insert(path.as_str().to_owned()); Ok(()) } async fn list(&self, _path: &RemotePath) -> Result, FsError> { Ok(Vec::new()) } async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { Ok(()) } } #[derive(Default)] struct FakeStoreInner { projects: Vec, workspace: Option, } /// An in-memory [`ProjectStore`]. #[derive(Default, Clone)] struct FakeStore(Arc>); impl FakeStore { fn saved_workspace(&self) -> Option { self.0.lock().unwrap().workspace.clone() } } #[async_trait] impl ProjectStore for FakeStore { async fn list_projects(&self) -> Result, StoreError> { Ok(self.0.lock().unwrap().projects.clone()) } async fn load_project(&self, id: ProjectId) -> Result { self.0 .lock() .unwrap() .projects .iter() .find(|p| p.id == id) .cloned() .ok_or(StoreError::NotFound) } async fn save_project(&self, project: &Project) -> Result<(), StoreError> { let mut inner = self.0.lock().unwrap(); if let Some(slot) = inner.projects.iter_mut().find(|p| p.id == project.id) { *slot = project.clone(); } else { inner.projects.push(project.clone()); } Ok(()) } async fn save_workspace(&self, workspace: &Workspace) -> Result<(), StoreError> { self.0.lock().unwrap().workspace = Some(workspace.clone()); Ok(()) } async fn load_workspace(&self) -> Result { Ok(self.0.lock().unwrap().workspace.clone().unwrap_or_default()) } } /// A [`ProjectStore`] whose registry read always fails — used to assert the /// `Store` error code propagates. #[derive(Default, Clone)] struct BrokenStore; #[async_trait] impl ProjectStore for BrokenStore { async fn list_projects(&self) -> Result, StoreError> { Err(StoreError::Io("boom".into())) } async fn load_project(&self, _id: ProjectId) -> Result { Err(StoreError::Io("boom".into())) } async fn save_project(&self, _project: &Project) -> Result<(), StoreError> { Err(StoreError::Io("boom".into())) } async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> { Err(StoreError::Io("boom".into())) } async fn load_workspace(&self) -> Result { Err(StoreError::Io("boom".into())) } } /// Records published events. #[derive(Default, Clone)] struct SpyBus(Arc>>); impl SpyBus { fn events(&self) -> Vec { self.0.lock().unwrap().clone() } } impl EventBus for SpyBus { fn publish(&self, event: DomainEvent) { self.0.lock().unwrap().push(event); } fn subscribe(&self) -> EventStream { Box::new(std::iter::empty()) } } /// Deterministic ids: nil-based UUIDs derived from a counter. struct SeqIds(Mutex); impl SeqIds { fn new() -> Self { Self(Mutex::new(1)) } } impl IdGenerator for SeqIds { fn new_uuid(&self) -> uuid::Uuid { let mut n = self.0.lock().unwrap(); let v = *n; *n += 1; uuid::Uuid::from_u128(v) } } struct FixedClock(i64); impl Clock for FixedClock { fn now_millis(&self) -> i64 { self.0 } } // --------------------------------------------------------------------------- // Wiring helpers // --------------------------------------------------------------------------- struct Env { store: FakeStore, fs: FakeFs, bus: SpyBus, create: CreateProject, open: OpenProject, } fn env() -> Env { let store = FakeStore::default(); let fs = FakeFs::default(); let bus = SpyBus::default(); let ids: Arc = Arc::new(SeqIds::new()); let clock: Arc = Arc::new(FixedClock(1_700_000_000_000)); let create = CreateProject::new( Arc::new(store.clone()), Arc::new(fs.clone()), ids, clock, Arc::new(bus.clone()), ); let open = OpenProject::new(Arc::new(store.clone()), Arc::new(fs.clone())); Env { store, fs, bus, create, open, } } fn input(name: &str, root: &str) -> CreateProjectInput { CreateProjectInput { name: name.to_owned(), root: root.to_owned(), remote: None, default_profile_id: None, } } // --------------------------------------------------------------------------- // CreateProject // --------------------------------------------------------------------------- #[tokio::test] async fn create_inits_ideai_dir_and_writes_camelcase_project_json() { let env = env(); let out = env .create .execute(input("Demo", "/home/me/proj")) .await .expect("creation succeeds"); // .ideai/ created. assert!(env.fs.has_dir("/home/me/proj/.ideai"), "dir created"); // project.json written with camelCase fields and no `root`. let bytes = env .fs .read_file("/home/me/proj/.ideai/project.json") .expect("project.json written"); let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(json["version"], 1); assert_eq!(json["name"], "Demo"); assert_eq!(json["id"], out.project.id.to_string()); assert_eq!(json["createdAt"], 1_700_000_000_000i64); assert_eq!(json["remote"]["kind"], "local"); assert!(json.get("root").is_none(), "root must NOT be stored"); // default_profile_id omitted when None (skip_serializing_if). assert!(json.get("defaultProfileId").is_none()); } #[tokio::test] async fn create_registers_project_in_store() { let env = env(); let out = env.create.execute(input("Demo", "/p")).await.unwrap(); let stored = env.store.list_projects().await.unwrap(); assert_eq!(stored.len(), 1); assert_eq!(stored[0].id, out.project.id); assert_eq!(stored[0].name, "Demo"); } #[tokio::test] async fn create_publishes_project_created_event() { let env = env(); let out = env.create.execute(input("Demo", "/p")).await.unwrap(); assert_eq!( env.bus.events(), vec![DomainEvent::ProjectCreated { project_id: out.project.id }] ); } #[tokio::test] async fn create_rejects_duplicate_remote_root() { let env = env(); env.create.execute(input("A", "/same")).await.unwrap(); let err = env .create .execute(input("B", "/same")) .await .expect_err("duplicate (remote, root) rejected"); assert_eq!(err.code(), "INVALID", "got {err:?}"); // Only the first project remains registered. assert_eq!(env.store.list_projects().await.unwrap().len(), 1); } #[tokio::test] async fn create_allows_same_root_on_different_remote() { let env = env(); env.create.execute(input("Local", "/shared")).await.unwrap(); let remote_input = CreateProjectInput { remote: Some(RemoteRef::Wsl { distro: "Ubuntu".to_owned(), }), ..input("Wsl", "/shared") }; env.create .execute(remote_input) .await .expect("same root, different remote is allowed"); assert_eq!(env.store.list_projects().await.unwrap().len(), 2); } #[tokio::test] async fn create_rejects_non_absolute_root() { let env = env(); let err = env .create .execute(input("X", "relative/path")) .await .expect_err("non-absolute root rejected"); assert_eq!(err.code(), "INVALID", "got {err:?}"); } #[tokio::test] async fn create_rejects_empty_name() { let env = env(); let err = env .create .execute(input("", "/abs")) .await .expect_err("empty name rejected"); assert_eq!(err.code(), "INVALID", "got {err:?}"); } #[tokio::test] async fn create_propagates_store_error_code() { let ids: Arc = Arc::new(SeqIds::new()); let clock: Arc = Arc::new(FixedClock(0)); let create = CreateProject::new( Arc::new(BrokenStore), Arc::new(FakeFs::default()), ids, clock, Arc::new(SpyBus::default()), ); let err = create .execute(input("X", "/abs")) .await .expect_err("store failure surfaces"); assert_eq!(err.code(), "STORE", "got {err:?}"); } // --------------------------------------------------------------------------- // OpenProject — tolerant reads // --------------------------------------------------------------------------- #[tokio::test] async fn open_loads_project_and_meta() { let env = env(); let created = env.create.execute(input("Demo", "/o/proj")).await.unwrap(); let out = env .open .execute(OpenProjectInput { project_id: created.project.id, }) .await .expect("open succeeds"); assert_eq!(out.project.id, created.project.id); assert_eq!(out.project.root, created.project.root); let meta = out.meta.expect("meta present (project.json was written)"); assert_eq!(meta.id, created.project.id); assert_eq!(meta.name, "Demo"); // No agents.json was written → manifest tolerantly None. assert!(out.manifest.is_none(), "agents.json absent → None"); } #[tokio::test] async fn open_unknown_project_is_not_found() { let env = env(); let err = env .open .execute(OpenProjectInput { project_id: ProjectId::from_uuid(uuid::Uuid::from_u128(999)), }) .await .expect_err("unknown id"); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); } #[tokio::test] async fn open_tolerates_missing_meta_file() { // Register a project in the store WITHOUT writing any .ideai/ files. let store = FakeStore::default(); let fs = FakeFs::default(); let id = ProjectId::from_uuid(uuid::Uuid::from_u128(7)); let project = Project::new( id, "Orphan", ProjectPath::new("/no/ideai").unwrap(), RemoteRef::Local, 0, ) .unwrap(); store.save_project(&project).await.unwrap(); let open = OpenProject::new(Arc::new(store), Arc::new(fs)); let out = open .execute(OpenProjectInput { project_id: id }) .await .expect("open does not fail on missing meta"); assert!(out.meta.is_none(), "missing project.json → None"); assert!(out.manifest.is_none(), "missing agents.json → None"); } #[tokio::test] async fn open_tolerates_corrupt_json() { let store = FakeStore::default(); let fs = FakeFs::default(); let id = ProjectId::from_uuid(uuid::Uuid::from_u128(8)); let project = Project::new( id, "Corrupt", ProjectPath::new("/c/proj").unwrap(), RemoteRef::Local, 0, ) .unwrap(); store.save_project(&project).await.unwrap(); // Write garbage at both .ideai/ paths. fs.write( &RemotePath::new("/c/proj/.ideai/project.json"), b"{ not json ]", ) .await .unwrap(); fs.write( &RemotePath::new("/c/proj/.ideai/agents.json"), b"<<>>", ) .await .unwrap(); let open = OpenProject::new(Arc::new(store), Arc::new(fs)); let out = open .execute(OpenProjectInput { project_id: id }) .await .expect("corrupt JSON does not fail the open"); assert!(out.meta.is_none(), "corrupt project.json → None"); assert!(out.manifest.is_none(), "corrupt agents.json → None"); } // --------------------------------------------------------------------------- // ListProjects // --------------------------------------------------------------------------- #[tokio::test] async fn list_projects_returns_registered() { let env = env(); env.create.execute(input("A", "/a")).await.unwrap(); env.create.execute(input("B", "/b")).await.unwrap(); let list = ListProjects::new(Arc::new(env.store.clone())); let out = list.execute().await.unwrap(); let names: Vec<&str> = out.projects.iter().map(|p| p.name.as_str()).collect(); assert_eq!(out.projects.len(), 2); assert!(names.contains(&"A") && names.contains(&"B")); } // --------------------------------------------------------------------------- // CloseProject / CloseTab // --------------------------------------------------------------------------- #[tokio::test] async fn close_persists_workspace() { let store = FakeStore::default(); let close = CloseProject::new(Arc::new(store.clone())); let id = ProjectId::from_uuid(uuid::Uuid::from_u128(3)); let out = close .execute(CloseProjectInput { project_id: id, workspace: Some(Workspace::default()), }) .await .unwrap(); assert_eq!(out.project_id, id); assert!(store.saved_workspace().is_some(), "workspace persisted"); } #[tokio::test] async fn close_without_workspace_skips_persistence() { let store = FakeStore::default(); let close = CloseProject::new(Arc::new(store.clone())); let id = ProjectId::from_uuid(uuid::Uuid::from_u128(4)); close .execute(CloseProjectInput { project_id: id, workspace: None, }) .await .unwrap(); assert!(store.saved_workspace().is_none(), "no persistence when None"); } #[tokio::test] async fn close_tab_delegates_to_persistence() { let store = FakeStore::default(); let close_tab = CloseTab::new(Arc::new(store.clone())); let id = ProjectId::from_uuid(uuid::Uuid::from_u128(5)); let out = close_tab .execute(CloseTabInput { project_id: id, workspace: Some(Workspace::default()), }) .await .unwrap(); assert_eq!(out.project_id, id); assert!(store.saved_workspace().is_some(), "tab close persists too"); }