feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
565
crates/application/tests/project_usecases.rs
Normal file
565
crates/application/tests/project_usecases.rs
Normal file
@ -0,0 +1,565 @@
|
||||
//! 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<HashMap<String, Vec<u8>>>` 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<String, Vec<u8>>,
|
||||
dirs: HashSet<String>,
|
||||
}
|
||||
|
||||
/// An in-memory [`FileSystem`] recording writes and created directories.
|
||||
#[derive(Default, Clone)]
|
||||
struct FakeFs(Arc<Mutex<FakeFsInner>>);
|
||||
|
||||
impl FakeFs {
|
||||
fn read_file(&self, path: &str) -> Option<Vec<u8>> {
|
||||
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<Vec<u8>, 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<bool, FsError> {
|
||||
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<Vec<DirEntry>, FsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeStoreInner {
|
||||
projects: Vec<Project>,
|
||||
workspace: Option<Workspace>,
|
||||
}
|
||||
|
||||
/// An in-memory [`ProjectStore`].
|
||||
#[derive(Default, Clone)]
|
||||
struct FakeStore(Arc<Mutex<FakeStoreInner>>);
|
||||
|
||||
impl FakeStore {
|
||||
fn saved_workspace(&self) -> Option<Workspace> {
|
||||
self.0.lock().unwrap().workspace.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProjectStore for FakeStore {
|
||||
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().projects.clone())
|
||||
}
|
||||
|
||||
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
||||
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<Workspace, StoreError> {
|
||||
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<Vec<Project>, StoreError> {
|
||||
Err(StoreError::Io("boom".into()))
|
||||
}
|
||||
async fn load_project(&self, _id: ProjectId) -> Result<Project, StoreError> {
|
||||
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<Workspace, StoreError> {
|
||||
Err(StoreError::Io("boom".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Records published events.
|
||||
#[derive(Default, Clone)]
|
||||
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
||||
|
||||
impl SpyBus {
|
||||
fn events(&self) -> Vec<DomainEvent> {
|
||||
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<u128>);
|
||||
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<dyn IdGenerator> = Arc::new(SeqIds::new());
|
||||
let clock: Arc<dyn Clock> = 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<dyn IdGenerator> = Arc::new(SeqIds::new());
|
||||
let clock: Arc<dyn Clock> = 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"<<<broken>>>",
|
||||
)
|
||||
.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");
|
||||
}
|
||||
Reference in New Issue
Block a user