cause: l'utilisateur ne pouvait pas retirer un projet du registre IdeA (projects.json) ; la seule issue laissait l'entrée fantôme dans la liste des projets, sans capacité générique de retrait. domain: port ProjectStore::remove_project(id) -> Option<Project> avec implémentation par défaut « not supported » pour ne pas casser les stores spécialisés. infrastructure: FsProjectStore implémente remove_project — retire l'entrée du registre persisté et la renvoie, None si id inconnu, ordre relatif du registre préservé ; test store dédié. application: use case RemoveProjectFromRegistry (remove.rs) — NotFound explicite si id absent, retourne le projet retiré + registre rafraîchi. backend/app-tauri/web-server: DTO RemoveProjectRequestDto/RemoveProjectResponseDto (removed, projects, restore_state, was_open), adapter partagé remove_project_for_adapter réutilisé par la commande Tauri remove_project et la route HTTP — même contrat desktop/web ; remove_project_and_deactivate désactive le projet s'il était ouvert, purge l'entrée open-projects restore correspondante et renvoie was_open/restore_state ; test adapter vérifiant le contrat de bout en bout (entrée retirée, restore purgé, root intact). frontend: DTO + ProjectGateway.removeProject (HTTP + mock), ProjectsView expose un menu d'actions projet (ProjectActionsMenu) avec entrée Retirer, RemoveProjectDialog — confirmation explicite précisant que le dossier disque n'est pas supprimé, invalidation/rechargement de la liste des projets après succès (useProjects), tests ProjectsView/projects/adapters/httpInvoker/services. QA verte: cargo targeted tests/check OK (domain, application project_usecases, infrastructure project_store, backend, app-tauri, web-server), npm targeted/typecheck/build OK ; suite npm test complète rouge non bloquante hors #308 (échecs préexistants listProjectSettings/web). Vérifications Git: git diff --check OK, cargo fmt --all -- --check OK. fichiers .ideai/tickets/308/, tickets/counter.json, tickets/index.json, attachments/ et scripts/ hors périmètre et préservés en worktree (sync store traité séparément) ; aucun push. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
181 lines
5.8 KiB
Rust
181 lines
5.8 KiB
Rust
//! L2 integration tests for [`FsProjectStore`] against a real temp directory,
|
|
//! using a real [`LocalFileSystem`] so the full persistence path (JSON layout,
|
|
//! tolerant reads, upsert) is exercised end-to-end.
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use domain::ids::ProjectId;
|
|
use domain::layout::Workspace;
|
|
use domain::ports::{FileSystem, ProjectStore, RemotePath};
|
|
use domain::project::{Project, ProjectPath};
|
|
use domain::remote::RemoteRef;
|
|
use infrastructure::{FsProjectStore, LocalFileSystem};
|
|
use uuid::Uuid;
|
|
|
|
/// A unique scratch directory under the OS temp dir, cleaned up on drop.
|
|
struct TempDir(PathBuf);
|
|
impl TempDir {
|
|
fn new() -> Self {
|
|
let p = std::env::temp_dir().join(format!("idea-l2-store-{}", Uuid::new_v4()));
|
|
std::fs::create_dir_all(&p).unwrap();
|
|
Self(p)
|
|
}
|
|
/// The app-data dir as a plain string, as the composition root would pass it.
|
|
fn app_data_dir(&self) -> String {
|
|
self.0.to_string_lossy().into_owned()
|
|
}
|
|
fn child(&self, name: &str) -> RemotePath {
|
|
RemotePath::new(self.0.join(name).to_string_lossy().into_owned())
|
|
}
|
|
}
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn store(tmp: &TempDir) -> FsProjectStore {
|
|
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
|
FsProjectStore::new(fs, tmp.app_data_dir())
|
|
}
|
|
|
|
fn sample_project(id: ProjectId, name: &str, root: &str) -> Project {
|
|
Project::new(
|
|
id,
|
|
name,
|
|
ProjectPath::new(root).unwrap(),
|
|
RemoteRef::local(),
|
|
1_700_000_000_000,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn save_then_list_roundtrips() {
|
|
let tmp = TempDir::new();
|
|
let store = store(&tmp);
|
|
|
|
let p = sample_project(ProjectId::new_random(), "alpha", "/home/me/alpha");
|
|
store.save_project(&p).await.unwrap();
|
|
|
|
let listed = store.list_projects().await.unwrap();
|
|
assert_eq!(listed, vec![p]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn save_upserts_by_id_without_duplicating() {
|
|
let tmp = TempDir::new();
|
|
let store = store(&tmp);
|
|
|
|
let id = ProjectId::new_random();
|
|
let first = sample_project(id, "before", "/home/me/proj");
|
|
store.save_project(&first).await.unwrap();
|
|
|
|
// Same id, changed fields: must update in place, not append.
|
|
let updated = sample_project(id, "after", "/home/me/proj-renamed");
|
|
store.save_project(&updated).await.unwrap();
|
|
|
|
let listed = store.list_projects().await.unwrap();
|
|
assert_eq!(listed.len(), 1, "upsert must not duplicate by id");
|
|
assert_eq!(listed[0], updated);
|
|
assert_eq!(listed[0].name, "after");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_registry_lists_empty() {
|
|
let tmp = TempDir::new();
|
|
let store = store(&tmp);
|
|
|
|
// No projects.json written yet: tolerant read returns an empty list.
|
|
let listed = store.list_projects().await.unwrap();
|
|
assert!(listed.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remove_project_preserves_order_and_never_touches_project_root() {
|
|
let tmp = TempDir::new();
|
|
let store = store(&tmp);
|
|
let root = tmp.0.join("owned-project-root");
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
std::fs::write(root.join("keep.txt"), b"untouched").unwrap();
|
|
|
|
let first = sample_project(ProjectId::new_random(), "first", "/outside/first");
|
|
let removed = sample_project(ProjectId::new_random(), "removed", &root.to_string_lossy());
|
|
let last = sample_project(ProjectId::new_random(), "last", "/outside/last");
|
|
for project in [&first, &removed, &last] {
|
|
store.save_project(project).await.unwrap();
|
|
}
|
|
|
|
assert_eq!(
|
|
store.remove_project(removed.id).await.unwrap(),
|
|
Some(removed)
|
|
);
|
|
assert_eq!(store.list_projects().await.unwrap(), vec![first, last]);
|
|
assert_eq!(std::fs::read(root.join("keep.txt")).unwrap(), b"untouched");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remove_missing_project_does_not_rewrite_the_registry() {
|
|
let tmp = TempDir::new();
|
|
let store = store(&tmp);
|
|
let project = sample_project(ProjectId::new_random(), "kept", "/outside/kept");
|
|
store.save_project(&project).await.unwrap();
|
|
let before = std::fs::read(tmp.0.join("projects.json")).unwrap();
|
|
|
|
assert_eq!(
|
|
store.remove_project(ProjectId::new_random()).await.unwrap(),
|
|
None
|
|
);
|
|
assert_eq!(std::fs::read(tmp.0.join("projects.json")).unwrap(), before);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn workspace_save_then_load_roundtrips() {
|
|
let tmp = TempDir::new();
|
|
let store = store(&tmp);
|
|
|
|
// Missing workspace returns the default.
|
|
let loaded = store.load_workspace().await.unwrap();
|
|
assert_eq!(loaded, Workspace::default());
|
|
|
|
let ws = Workspace::default();
|
|
store.save_workspace(&ws).await.unwrap();
|
|
let back = store.load_workspace().await.unwrap();
|
|
assert_eq!(back, ws);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn registry_file_is_camelcase_json() {
|
|
let tmp = TempDir::new();
|
|
let store = store(&tmp);
|
|
|
|
let p = sample_project(ProjectId::new_random(), "jsoncheck", "/srv/app");
|
|
store.save_project(&p).await.unwrap();
|
|
|
|
// Read the raw bytes the store wrote and assert the camelCase shape.
|
|
let fs = LocalFileSystem::new();
|
|
let bytes = fs.read(&tmp.child("projects.json")).await.unwrap();
|
|
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
|
|
|
assert!(json.get("version").is_some(), "top-level `version` present");
|
|
let projects = json
|
|
.get("projects")
|
|
.and_then(|v| v.as_array())
|
|
.expect("top-level `projects` array");
|
|
assert_eq!(projects.len(), 1);
|
|
|
|
let entry = &projects[0];
|
|
// camelCase serialization of `created_at`.
|
|
assert!(
|
|
entry.get("createdAt").is_some(),
|
|
"project uses camelCase `createdAt`, got {entry}"
|
|
);
|
|
assert!(entry.get("created_at").is_none(), "no snake_case leak");
|
|
assert_eq!(
|
|
entry.get("name").and_then(|v| v.as_str()),
|
|
Some("jsoncheck")
|
|
);
|
|
assert_eq!(entry.get("root").and_then(|v| v.as_str()), Some("/srv/app"));
|
|
}
|