Les builders `with_opencode` / `with_opencode_provider` s'évacuent réciproquement (dernier appel gagnant) pour honorer l'invariant `opencode_backend_is_consistent`. Le use case SaveOpenCodeProviderProfile route désormais via le builder au lieu de muter le champ directement — c'est ce qui dupliquait `opencode` + `opencodeProvider` et forçait un repli sur llamacpp même quand l'utilisateur choisissait un provider cloud. Défense en profondeur côté store : - `read_doc` répare les profils corrompus sur disque (drop du stale `opencode`) - `save` refuse de persister un profil violant l'invariant via le nouveau variant `StoreError::Invalid` (mappé vers `AppError::Invalid`) Tests verts : builders last-wins (domain), save rejets + read repair (infra, profile_store 10/10). Refs #97
256 lines
8.6 KiB
Rust
256 lines
8.6 KiB
Rust
//! L5 integration tests for [`FsProfileStore`] against a real temp directory,
|
|
//! using a real [`LocalFileSystem`] so the full persistence path (camelCase
|
|
//! `profiles.json`, upsert, delete, first-run marker) is exercised end-to-end.
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use domain::ids::ProfileId;
|
|
use domain::ports::{FileSystem, ProfileStore, RemotePath, SecretRef, StoreError};
|
|
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter};
|
|
use infrastructure::{FsProfileStore, 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-l5-profile-{}", Uuid::new_v4()));
|
|
std::fs::create_dir_all(&p).unwrap();
|
|
Self(p)
|
|
}
|
|
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) -> FsProfileStore {
|
|
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
|
FsProfileStore::new(fs, tmp.app_data_dir())
|
|
}
|
|
|
|
fn sample(id: u128, name: &str, command: &str) -> AgentProfile {
|
|
AgentProfile::new(
|
|
ProfileId::from_uuid(Uuid::from_u128(id)),
|
|
name,
|
|
command,
|
|
Vec::new(),
|
|
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
|
Some(format!("{command} --version")),
|
|
"{projectRoot}",
|
|
None,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn save_then_list_roundtrips() {
|
|
let tmp = TempDir::new();
|
|
let store = store(&tmp);
|
|
|
|
let p = sample(1, "Claude", "claude");
|
|
store.save(&p).await.unwrap();
|
|
|
|
let listed = store.list().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 first = sample(1, "before", "claude");
|
|
store.save(&first).await.unwrap();
|
|
|
|
let updated = sample(1, "after", "claude-renamed");
|
|
store.save(&updated).await.unwrap();
|
|
|
|
let listed = store.list().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 delete_removes_profile() {
|
|
let tmp = TempDir::new();
|
|
let store = store(&tmp);
|
|
|
|
let a = sample(1, "A", "a");
|
|
let b = sample(2, "B", "b");
|
|
store.save(&a).await.unwrap();
|
|
store.save(&b).await.unwrap();
|
|
|
|
store.delete(a.id).await.unwrap();
|
|
|
|
let listed = store.list().await.unwrap();
|
|
assert_eq!(listed, vec![b]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_unknown_is_not_found() {
|
|
let tmp = TempDir::new();
|
|
let store = store(&tmp);
|
|
store.save(&sample(1, "A", "a")).await.unwrap();
|
|
|
|
let err = store
|
|
.delete(ProfileId::from_uuid(Uuid::from_u128(999)))
|
|
.await
|
|
.expect_err("deleting unknown id fails");
|
|
assert!(matches!(err, StoreError::NotFound), "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn is_configured_false_before_any_write() {
|
|
let tmp = TempDir::new();
|
|
let store = store(&tmp);
|
|
// First run: no profiles.json yet.
|
|
assert!(!store.is_configured().await.unwrap());
|
|
assert!(store.list().await.unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn is_configured_true_after_save() {
|
|
let tmp = TempDir::new();
|
|
let store = store(&tmp);
|
|
store.save(&sample(1, "A", "a")).await.unwrap();
|
|
assert!(store.is_configured().await.unwrap());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mark_configured_creates_file_with_empty_profiles() {
|
|
let tmp = TempDir::new();
|
|
let store = store(&tmp);
|
|
|
|
assert!(!store.is_configured().await.unwrap());
|
|
store.mark_configured().await.unwrap();
|
|
assert!(store.is_configured().await.unwrap(), "marker materialised");
|
|
assert!(
|
|
store.list().await.unwrap().is_empty(),
|
|
"empty profile list recorded"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn profiles_file_is_camelcase_versioned() {
|
|
let tmp = TempDir::new();
|
|
let store = store(&tmp);
|
|
|
|
let p = sample(1, "Claude", "claude");
|
|
store.save(&p).await.unwrap();
|
|
|
|
let fs = LocalFileSystem::new();
|
|
let bytes = fs.read(&tmp.child("profiles.json")).await.unwrap();
|
|
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
|
|
|
assert_eq!(json["version"], 1);
|
|
let profiles = json
|
|
.get("profiles")
|
|
.and_then(|v| v.as_array())
|
|
.expect("top-level `profiles` array");
|
|
assert_eq!(profiles.len(), 1);
|
|
|
|
let entry = &profiles[0];
|
|
assert_eq!(entry["name"], "Claude");
|
|
assert_eq!(entry["command"], "claude");
|
|
// camelCase fields, tagged contextInjection.
|
|
assert!(entry.get("cwdTemplate").is_some(), "camelCase cwdTemplate");
|
|
assert!(entry.get("cwd_template").is_none(), "no snake_case leak");
|
|
assert_eq!(entry["contextInjection"]["strategy"], "conventionFile");
|
|
assert_eq!(entry["contextInjection"]["target"], "CLAUDE.md");
|
|
}
|
|
|
|
/// Fixture pair for ticket #97: the local llama.cpp backend (`opencode`) and the
|
|
/// cloud provider (`opencodeProvider`) — the two mutually exclusive OpenCode
|
|
/// backends. A profile carrying both is inconsistent.
|
|
fn opencode_backends() -> (OpenCodeConfig, OpenCodeProviderConfig) {
|
|
let local =
|
|
OpenCodeConfig::new("http://localhost:8080/v1", None, "qwen3-coder-30b", None, None).unwrap();
|
|
let cloud =
|
|
OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", SecretRef::new("secret-cloud"))
|
|
.unwrap();
|
|
(local, cloud)
|
|
}
|
|
|
|
/// Ticket #97 — read repair (`read_doc`, profile.rs:90-105): a profile corrupted
|
|
/// on disk (both `opencode` and `opencodeProvider` present) is silently repaired
|
|
/// on read by dropping the stale `opencode`, converging with the enforced
|
|
/// builder invariant. The cloud provider — the user's actual intent — is kept.
|
|
#[tokio::test]
|
|
async fn read_doc_repairs_stale_opencode_when_both_sections_present() {
|
|
let tmp = TempDir::new();
|
|
let (local, cloud) = opencode_backends();
|
|
|
|
// The `save` guard would refuse this, so plant the corruption directly on
|
|
// disk (bypassing the guard), exactly like a stale file from an older build.
|
|
let mut corrupted = sample(1, "OpenCode Cloud", "opencode")
|
|
.with_structured_adapter(StructuredAdapter::OpenCode)
|
|
.with_opencode(local);
|
|
corrupted.opencode_provider = Some(cloud.clone());
|
|
assert!(
|
|
!corrupted.opencode_backend_is_consistent(),
|
|
"fixture must violate the invariant"
|
|
);
|
|
|
|
let fs = LocalFileSystem::new();
|
|
let doc = serde_json::json!({ "version": 1, "profiles": [corrupted] });
|
|
fs.write(&tmp.child("profiles.json"), &serde_json::to_vec_pretty(&doc).unwrap())
|
|
.await
|
|
.unwrap();
|
|
|
|
let store = store(&tmp);
|
|
let listed = store.list().await.expect("read repairs instead of failing");
|
|
assert_eq!(listed.len(), 1, "corrupted profile preserved as an entry");
|
|
let repaired = &listed[0];
|
|
assert!(repaired.opencode.is_none(), "stale local `opencode` dropped on read");
|
|
assert_eq!(
|
|
repaired.opencode_provider.as_ref().unwrap().provider_id,
|
|
"anthropic",
|
|
"cloud `opencodeProvider` preserved"
|
|
);
|
|
assert!(
|
|
repaired.opencode_backend_is_consistent(),
|
|
"read repair restored the mutual-exclusion invariant"
|
|
);
|
|
}
|
|
|
|
/// Ticket #97 — write guard (`save`, profile.rs:129-139): persisting a profile
|
|
/// that violates the OpenCode backend mutual-exclusion invariant is rejected
|
|
/// with [`StoreError::Invalid`], and nothing is written to disk.
|
|
#[tokio::test]
|
|
async fn save_rejects_profile_violating_backend_exclusion() {
|
|
let tmp = TempDir::new();
|
|
let store = store(&tmp);
|
|
let (local, cloud) = opencode_backends();
|
|
|
|
// Only reachable via direct mutation: the builders enforce exclusion
|
|
// (last-wins), so an inconsistent profile cannot be built through the API.
|
|
let mut inconsistent = sample(1, "OpenCode Both", "opencode")
|
|
.with_structured_adapter(StructuredAdapter::OpenCode)
|
|
.with_opencode(local);
|
|
inconsistent.opencode_provider = Some(cloud);
|
|
assert!(!inconsistent.opencode_backend_is_consistent());
|
|
|
|
let err = store
|
|
.save(&inconsistent)
|
|
.await
|
|
.expect_err("save must reject an invariant-violating profile");
|
|
assert!(
|
|
matches!(err, StoreError::Invalid(_)),
|
|
"defense-in-depth guard maps to Invalid; got {err:?}"
|
|
);
|
|
assert!(
|
|
!store.is_configured().await.unwrap(),
|
|
"rejected save must not materialise profiles.json"
|
|
);
|
|
}
|