Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
566 lines
16 KiB
Rust
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");
|
|
}
|