Files
IdeA/crates/application/tests/project_usecases.rs
Blomios 307ae71857 feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
2026-06-06 01:27:01 +02:00

566 lines
16 KiB
Rust

//! 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");
}