Files
IdeA/crates/infrastructure/tests/skill_store.rs

212 lines
6.4 KiB
Rust

//! L12 integration tests for [`FsSkillStore`] against a real temp directory and a
//! real [`LocalFileSystem`]: md + `index.json` round-trip in **both** scopes,
//! scope isolation (a `Project` skill never appears under `Global` and vice
//! versa), upsert, delete, tolerant reads, and the on-disk layout.
use std::path::PathBuf;
use std::sync::Arc;
use domain::ids::SkillId;
use domain::markdown::MarkdownDoc;
use domain::ports::{FileSystem, RemotePath, SkillStore, StoreError};
use domain::project::ProjectPath;
use domain::skill::{Skill, SkillScope};
use infrastructure::{FsSkillStore, LocalFileSystem};
use uuid::Uuid;
/// A unique scratch directory under the OS temp dir, cleaned up on drop. It plays
/// **both** roles: its own path is the global app-data dir, and a `project/`
/// child is the project root (so the two scopes resolve to disjoint subtrees).
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("idea-l12-skill-{}", 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 project_root(&self) -> ProjectPath {
ProjectPath::new(self.0.join("project").to_string_lossy().into_owned()).unwrap()
}
fn child(&self, rel: &str) -> RemotePath {
RemotePath::new(self.0.join(rel).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) -> FsSkillStore {
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
FsSkillStore::new(fs, tmp.app_data_dir())
}
fn sid(n: u128) -> SkillId {
SkillId::from_uuid(Uuid::from_u128(n))
}
fn skill(id: SkillId, name: &str, content: &str, scope: SkillScope) -> Skill {
Skill::new(id, name, MarkdownDoc::new(content), scope).unwrap()
}
#[tokio::test]
async fn missing_index_lists_empty_in_both_scopes() {
let tmp = TempDir::new();
let s = store(&tmp);
let root = tmp.project_root();
assert!(s.list(SkillScope::Global, &root).await.unwrap().is_empty());
assert!(s.list(SkillScope::Project, &root).await.unwrap().is_empty());
}
#[tokio::test]
async fn global_roundtrip_save_get_list() {
let tmp = TempDir::new();
let s = store(&tmp);
let root = tmp.project_root();
let sk = skill(sid(1), "refactor", "# Refactor workflow", SkillScope::Global);
s.save(&sk, &root).await.unwrap();
assert_eq!(s.get(SkillScope::Global, &root, sid(1)).await.unwrap(), sk);
assert_eq!(
s.list(SkillScope::Global, &root).await.unwrap(),
vec![sk.clone()]
);
// The Markdown landed under the global skills dir.
let fs = LocalFileSystem::new();
let bytes = fs
.read(&tmp.child(&format!("skills/md/{}.md", sid(1))))
.await
.unwrap();
assert_eq!(String::from_utf8(bytes).unwrap(), sk.content_md.as_str());
}
#[tokio::test]
async fn project_roundtrip_lands_under_ideai() {
let tmp = TempDir::new();
let s = store(&tmp);
let root = tmp.project_root();
let sk = skill(sid(1), "review", "# Review workflow", SkillScope::Project);
s.save(&sk, &root).await.unwrap();
assert_eq!(s.get(SkillScope::Project, &root, sid(1)).await.unwrap(), sk);
let fs = LocalFileSystem::new();
let bytes = fs
.read(&tmp.child(&format!("project/.ideai/skills/md/{}.md", sid(1))))
.await
.unwrap();
assert_eq!(String::from_utf8(bytes).unwrap(), sk.content_md.as_str());
}
#[tokio::test]
async fn scopes_are_isolated() {
let tmp = TempDir::new();
let s = store(&tmp);
let root = tmp.project_root();
s.save(&skill(sid(1), "g", "global body", SkillScope::Global), &root)
.await
.unwrap();
s.save(
&skill(sid(2), "p", "project body", SkillScope::Project),
&root,
)
.await
.unwrap();
// A global skill never surfaces in the project scope and vice-versa.
let globals = s.list(SkillScope::Global, &root).await.unwrap();
let projects = s.list(SkillScope::Project, &root).await.unwrap();
assert_eq!(globals.len(), 1);
assert_eq!(projects.len(), 1);
assert_eq!(globals[0].id, sid(1));
assert_eq!(projects[0].id, sid(2));
assert!(matches!(
s.get(SkillScope::Global, &root, sid(2)).await.unwrap_err(),
StoreError::NotFound
));
assert!(matches!(
s.get(SkillScope::Project, &root, sid(1)).await.unwrap_err(),
StoreError::NotFound
));
}
#[tokio::test]
async fn save_upserts_content() {
let tmp = TempDir::new();
let s = store(&tmp);
let root = tmp.project_root();
s.save(&skill(sid(1), "k", "v1", SkillScope::Global), &root)
.await
.unwrap();
s.save(&skill(sid(1), "k", "v2", SkillScope::Global), &root)
.await
.unwrap();
assert_eq!(
s.get(SkillScope::Global, &root, sid(1))
.await
.unwrap()
.content_md
.as_str(),
"v2"
);
assert_eq!(
s.list(SkillScope::Global, &root).await.unwrap().len(),
1,
"upsert, not append"
);
}
#[tokio::test]
async fn delete_removes_from_index_and_is_not_found_twice() {
let tmp = TempDir::new();
let s = store(&tmp);
let root = tmp.project_root();
s.save(&skill(sid(1), "k", "x", SkillScope::Project), &root)
.await
.unwrap();
s.delete(SkillScope::Project, &root, sid(1)).await.unwrap();
assert!(s.list(SkillScope::Project, &root).await.unwrap().is_empty());
assert!(matches!(
s.delete(SkillScope::Project, &root, sid(1))
.await
.unwrap_err(),
StoreError::NotFound
));
}
#[tokio::test]
async fn index_is_camelcase_with_content_hash() {
let tmp = TempDir::new();
let s = store(&tmp);
let root = tmp.project_root();
s.save(&skill(sid(1), "refactor", "hello", SkillScope::Global), &root)
.await
.unwrap();
let fs = LocalFileSystem::new();
let bytes = fs.read(&tmp.child("skills/index.json")).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let entry = &json.get("skills").unwrap().as_array().unwrap()[0];
assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("refactor"));
assert!(
entry.get("contentHash").is_some(),
"camelCase contentHash present"
);
assert!(
entry.get("content_hash").is_none(),
"no snake_case leak"
);
}