//! Tests for `recipe_repos` — hermetic `file://` git repos (no network). use super::{ LAST_SYNC_FILE, ReposRegistry, archive_at, auth_url, load_registry, save_registry, sync_one, sync_repos, unarchive_at, }; use crate::config::RecipeRepo; use std::path::PathBuf; fn git_ok(args: &[&str]) { let out = std::process::Command::new("git") .args(args) .output() .expect("run git"); assert!( out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr) ); } /// Create a temp git repo with `files` (`rel path -> content`), committed; /// returns (dir, `file://` url). fn make_repo(tag: &str, files: &[(&str, &str)]) -> (PathBuf, String) { let dir = std::env::temp_dir().join(format!("sm121-rr-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); for (rel, content) in files { let p = dir.join(rel); if let Some(parent) = p.parent() { std::fs::create_dir_all(parent).unwrap(); } std::fs::write(&p, content).unwrap(); } let d = dir.display().to_string(); git_ok(&["-C", &d, "init", "-q"]); git_ok(&["-C", &d, "config", "user.email", "t@sm121.test"]); git_ok(&["-C", &d, "config", "user.name", "sm121-test"]); git_ok(&["-C", &d, "add", "-A"]); git_ok(&["-C", &d, "commit", "-qm", "init"]); (dir, format!("file://{}", d)) } fn repo(url: String) -> RecipeRepo { RecipeRepo { url, token: None } } fn cache_base(tag: &str) -> PathBuf { let p = std::env::temp_dir().join(format!("sm121-rr-cache-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&p); p } #[test] fn clone_then_pull_picks_up_new_commits() { let (_src_a, a_url) = make_repo( "a", &[ ( "recipes/alpha/recipe.toml", "engine = \"vllm\"\nversion = \"v1\"\n", ), ("recipes/beta/repo-note.txt", "static\n"), ], ); let (src_b, b_url) = make_repo( "b", &[( "recipes/beta/recipe.toml", "engine = \"sglang\"\nversion = \"b1\"\n", )], ); let base = cache_base("pull"); let reg = ReposRegistry::default(); // first sync: clones both, in list order let roots = sync_repos( &[repo(a_url.clone()), repo(b_url.clone())], &base, ®, false, ); assert_eq!(roots.len(), 2, "both repos synced: {roots:?}"); assert!(roots[0].join("recipes/alpha/recipe.toml").is_file()); assert!(roots[1].join("recipes/beta/recipe.toml").is_file()); assert!(roots[0].join(LAST_SYNC_FILE).is_file(), "last-sync stamped"); // new commit in B → the subsequent sync pulls it let b2 = src_b.join("recipes/gamma/recipe.toml"); std::fs::create_dir_all(b2.parent().unwrap()).unwrap(); std::fs::write(&b2, "engine = \"vllm\"\n").unwrap(); let d = src_b.display().to_string(); git_ok(&["-C", &d, "add", "-A"]); git_ok(&["-C", &d, "commit", "-qm", "gamma"]); let roots = sync_repos(&[repo(a_url), repo(b_url)], &base, ®, false); assert!( roots[1].join("recipes/gamma/recipe.toml").is_file(), "pull landed gamma" ); let _ = _src_a; } #[test] fn first_repo_shadows_later_on_name_collision() { let (_a, a_url) = make_repo( "shadow-a", &[( "recipes/dup/recipe.toml", "engine = \"vllm\"\norigin = \"a\"\n", )], ); let (_b, b_url) = make_repo( "shadow-b", &[( "recipes/dup/recipe.toml", "engine = \"vllm\"\norigin = \"b\"\n", )], ); let base = cache_base("shadow"); let roots = sync_repos( &[repo(a_url.clone()), repo(b_url.clone())], &base, &ReposRegistry::default(), false, ); assert_eq!(roots.len(), 2); // roots land in recipe_sources as their `recipes/` dirs, in list order → // the earlier (higher-priority) repo's copy is the one the search finds first let cfg: crate::config::Config = toml::from_str(&format!( "recipe_sources = [\"{}\", \"{}\"]\n", roots[0].join("recipes").display(), roots[1].join("recipes").display() )) .unwrap(); let found = crate::config::recipe_file("dup", &cfg.recipe_sources) .unwrap_or_else(|| panic!("recipe 'dup' not found under the synced roots")); assert!( found.starts_with(roots[0].join("recipes").display().to_string().as_str()), "first repo shadows the later one: {found:?}" ); } #[test] fn archived_repo_is_skipped() { let (_a, a_url) = make_repo( "arch-a", &[("recipes/alpha/recipe.toml", "engine = \"vllm\"\n")], ); let (_b, b_url) = make_repo( "arch-b", &[("recipes/beta/recipe.toml", "engine = \"sglang\"\n")], ); let base = cache_base("arch"); let reg_path = base.join("repos.json"); archive_at(®_path, &a_url).unwrap(); let reg = load_registry(®_path); assert!(reg.is_archived(&a_url)); let roots = sync_repos( &[repo(a_url.clone()), repo(b_url.clone())], &base, ®, false, ); assert_eq!(roots.len(), 1, "only the un-archived repo synced"); assert!(roots[0].join("recipes/beta/recipe.toml").is_file()); // unarchive → it syncs again unarchive_at(®_path, &a_url).unwrap(); let reg = load_registry(®_path); assert!(!reg.is_archived(&a_url)); let roots = sync_repos(&[repo(a_url), repo(b_url)], &base, ®, false); assert_eq!(roots.len(), 2); } #[test] fn unreachable_repo_is_skipped_not_fatal() { let (_b, b_url) = make_repo( "reach-b", &[("recipes/beta/recipe.toml", "engine = \"sglang\"\n")], ); let dead = format!( "file://{}/no-such-sm121-repo-{}", std::env::temp_dir().display(), std::process::id() ); let base = cache_base("reach"); let roots = sync_repos( &[repo(dead), repo(b_url)], &base, &ReposRegistry::default(), false, ); assert_eq!(roots.len(), 1, "the live repo still syncs: {roots:?}"); assert!(roots[0].join("recipes/beta/recipe.toml").is_file()); } #[test] fn force_reclones_from_scratch() { let (_src, url) = make_repo( "force", &[("recipes/beta/recipe.toml", "engine = \"sglang\"\n")], ); let base = cache_base("force"); let reg = ReposRegistry::default(); let roots = sync_repos(&[repo(url.clone())], &base, ®, false); std::fs::write(roots[0].join("junk.txt"), "x").unwrap(); let roots = sync_repos(&[repo(url)], &base, ®, true); assert!( !roots[0].join("junk.txt").exists(), "force re-clone dropped the local junk" ); assert!(roots[0].join("recipes/beta/recipe.toml").is_file()); } #[test] fn registry_missing_is_fresh() { let p = cache_base("reg-missing").join("repos.json"); let reg = load_registry(&p); assert!(reg.archived.is_empty()); } #[test] fn registry_corrupt_is_backed_up_and_recreated() { let p = cache_base("reg-bak").join("repos.json"); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(&p, "{ not json").unwrap(); let reg = load_registry(&p); assert!( reg.archived.is_empty(), "corrupt registry degrades to fresh" ); assert!( p.with_extension("json.bak").exists(), "corrupt file preserved as .bak" ); // and it is writable again save_registry(&p, ®).unwrap(); } #[test] fn archive_unarchive_round_trip() { let p = cache_base("reg-rt").join("repos.json"); archive_at(&p, "https://git.example/x.git").unwrap(); assert!(load_registry(&p).is_archived("https://git.example/x.git")); // idempotent archive_at(&p, "https://git.example/x.git").unwrap(); assert_eq!(load_registry(&p).archived.len(), 1); unarchive_at(&p, "https://git.example/x.git").unwrap(); assert!(!load_registry(&p).is_archived("https://git.example/x.git")); // unarchiving a never-archived url is a no-op unarchive_at(&p, "https://git.example/y.git").unwrap(); } #[test] fn token_is_embedded_only_for_http_urls() { let https = RecipeRepo { url: "https://git.kilork.org/sm121/recipes-production.git".into(), token: Some("tok123".into()), }; assert_eq!( auth_url(&https), "https://tok123@git.kilork.org/sm121/recipes-production.git" ); let ssh = RecipeRepo { url: "git@git.kilork.org:sm121/recipes.git".into(), token: Some("ignored".into()), }; assert_eq!( auth_url(&ssh), ssh.url, "ssh urls pass through (the token is a no-op)" ); let no_token = RecipeRepo { url: "https://git.kilork.org/x.git".into(), token: None, }; assert_eq!(auth_url(&no_token), no_token.url); } #[test] fn sync_one_stamps_last_sync() { let (_src, url) = make_repo("stamp", &[("recipes/beta/recipe.toml", "x = 1\n")]); let base = cache_base("stamp"); let dir = super::repo_cache_dir(&base, &url); sync_one(&url, &dir, false).unwrap(); let ts: i64 = std::fs::read_to_string(dir.join(LAST_SYNC_FILE)) .unwrap() .trim() .parse() .unwrap(); let now = crate::stamp::now_secs(); assert!(ts > now - 60 && ts <= now, "stamp is a recent epoch: {ts}"); }