diff options
18 files changed, 3126 insertions, 192 deletions
diff --git a/crates/shirabe-php-shim/src/array.rs b/crates/shirabe-php-shim/src/array.rs index 280ec64..ace6a72 100644 --- a/crates/shirabe-php-shim/src/array.rs +++ b/crates/shirabe-php-shim/src/array.rs @@ -233,20 +233,21 @@ pub fn array_search_mixed( haystack: &PhpMixed, strict: bool, ) -> Option<PhpMixed> { - if !strict { - // TODO(phase-c): non-strict array_search needs PHP's loose `==` comparison - // semantics. Only the strict path is implemented; loose comparison is - // deferred rather than approximated. - todo!("non-strict array_search (PHP loose comparison)"); - } + let matches = |value: &PhpMixed| -> bool { + if strict { + value == needle + } else { + loose_eq(value, needle) + } + }; match haystack { PhpMixed::List(items) => items .iter() - .position(|value| value == needle) + .position(matches) .map(|i| PhpMixed::Int(i as i64)), PhpMixed::Array(map) => map .iter() - .find(|(_, value)| *value == needle) + .find(|(_, value)| matches(value)) .map(|(key, _)| php_key_to_mixed(key)), _ => None, } diff --git a/crates/shirabe-php-shim/src/datetime.rs b/crates/shirabe-php-shim/src/datetime.rs index bddf5da..e41028e 100644 --- a/crates/shirabe-php-shim/src/datetime.rs +++ b/crates/shirabe-php-shim/src/datetime.rs @@ -19,6 +19,7 @@ pub const DATE_ATOM: &str = DATE_RFC3339; pub fn date_format_to_strftime(format: &str) -> &'static str { match format { "Y-m-d H:i:s" => "%Y-%m-%d %H:%M:%S", + "Y-m-d Hi" => "%Y-%m-%d %H%M", "Y-m-d" => "%Y-%m-%d", "Ymd" => "%Y%m%d", other => panic!("Unsupported PHP date format: {other:?}"), diff --git a/crates/shirabe/src/repository/vcs/github_driver.rs b/crates/shirabe/src/repository/vcs/github_driver.rs index bea7294..7c50a7e 100644 --- a/crates/shirabe/src/repository/vcs/github_driver.rs +++ b/crates/shirabe/src/repository/vcs/github_driver.rs @@ -1298,6 +1298,18 @@ impl GitHubDriver { None } + + /// For testing only. Mirrors the `setAttribute($driver, 'tags', ...)` reflection + /// helper used by GitHubDriverTest to seed private state. + pub fn __set_tags(&mut self, tags: Option<IndexMap<String, String>>) { + self.tags = tags; + } + + /// For testing only. Mirrors the `setAttribute($driver, 'branches', ...)` reflection + /// helper used by GitHubDriverTest to seed private state. + pub fn __set_branches(&mut self, branches: Option<IndexMap<String, String>>) { + self.branches = branches; + } } impl crate::repository::vcs::VcsDriverInterface for GitHubDriver { diff --git a/crates/shirabe/src/repository/vcs/gitlab_driver.rs b/crates/shirabe/src/repository/vcs/gitlab_driver.rs index f1f361e..7e8073d 100644 --- a/crates/shirabe/src/repository/vcs/gitlab_driver.rs +++ b/crates/shirabe/src/repository/vcs/gitlab_driver.rs @@ -238,6 +238,18 @@ impl GitLabDriver { Ok(()) } + /// For testing only. Mirrors the `setAttribute($driver, 'branches', ...)` reflection + /// helper used by GitLabDriverTest to seed private state. + pub fn __set_branches(&mut self, branches: Option<IndexMap<String, String>>) { + self.branches = branches; + } + + /// For testing only. Mirrors the `setAttribute($driver, 'tags', ...)` reflection + /// helper used by GitLabDriverTest to seed private state. + pub fn __set_tags(&mut self, tags: Option<IndexMap<String, String>>) { + self.tags = tags; + } + /// Updates the HttpDownloader instance. /// Mainly useful for tests. /// diff --git a/crates/shirabe/src/util/git.rs b/crates/shirabe/src/util/git.rs index f031157..142d5e2 100644 --- a/crates/shirabe/src/util/git.rs +++ b/crates/shirabe/src/util/git.rs @@ -132,6 +132,19 @@ impl Git { self.run_command(callables, url, cwd, initial_clone, command_output) } + /// For testing only. Public seam over the (deprecated) private `run_command`, + /// mirroring `Git::runCommand` as exercised by `GitTest`. + pub fn __run_command( + &mut self, + command_callable: Vec<Box<dyn Fn(&str) -> Vec<String>>>, + url: &str, + cwd: Option<&str>, + initial_clone: bool, + command_output: Option<&mut PhpMixed>, + ) -> Result<()> { + self.run_command(command_callable, url, cwd, initial_clone, command_output) + } + /// @param callable|array<callable> $commandCallable /// @param mixed $commandOutput the output will be written into this var if passed by ref /// if a callable is passed it will be used as output handler @@ -1310,6 +1323,22 @@ impl Git { version.clone().unwrap_or(None) } + /// For testing only. Resets the cached git `version` static so the next + /// `get_version` call re-runs `git --version`, mirroring the + /// `ReflectionProperty(GitUtil::class, 'version')->setValue(null, false)` + /// done in VersionGuesserTest's setUp/tearDown. + pub fn __reset_version() { + *VERSION.lock().unwrap() = None; + } + + /// For testing only. Seeds the cached git `version` static, mirroring the + /// `ReflectionProperty(GitUtil::class, 'version')->setValue(null, $version)` + /// done in GitDownloaderTest's `initGitVersion`. `Some(v)` records a detected + /// version; `None` records that git is unavailable, both without shelling out. + pub fn __set_version(version: Option<String>) { + *VERSION.lock().unwrap() = Some(version); + } + /// @param string[] $credentials fn mask_credentials(&self, error: &str, credentials: &[String]) -> String { let mut masked_credentials: Vec<String> = vec![]; diff --git a/crates/shirabe/src/util/http/response.rs b/crates/shirabe/src/util/http/response.rs index e1da15d..8b7bd34 100644 --- a/crates/shirabe/src/util/http/response.rs +++ b/crates/shirabe/src/util/http/response.rs @@ -63,7 +63,7 @@ impl Response { pub fn find_header_value(headers: &[String], name: &str) -> Option<String> { let mut value = None; - let pattern = format!("(?i)^{}:\\s*(.+?)\\s*$", preg_quote(name, None)); + let pattern = format!("{{^{}:\\s*(.+?)\\s*$}}i", preg_quote(name, None)); for header in headers { let mut matches: indexmap::IndexMap< shirabe_external_packages::composer::pcre::CaptureKey, diff --git a/crates/shirabe/src/util/http_downloader.rs b/crates/shirabe/src/util/http_downloader.rs index edc84cc..782f04a 100644 --- a/crates/shirabe/src/util/http_downloader.rs +++ b/crates/shirabe/src/util/http_downloader.rs @@ -883,6 +883,30 @@ impl HttpDownloader { && function_exists("curl_multi_init") } + /// For testing only. Builds an HttpDownloader whose request methods are fully + /// short-circuited by the mock (see [`HttpDownloader::__expects`]), without + /// constructing the real curl/rfs backends. Mirrors HttpDownloaderMock, which + /// extends HttpDownloader but never performs curl I/O. + pub fn __new_mock( + io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>, + config: std::rc::Rc<std::cell::RefCell<Config>>, + ) -> Self { + Self { + io, + config, + jobs: IndexMap::new(), + options: IndexMap::new(), + running_jobs: 0, + max_jobs: 12, + curl: None, + rfs: None, + id_gen: 0, + disabled: false, + allow_async: false, + mock: None, + } + } + /// For testing only. Mirrors HttpDownloaderMock::expects: installs the expectation queue, /// strict flag and default handler used by the mock request path. pub fn __expects( diff --git a/crates/shirabe/src/util/url.rs b/crates/shirabe/src/util/url.rs index 534e318..5820539 100644 --- a/crates/shirabe/src/util/url.rs +++ b/crates/shirabe/src/util/url.rs @@ -164,10 +164,10 @@ impl Url { pub fn sanitize(url: String) -> String { // GitHub repository rename result in redirect locations containing the access_token as GET parameter // e.g. https://api.github.com/repositories/9999999999?access_token=github_token - let url = Preg::replace(r"([&?]access_token=)[^&]+", "$1***", &url); + let url = Preg::replace(r"{([&?]access_token=)[^&]+}", "$1***", &url); Preg::replace_callback( - r"(?i)^(?P<prefix>[a-z0-9]+://)?(?P<user>[^:/\s@]+):(?P<password>[^@\s/]+)@", + r"{^(?P<prefix>[a-z0-9]+://)?(?P<user>[^:/\s@]+):(?P<password>[^@\s/]+)@}i", |m| { let user = m .get(&CaptureKey::ByName("user".to_string())) diff --git a/crates/shirabe/tests/common/http_downloader_mock.rs b/crates/shirabe/tests/common/http_downloader_mock.rs index 249585c..7a021f9 100644 --- a/crates/shirabe/tests/common/http_downloader_mock.rs +++ b/crates/shirabe/tests/common/http_downloader_mock.rs @@ -64,12 +64,7 @@ pub fn get_http_downloader_mock( ) -> (Rc<RefCell<HttpDownloader>>, HttpDownloaderMockGuard) { let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(NullIO::new())); let config = Rc::new(RefCell::new(Config::new(false, None))); - let downloader = Rc::new(RefCell::new(HttpDownloader::new( - io, - config, - IndexMap::new(), - false, - ))); + let downloader = Rc::new(RefCell::new(HttpDownloader::__new_mock(io, config))); downloader .borrow_mut() .__expects(expectations, strict, default_handler); diff --git a/crates/shirabe/tests/downloader/git_downloader_test.rs b/crates/shirabe/tests/downloader/git_downloader_test.rs index 69b0a6c..07dec08 100644 --- a/crates/shirabe/tests/downloader/git_downloader_test.rs +++ b/crates/shirabe/tests/downloader/git_downloader_test.rs @@ -1,18 +1,40 @@ //! ref: composer/tests/Composer/Test/Downloader/GitDownloaderTest.php +use std::cell::RefCell; +use std::rc::Rc; + +use indexmap::IndexMap; +use serial_test::serial; +use shirabe::config::Config; +use shirabe::downloader::VcsDownloader; +use shirabe::downloader::git_downloader::GitDownloader; +use shirabe::io::IOInterface; +use shirabe::package::handle::{CompletePackageHandle, PackageInterfaceHandle}; +use shirabe::util::Git as GitUtil; +use shirabe::util::ProcessExecutor; use shirabe::util::filesystem::Filesystem; +use shirabe_php_shim::PhpMixed; +use shirabe_semver::VersionParser; use tempfile::TempDir; +use crate::config_stub::ConfigStubBuilder; +use crate::io_stub::IOStub; +use crate::process_executor_mock::{cmd, cmd_full, get_process_executor_mock}; + +fn run<F: std::future::Future>(future: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(future) +} + fn set_up() -> TempDir { - // skipIfNotExecutable('git') - let () = todo!(); - // initGitVersion('1.0.0') resets the Composer\Util\Git static version cache via - // reflection; the static cache is not reachable from a test here. - #[allow(unreachable_code)] - { - let _fs = Filesystem::new(None); - TempDir::new().unwrap() - } + // skipIfNotExecutable('git') is irrelevant because every git invocation is mocked. + + // initGitVersion('1.0.0'): seed the Composer\Util\Git static version cache. + GitUtil::__set_version(Some("1.0.0".to_string())); + + TempDir::new().unwrap() } fn tear_down(working_dir: &std::path::Path) { @@ -20,9 +42,8 @@ fn tear_down(working_dir: &std::path::Path) { let mut fs = Filesystem::new(None); fs.remove_directory(working_dir).unwrap(); } - // initGitVersion(false) resets the Composer\Util\Git static version cache via - // reflection; the static cache is not reachable from a test here. - todo!() + // initGitVersion(false): drop the static version cache so it is re-detected next time. + GitUtil::__set_version(None); } struct TearDown { @@ -41,37 +62,280 @@ impl Drop for TearDown { } } -// These construct a GitDownloader with a mocked IO/Config and a mocked ProcessExecutor to -// feed git command output; mocking is not available, and a real HttpDownloader reaches -// curl_multi_init (todo!()). -#[ignore = "requires getProcessExecutorMock/getIOMock and getMockBuilder PackageInterface mock; no ProcessExecutorMock/IOMock mocking infrastructure exists"] +/// ref: TestCase::getMockBuilder('Composer\Package\PackageInterface')->getMock() +/// +/// A `getMockBuilder(PackageInterface)` mock returns null/0 for every unstubbed +/// method, so a real CompletePackage seeded with the stubbed values is a faithful +/// stand-in only when the resulting `getSourceUrls()` equals `[getSourceUrl()]`. +/// The multi-URL/mirror cases in PHP configure `getSourceUrls` to an arbitrary +/// list that a real package cannot reproduce, so those tests stay ignored. +fn get_package( + name: &str, + pretty_version: &str, + source_reference: Option<&str>, + source_url: Option<&str>, +) -> PackageInterfaceHandle { + let norm_version = VersionParser.normalize(pretty_version, None).unwrap(); + let package = + CompletePackageHandle::new(name.to_string(), norm_version, pretty_version.to_string()); + package.__set_source_type(Some("git".to_string())); + package.set_source_reference(source_reference.map(|s| s.to_string())); + package.set_source_url(source_url.map(|s| s.to_string())); + package.into() +} + +/// ref: GitDownloaderTest::setupConfig (seeds a temp `home` when none is set) +fn setup_config(config: Config) -> Config { + let mut config = config; + if !config.has("home") { + let tmp_dir = TempDir::new().unwrap().keep(); + let mut top: IndexMap<String, PhpMixed> = IndexMap::new(); + let mut section: IndexMap<String, PhpMixed> = IndexMap::new(); + section.insert( + "home".to_string(), + PhpMixed::String(tmp_dir.to_string_lossy().into_owned()), + ); + top.insert("config".to_string(), PhpMixed::Array(section)); + config.merge(&top, Config::SOURCE_UNKNOWN); + } + config +} + +/// ref: GitDownloaderTest::getDownloaderMock (defaults the IO/Config/Filesystem) +fn get_downloader_mock( + io: Option<Rc<RefCell<dyn IOInterface>>>, + config: Option<Config>, + process: Rc<RefCell<ProcessExecutor>>, + filesystem: Option<Rc<RefCell<Filesystem>>>, +) -> GitDownloader { + let io = + io.unwrap_or_else(|| Rc::new(RefCell::new(IOStub::new())) as Rc<RefCell<dyn IOInterface>>); + let config = Rc::new(RefCell::new(setup_config( + config.unwrap_or_else(|| ConfigStubBuilder::new().build()), + ))); + GitDownloader::new(io, config, Some(process), filesystem) +} + +#[serial] #[test] fn test_download_for_package_without_source_reference() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); - let _ = &working_dir; - todo!() + + let package = get_package("dummy/pkg", "1.0.0", None, None); + + let (process, _guard) = get_process_executor_mock(vec![], false, Default::default()); + let mut downloader = get_downloader_mock(None, None, process, None); + + let result = run(async { + downloader.download(package.clone(), "/path", None).await?; + downloader + .prepare("install", package.clone(), "/path", None) + .await?; + downloader.install(package.clone(), "/path").await?; + downloader.cleanup("install", package, "/path", None).await + }); + + let e = result.expect_err("missing source reference should throw"); + assert!(e.to_string().contains("missing reference information")); } -#[ignore = "requires getProcessExecutorMock with expects() command expectations and getMockBuilder PackageInterface mock; no mocking infrastructure exists"] +#[serial] #[test] fn test_download() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); - let _ = &working_dir; - todo!() + + let url = "https://example.com/composer/composer"; + let package = get_package( + "composer/composer", + "dev-master", + Some("1234567890123456789012345678901234567890"), + Some(url), + ); + + let expected_path = working_dir + .path() + .join("composerPath") + .to_string_lossy() + .into_owned(); + + let (process, _guard) = get_process_executor_mock( + vec![ + cmd(vec![ + "git", + "clone", + "--no-checkout", + "--", + url, + &expected_path, + ]), + cmd(vec!["git", "remote", "add", "composer", "--", url]), + cmd(["git", "fetch", "composer"]), + cmd(vec!["git", "remote", "set-url", "origin", "--", url]), + cmd(vec!["git", "remote", "set-url", "composer", "--", url]), + cmd(["git", "branch", "-r"]), + cmd(["git", "checkout", "master", "--"]), + cmd(vec![ + "git", + "reset", + "--hard", + "1234567890123456789012345678901234567890", + "--", + ]), + ], + true, + Default::default(), + ); + + let mut downloader = get_downloader_mock(None, None, process, None); + + run(async { + downloader + .download(package.clone(), &expected_path, None) + .await + .unwrap(); + downloader + .prepare("install", package.clone(), &expected_path, None) + .await + .unwrap(); + downloader + .install(package.clone(), &expected_path) + .await + .unwrap(); + downloader + .cleanup("install", package, &expected_path, None) + .await + .unwrap(); + }); } -#[ignore = "requires getProcessExecutorMock with expects() command expectations and initGitVersion reflection on Git::version static cache; no mocking infrastructure exists"] +#[serial] #[test] fn test_download_with_cache() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); - let _ = &working_dir; - todo!() + + let url = "https://example.com/composer/composer"; + let package = get_package( + "composer/composer", + "dev-master", + Some("1234567890123456789012345678901234567890"), + Some(url), + ); + + // initGitVersion('2.17.0'): enables the cache (`--dissociate`) code path. + GitUtil::__set_version(Some("2.17.0".to_string())); + + let config = setup_config(Config::new(false, None)); + let cache_vcs_dir = config + .get("cache-vcs-dir") + .as_string() + .unwrap_or("") + .to_string(); + // ref: GitDownloaderTest cachePath = cache-vcs-dir.'/'.preg_replace('{[^a-z0-9.]}i', '-', url).'/' + let sanitized: String = url + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' { + c + } else { + '-' + } + }) + .collect(); + let cache_path = format!("{}/{}/", cache_vcs_dir, sanitized); + + let mut filesystem = Filesystem::new(None); + filesystem.remove_directory(&cache_path).unwrap(); + + let expected_path = working_dir + .path() + .join("composerPath") + .to_string_lossy() + .into_owned(); + + let cache_path_for_callback = cache_path.clone(); + let mut clone_mirror = cmd(vec!["git", "clone", "--mirror", "--", url, &cache_path]); + clone_mirror.callback = Some(Box::new(move || { + std::fs::create_dir_all(&cache_path_for_callback).ok(); + })); + + let (process, _guard) = get_process_executor_mock( + vec![ + clone_mirror, + cmd(["git", "remote", "-v"]), + cmd(vec!["git", "remote", "set-url", "origin", "--", url]), + cmd_full(["git", "rev-parse", "--git-dir"], 0, ".", ""), + cmd(vec![ + "git", + "rev-parse", + "--quiet", + "--verify", + "1234567890123456789012345678901234567890^{commit}", + ]), + cmd(vec![ + "git", + "clone", + "--no-checkout", + &cache_path, + &expected_path, + "--dissociate", + "--reference", + &cache_path, + ]), + cmd(vec!["git", "remote", "set-url", "origin", "--", url]), + cmd(vec!["git", "remote", "add", "composer", "--", url]), + cmd(["git", "branch", "-r"]), + cmd_full(["git", "checkout", "master", "--"], 1, "", ""), + cmd(vec![ + "git", + "checkout", + "-B", + "master", + "composer/master", + "--", + ]), + cmd(vec![ + "git", + "reset", + "--hard", + "1234567890123456789012345678901234567890", + "--", + ]), + ], + true, + Default::default(), + ); + + let mut downloader = get_downloader_mock(None, Some(config), process, None); + + run(async { + downloader + .download(package.clone(), &expected_path, None) + .await + .unwrap(); + downloader + .prepare("install", package.clone(), &expected_path, None) + .await + .unwrap(); + downloader + .install(package.clone(), &expected_path) + .await + .unwrap(); + downloader + .cleanup("install", package, &expected_path, None) + .await + .unwrap(); + }); + + let mut fs = Filesystem::new(None); + fs.remove_directory(&cache_path).ok(); } -#[ignore = "requires getProcessExecutorMock with expects() command expectations and getMockBuilder PackageInterface mock; no mocking infrastructure exists"] +#[ignore = "getSourceUrls returns a mirror list (['https://github.com/mirrors/composer', \ + 'https://github.com/composer/composer']) that a real CompletePackage cannot reproduce \ + from a single source_url; no PackageInterface mock with arbitrary getSourceUrls exists"] #[test] fn test_download_uses_various_protocols_and_sets_push_url_for_github() { let working_dir = set_up(); @@ -80,7 +344,8 @@ fn test_download_uses_various_protocols_and_sets_push_url_for_github() { todo!() } -#[ignore = "requires getProcessExecutorMock with expects() command expectations and pushUrlProvider dataProvider; no mocking infrastructure exists"] +#[ignore = "pushUrlProvider configures getSourceUrls to a list a real CompletePackage cannot \ + reproduce; no PackageInterface mock with arbitrary getSourceUrls exists"] #[test] fn test_download_and_set_push_url_use_custom_various_protocols_for_github() { let working_dir = set_up(); @@ -89,43 +354,243 @@ fn test_download_and_set_push_url_use_custom_various_protocols_for_github() { todo!() } -#[ignore = "requires getProcessExecutorMock with expects() command expectations and getMockBuilder PackageInterface mock; no mocking infrastructure exists"] +#[serial] #[test] fn test_download_throws_runtime_exception_if_git_command_fails() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); - let _ = &working_dir; - todo!() + + let url = "https://example.com/composer/composer"; + let package = get_package("composer/composer", "1.0.0", Some("ref"), Some(url)); + + let expected_path = working_dir + .path() + .join("composerPath") + .to_string_lossy() + .into_owned(); + + let (process, _guard) = get_process_executor_mock( + vec![cmd_full( + vec!["git", "clone", "--no-checkout", "--", url, &expected_path], + 1, + "", + "", + )], + false, + Default::default(), + ); + + let mut downloader = get_downloader_mock(None, None, process, None); + + let result = run(async { + downloader + .download(package.clone(), &expected_path, None) + .await?; + downloader + .prepare("install", package.clone(), &expected_path, None) + .await?; + downloader.install(package.clone(), &expected_path).await?; + downloader + .cleanup("install", package, &expected_path, None) + .await + }); + + let e = result.expect_err("failed git clone should throw"); + assert!(e.to_string().contains(&format!( + "Failed to execute git clone --no-checkout -- {} {}", + url, expected_path + ))); } -#[ignore = "requires getProcessExecutorMock/getDownloaderMock and getMockBuilder PackageInterface mock; no mocking infrastructure exists"] +#[serial] #[test] fn test_updatefor_package_without_source_reference() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); - let _ = &working_dir; - todo!() + + let initial_package = get_package("dummy/pkg", "1.0.0", Some("ref"), None); + let source_package = get_package("dummy/pkg", "1.0.0", None, None); + + let (process, _guard) = get_process_executor_mock(vec![], false, Default::default()); + let mut downloader = get_downloader_mock(None, None, process, None); + + let result = run(async { + downloader + .download( + source_package.clone(), + "/path", + Some(initial_package.clone()), + ) + .await?; + downloader + .prepare( + "update", + source_package.clone(), + "/path", + Some(initial_package.clone()), + ) + .await?; + downloader + .update(initial_package.clone(), source_package.clone(), "/path") + .await?; + downloader + .cleanup("update", source_package, "/path", Some(initial_package)) + .await + }); + + let e = result.expect_err("missing source reference should throw"); + assert!(e.to_string().contains("missing reference information")); } -#[ignore = "requires getProcessExecutorMock with expects() command expectations and getMockBuilder PackageInterface mock; no mocking infrastructure exists"] +#[serial] #[test] fn test_update() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); - let _ = &working_dir; - todo!() + + let url = "https://github.com/composer/composer"; + let package = get_package("composer/composer", "1.0.0", Some("ref"), Some(url)); + + let (process, _guard) = get_process_executor_mock( + vec![ + cmd(["git", "show-ref", "--head", "-d"]), + cmd(["git", "status", "--porcelain", "--untracked-files=no"]), + cmd_full( + ["git", "rev-parse", "--quiet", "--verify", "ref^{commit}"], + 1, + "", + "", + ), + // fallback commands for the above failing + cmd(["git", "remote", "-v"]), + cmd(vec!["git", "remote", "set-url", "composer", "--", url]), + cmd(["git", "fetch", "composer"]), + cmd(["git", "fetch", "--tags", "composer"]), + cmd(["git", "remote", "-v"]), + cmd(vec!["git", "remote", "set-url", "composer", "--", url]), + cmd(["git", "branch", "-r"]), + cmd(["git", "checkout", "ref", "--"]), + cmd(["git", "reset", "--hard", "ref", "--"]), + cmd(["git", "remote", "-v"]), + ], + true, + Default::default(), + ); + + let mut fs = Filesystem::new(None); + fs.ensure_directory_exists(&format!("{}/.git", working_dir.path().to_string_lossy())) + .unwrap(); + let working_dir_str = working_dir.path().to_string_lossy().into_owned(); + + let mut downloader = get_downloader_mock(None, Some(Config::new(false, None)), process, None); + + run(async { + downloader + .download(package.clone(), &working_dir_str, Some(package.clone())) + .await + .unwrap(); + downloader + .prepare( + "update", + package.clone(), + &working_dir_str, + Some(package.clone()), + ) + .await + .unwrap(); + downloader + .update(package.clone(), package.clone(), &working_dir_str) + .await + .unwrap(); + downloader + .cleanup("update", package.clone(), &working_dir_str, Some(package)) + .await + .unwrap(); + }); } -#[ignore = "requires getProcessExecutorMock with expects() command expectations and getMockBuilder PackageInterface mock; no mocking infrastructure exists"] +#[serial] #[test] fn test_update_with_new_repo_url() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); - let _ = &working_dir; - todo!() + + let url = "https://github.com/composer/composer"; + let package = get_package("composer/composer", "1.0.0", Some("ref"), Some(url)); + + let (process, _guard) = get_process_executor_mock( + vec![ + cmd(["git", "show-ref", "--head", "-d"]), + cmd(["git", "status", "--porcelain", "--untracked-files=no"]), + cmd_full( + ["git", "rev-parse", "--quiet", "--verify", "ref^{commit}"], + 0, + "", + "", + ), + cmd(["git", "remote", "-v"]), + cmd(vec!["git", "remote", "set-url", "composer", "--", url]), + cmd(["git", "branch", "-r"]), + cmd(["git", "checkout", "ref", "--"]), + cmd(["git", "reset", "--hard", "ref", "--"]), + cmd_full( + ["git", "remote", "-v"], + 0, + "origin https://github.com/old/url (fetch)\n\ + origin https://github.com/old/url (push)\n\ + composer https://github.com/old/url (fetch)\n\ + composer https://github.com/old/url (push)\n", + "", + ), + cmd(vec!["git", "remote", "set-url", "origin", "--", url]), + cmd(vec![ + "git", + "remote", + "set-url", + "--push", + "origin", + "--", + "git@github.com:composer/composer.git", + ]), + ], + true, + Default::default(), + ); + + let mut fs = Filesystem::new(None); + fs.ensure_directory_exists(&format!("{}/.git", working_dir.path().to_string_lossy())) + .unwrap(); + let working_dir_str = working_dir.path().to_string_lossy().into_owned(); + + let mut downloader = get_downloader_mock(None, Some(Config::new(false, None)), process, None); + + run(async { + downloader + .download(package.clone(), &working_dir_str, Some(package.clone())) + .await + .unwrap(); + downloader + .prepare( + "update", + package.clone(), + &working_dir_str, + Some(package.clone()), + ) + .await + .unwrap(); + downloader + .update(package.clone(), package.clone(), &working_dir_str) + .await + .unwrap(); + downloader + .cleanup("update", package.clone(), &working_dir_str, Some(package)) + .await + .unwrap(); + }); } -#[ignore = "requires getProcessExecutorMock with expects() command expectations and getMockBuilder PackageInterface mock; no mocking infrastructure exists"] +#[ignore = "getSourceUrls returns a multi-URL list a real CompletePackage cannot reproduce; \ + no PackageInterface mock with arbitrary getSourceUrls exists"] #[test] fn test_update_throws_runtime_exception_if_git_command_fails() { let working_dir = set_up(); @@ -134,7 +599,8 @@ fn test_update_throws_runtime_exception_if_git_command_fails() { todo!() } -#[ignore = "requires getProcessExecutorMock with expects() command expectations and getMockBuilder PackageInterface mock; no mocking infrastructure exists"] +#[ignore = "getSourceUrls returns a multi-URL list (['/' , github]) a real CompletePackage cannot \ + reproduce; no PackageInterface mock with arbitrary getSourceUrls exists"] #[test] fn test_update_doesnt_throws_runtime_exception_if_git_command_fails_at_first_but_is_able_to_recover() { @@ -144,7 +610,8 @@ fn test_update_doesnt_throws_runtime_exception_if_git_command_fails_at_first_but todo!() } -#[ignore = "requires getIOMock with expects() output expectations, getProcessExecutorMock and getMockBuilder PackageInterface mock; no mocking infrastructure exists"] +#[ignore = "getSourceUrls returns a multi-URL list (['/foo/bar', github]) a real CompletePackage \ + cannot reproduce; no PackageInterface mock with arbitrary getSourceUrls exists"] #[test] fn test_downgrade_shows_appropriate_message() { let working_dir = set_up(); @@ -153,7 +620,8 @@ fn test_downgrade_shows_appropriate_message() { todo!() } -#[ignore = "requires getIOMock with expects() output expectations, getProcessExecutorMock and getMockBuilder PackageInterface mock; no mocking infrastructure exists"] +#[ignore = "getSourceUrls returns a multi-URL list (['/foo/bar', github]) a real CompletePackage \ + cannot reproduce; no PackageInterface mock with arbitrary getSourceUrls exists"] #[test] fn test_not_using_downgrading_with_references() { let working_dir = set_up(); @@ -162,7 +630,10 @@ fn test_not_using_downgrading_with_references() { todo!() } -#[ignore = "requires getProcessExecutorMock, getMockBuilder Filesystem mock with removeDirectoryAsync expectation and PackageInterface mock; no mocking infrastructure exists"] +#[ignore = "PHP mocks Filesystem::removeDirectoryAsync (asserting it is called once with the \ + working dir). With no Filesystem mock, the real removeDirectoryAsync drives the \ + Filesystem's own ProcessExecutor for `rm -rf`, which requires a Composer\\Loop and \ + cannot be redirected through the mocked ProcessExecutor"] #[test] fn test_remove() { let working_dir = set_up(); @@ -171,11 +642,15 @@ fn test_remove() { todo!() } -#[ignore = "requires getDownloaderMock which mocks IOInterface/Filesystem via getMockBuilder; no mocking infrastructure exists"] +#[serial] #[test] fn test_get_installation_source() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); let _ = &working_dir; - todo!() + + let (process, _guard) = get_process_executor_mock(vec![], false, Default::default()); + let downloader = get_downloader_mock(None, None, process, None); + + assert_eq!("source", downloader.get_installation_source()); } diff --git a/crates/shirabe/tests/downloader/main.rs b/crates/shirabe/tests/downloader/main.rs index a2bbab4..8244010 100644 --- a/crates/shirabe/tests/downloader/main.rs +++ b/crates/shirabe/tests/downloader/main.rs @@ -2,8 +2,12 @@ mod config_stub; #[path = "../common/http_downloader_mock.rs"] mod http_downloader_mock; +#[path = "../common/io_mock.rs"] +mod io_mock; #[path = "../common/io_stub.rs"] mod io_stub; +#[path = "../common/process_executor_mock.rs"] +mod process_executor_mock; mod archive_downloader_test; mod download_manager_test; diff --git a/crates/shirabe/tests/package/main.rs b/crates/shirabe/tests/package/main.rs index 57d7cd3..7d841b4 100644 --- a/crates/shirabe/tests/package/main.rs +++ b/crates/shirabe/tests/package/main.rs @@ -1,3 +1,9 @@ +#[path = "../common/config_stub.rs"] +mod config_stub; +#[path = "../common/io_stub.rs"] +mod io_stub; +#[path = "../common/process_executor_mock.rs"] +mod process_executor_mock; #[path = "../common/test_case.rs"] mod test_case; diff --git a/crates/shirabe/tests/package/version/version_guesser_test.rs b/crates/shirabe/tests/package/version/version_guesser_test.rs index 1ec0ed4..6bfe6c1 100644 --- a/crates/shirabe/tests/package/version/version_guesser_test.rs +++ b/crates/shirabe/tests/package/version/version_guesser_test.rs @@ -3,121 +3,608 @@ use std::cell::RefCell; use std::rc::Rc; +use indexmap::IndexMap; +use serial_test::serial; use shirabe::config::Config; use shirabe::package::version::{VersionGuesser, VersionParser}; +use shirabe::util::Git as GitUtil; use shirabe::util::platform::Platform; -use shirabe::util::process_executor::ProcessExecutor; +use shirabe::util::process_executor::{MockExpectation, MockHandler, ProcessExecutor}; +use shirabe_php_shim::PhpMixed; -#[allow(dead_code)] -fn set_up() { - // Resets GitUtil's cached `version` static via ReflectionProperty; the static is not - // exposed here and reflection-based mutation has no ported equivalent. - todo!() -} +use crate::process_executor_mock::{cmd, cmd_full, get_process_executor_mock}; -#[allow(dead_code)] -fn tear_down() { - todo!() +// Mirrors VersionGuesserTest::setUp/tearDown: reset GitUtil's cached `version` +// static so each test re-runs `git --version` against its own mock. +fn set_up() { + GitUtil::__reset_version(); } -#[allow(dead_code)] struct TearDown; impl Drop for TearDown { fn drop(&mut self) { - tear_down(); + GitUtil::__reset_version(); } } -// These drive VersionGuesser with a mocked ProcessExecutor feeding git/hg command output; -// mocking is not available here. -#[ignore = "requires getProcessExecutorMock with expects() command expectations; no ProcessExecutorMock mocking infrastructure exists"] +// `$config = new Config; $config->merge(['repositories' => ['packagist' => false]]);` +fn make_config() -> Rc<RefCell<Config>> { + let mut config = Config::new(true, None); + let mut repositories: IndexMap<String, PhpMixed> = IndexMap::new(); + repositories.insert("packagist".to_string(), PhpMixed::Bool(false)); + let mut merge: IndexMap<String, PhpMixed> = IndexMap::new(); + merge.insert("repositories".to_string(), PhpMixed::Array(repositories)); + config.merge(&merge, Config::SOURCE_UNKNOWN); + Rc::new(RefCell::new(config)) +} + +fn make_guesser( + config: Rc<RefCell<Config>>, + process: Rc<RefCell<ProcessExecutor>>, +) -> VersionGuesser { + VersionGuesser::new(config, process, VersionParser::new(), None) +} + +// Helper to build a `['key' => 'value']` package config used in the tests. +fn package_config(entries: &[(&str, PhpMixed)]) -> IndexMap<String, PhpMixed> { + let mut map: IndexMap<String, PhpMixed> = IndexMap::new(); + for (k, v) in entries { + map.insert(k.to_string(), v.clone()); + } + map +} + +fn string_list(items: &[&str]) -> PhpMixed { + PhpMixed::List( + items + .iter() + .map(|s| PhpMixed::String(s.to_string())) + .collect(), + ) +} + +#[ignore = "guess_hg_version builds an HttpDownloader for HgDriver, which calls curl_multi_init() (todo!() in shirabe-php-shim::curl)"] #[test] +#[serial] fn test_hg_guess_version_returns_data() { - todo!() + set_up(); + let _td = TearDown; + let branch = "default"; + + let expectations: Vec<MockExpectation> = vec![ + cmd_full(["git", "--version"], 0, "git version 2.33.0", ""), + cmd_full( + ["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], + 128, + "", + "", + ), + cmd_full(["git", "describe", "--exact-match", "--tags"], 128, "", ""), + cmd_full( + [ + "git", + "rev-list", + "--no-commit-header", + "--format=%H", + "-n1", + "HEAD", + "--no-show-signature", + ], + 128, + "", + "", + ), + cmd_full(["hg", "branch"], 0, branch, ""), + cmd_full(["hg", "branches"], 0, "", ""), + cmd_full(["hg", "bookmarks"], 0, "", ""), + ]; + let (process, _guard) = get_process_executor_mock(expectations, true, MockHandler::default()); + + let config = make_config(); + let mut guesser = make_guesser(config, process); + let version_data = guesser + .guess_version(&IndexMap::new(), "dummy/path") + .unwrap() + .expect("expected version data"); + + assert_eq!(format!("dev-{}", branch), version_data.version.unwrap()); + assert_eq!( + format!("dev-{}", branch), + version_data.pretty_version.unwrap() + ); + assert!(version_data.commit.as_deref().unwrap_or("").is_empty()); } -#[ignore = "requires getProcessExecutorMock with expects() command expectations; no ProcessExecutorMock mocking infrastructure exists"] #[test] +#[serial] fn test_guess_version_returns_data() { - todo!() + set_up(); + let _td = TearDown; + let commit_hash = "03a15d220da53c52eddd5f32ffca64a7b3801bea"; + let another_commit_hash = "03a15d220da53c52eddd5f32ffca64a7b3801bea"; + + let expectations: Vec<MockExpectation> = vec![ + cmd_full(["git", "--version"], 0, "git version 2.52.0", ""), + cmd_full( + ["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], + 0, + format!( + "* master {} Commit message\n(no branch) {} Commit message\n", + commit_hash, another_commit_hash + ), + "", + ), + ]; + let (process, _guard) = get_process_executor_mock(expectations, true, MockHandler::default()); + + let config = make_config(); + let mut guesser = make_guesser(config, process); + let version_data = guesser + .guess_version(&IndexMap::new(), "dummy/path") + .unwrap() + .expect("expected version data"); + + assert_eq!("dev-master", version_data.version.unwrap()); + assert_eq!("dev-master", version_data.pretty_version.unwrap()); + assert!(version_data.feature_version.is_none()); + assert!(version_data.feature_pretty_version.is_none()); + assert_eq!(commit_hash, version_data.commit.unwrap()); } -#[ignore = "requires getProcessExecutorMock with expects() command expectations; no ProcessExecutorMock mocking infrastructure exists"] #[test] +#[serial] fn test_guess_version_does_not_see_custom_default_branch_as_non_feature_branch() { - todo!() + set_up(); + let _td = TearDown; + let commit_hash = "03a15d220da53c52eddd5f32ffca64a7b3801bea"; + let another_commit_hash = "13a15d220da53c52eddd5f32ffca64a7b3801bea"; + + let expectations: Vec<MockExpectation> = vec![ + cmd_full(["git", "--version"], 0, "git version 2.52.0", ""), + cmd_full( + ["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], + 0, + // Assumption here is that arbitrary would be the default branch + format!( + " arbitrary {} Commit message\n* current {} Another message\n", + commit_hash, another_commit_hash + ), + "", + ), + ]; + let (process, _guard) = get_process_executor_mock(expectations, true, MockHandler::default()); + + let config = make_config(); + let mut guesser = make_guesser(config, process); + let version_data = guesser + .guess_version( + &package_config(&[("version", PhpMixed::String("self.version".to_string()))]), + "dummy/path", + ) + .unwrap() + .expect("expected version data"); + + assert_eq!("dev-current", version_data.version.unwrap()); + assert_eq!(another_commit_hash, version_data.commit.unwrap()); } -#[ignore = "requires getProcessExecutorMock with expects() command expectations; no ProcessExecutorMock mocking infrastructure exists"] +#[ignore = "feature-branch guessing calls ProcessExecutor::execute_async, whose mock path is todo!()"] #[test] +#[serial] fn test_guess_version_reads_and_respects_non_feature_branches_configuration_for_arbitrary_naming() { - todo!() + set_up(); + let _td = TearDown; + let commit_hash = "03a15d220da53c52eddd5f32ffca64a7b3801bea"; + let another_commit_hash = "13a15d220da53c52eddd5f32ffca64a7b3801bea"; + + let expectations: Vec<MockExpectation> = vec![ + cmd_full(["git", "--version"], 0, "git version 2.52.0", ""), + cmd_full( + ["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], + 0, + format!( + " arbitrary {} Commit message\n* feature {} Another message\n", + commit_hash, another_commit_hash + ), + "", + ), + cmd_full( + ["git", "rev-list", "arbitrary..feature"], + 0, + format!("{}\n", another_commit_hash), + "", + ), + ]; + let (process, _guard) = get_process_executor_mock(expectations, true, MockHandler::default()); + + let config = make_config(); + let mut guesser = make_guesser(config, process); + let version_data = guesser + .guess_version( + &package_config(&[ + ("version", PhpMixed::String("self.version".to_string())), + ("non-feature-branches", string_list(&["arbitrary"])), + ]), + "dummy/path", + ) + .unwrap() + .expect("expected version data"); + + assert_eq!("dev-arbitrary", version_data.version.unwrap()); + assert_eq!(another_commit_hash, version_data.commit.unwrap()); + assert_eq!( + "dev-feature", + version_data.feature_version.as_deref().unwrap() + ); + assert_eq!( + "dev-feature", + version_data.feature_pretty_version.as_deref().unwrap() + ); } -#[ignore = "requires getProcessExecutorMock with expects() command expectations; no ProcessExecutorMock mocking infrastructure exists"] +#[ignore = "feature-branch guessing calls ProcessExecutor::execute_async, whose mock path is todo!()"] #[test] +#[serial] fn test_guess_version_reads_and_respects_non_feature_branches_configuration_for_arbitrary_naming_regex() { - todo!() + set_up(); + let _td = TearDown; + let commit_hash = "03a15d220da53c52eddd5f32ffca64a7b3801bea"; + let another_commit_hash = "13a15d220da53c52eddd5f32ffca64a7b3801bea"; + + let expectations: Vec<MockExpectation> = vec![ + cmd_full(["git", "--version"], 0, "git version 2.52.0", ""), + cmd_full( + ["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], + 0, + format!( + " latest-testing {} Commit message\n* feature {} Another message\n", + commit_hash, another_commit_hash + ), + "", + ), + cmd_full( + ["git", "rev-list", "latest-testing..feature"], + 0, + format!("{}\n", another_commit_hash), + "", + ), + ]; + let (process, _guard) = get_process_executor_mock(expectations, true, MockHandler::default()); + + let config = make_config(); + let mut guesser = make_guesser(config, process); + let version_data = guesser + .guess_version( + &package_config(&[ + ("version", PhpMixed::String("self.version".to_string())), + ("non-feature-branches", string_list(&["latest-.*"])), + ]), + "dummy/path", + ) + .unwrap() + .expect("expected version data"); + + assert_eq!("dev-latest-testing", version_data.version.unwrap()); + assert_eq!(another_commit_hash, version_data.commit.unwrap()); + assert_eq!( + "dev-feature", + version_data.feature_version.as_deref().unwrap() + ); + assert_eq!( + "dev-feature", + version_data.feature_pretty_version.as_deref().unwrap() + ); } -#[ignore = "requires getProcessExecutorMock with expects() command expectations; no ProcessExecutorMock mocking infrastructure exists"] #[test] +#[serial] fn test_guess_version_reads_and_respects_non_feature_branches_configuration_for_arbitrary_naming_when_on_non_feature_branch() { - todo!() + set_up(); + let _td = TearDown; + let commit_hash = "03a15d220da53c52eddd5f32ffca64a7b3801bea"; + let another_commit_hash = "13a15d220da53c52eddd5f32ffca64a7b3801bea"; + + let expectations: Vec<MockExpectation> = vec![ + cmd_full(["git", "--version"], 0, "git version 2.52.0", ""), + cmd_full( + ["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], + 0, + format!( + "* latest-testing {} Commit message\n current {} Another message\n master {} Another message\n", + commit_hash, another_commit_hash, another_commit_hash + ), + "", + ), + ]; + let (process, _guard) = get_process_executor_mock(expectations, true, MockHandler::default()); + + let config = make_config(); + let mut guesser = make_guesser(config, process); + let version_data = guesser + .guess_version( + &package_config(&[ + ("version", PhpMixed::String("self.version".to_string())), + ("non-feature-branches", string_list(&["latest-.*"])), + ]), + "dummy/path", + ) + .unwrap() + .expect("expected version data"); + + assert_eq!("dev-latest-testing", version_data.version.unwrap()); + assert_eq!(commit_hash, version_data.commit.unwrap()); + assert!(version_data.feature_version.is_none()); + assert!(version_data.feature_pretty_version.is_none()); } -#[ignore = "requires getProcessExecutorMock with expects() command expectations; no ProcessExecutorMock mocking infrastructure exists"] #[test] +#[serial] fn test_detached_head_becomes_dev_hash() { - todo!() + set_up(); + let _td = TearDown; + let commit_hash = "03a15d220da53c52eddd5f32ffca64a7b3801bea"; + + let expectations: Vec<MockExpectation> = vec![ + cmd_full(["git", "--version"], 0, "git version 2.52.0", ""), + cmd_full( + ["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], + 0, + format!("* (no branch) {} Commit message\n", commit_hash), + "", + ), + cmd(["git", "describe", "--exact-match", "--tags"]), + ]; + let (process, _guard) = get_process_executor_mock(expectations, true, MockHandler::default()); + + let config = make_config(); + let mut guesser = make_guesser(config, process); + let version_data = guesser + .guess_version(&IndexMap::new(), "dummy/path") + .unwrap() + .expect("expected version data"); + + assert_eq!( + format!("dev-{}", commit_hash), + version_data.version.unwrap() + ); } -#[ignore = "requires getProcessExecutorMock with expects() command expectations; no ProcessExecutorMock mocking infrastructure exists"] #[test] +#[serial] fn test_detached_fetch_head_becomes_dev_hash_git2() { - todo!() + set_up(); + let _td = TearDown; + let commit_hash = "03a15d220da53c52eddd5f32ffca64a7b3801bea"; + + let expectations: Vec<MockExpectation> = vec![ + cmd_full(["git", "--version"], 0, "git version 2.52.0", ""), + cmd_full( + ["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], + 0, + format!( + "* (HEAD detached at FETCH_HEAD) {} Commit message\n", + commit_hash + ), + "", + ), + cmd(["git", "describe", "--exact-match", "--tags"]), + ]; + let (process, _guard) = get_process_executor_mock(expectations, true, MockHandler::default()); + + let config = make_config(); + let mut guesser = make_guesser(config, process); + let version_data = guesser + .guess_version(&IndexMap::new(), "dummy/path") + .unwrap() + .expect("expected version data"); + + assert_eq!( + format!("dev-{}", commit_hash), + version_data.version.unwrap() + ); } -#[ignore = "requires getProcessExecutorMock with expects() command expectations; no ProcessExecutorMock mocking infrastructure exists"] #[test] +#[serial] fn test_detached_commit_head_becomes_dev_hash_git2() { - todo!() + set_up(); + let _td = TearDown; + let commit_hash = "03a15d220da53c52eddd5f32ffca64a7b3801bea"; + + let expectations: Vec<MockExpectation> = vec![ + cmd_full(["git", "--version"], 0, "git version 2.52.0", ""), + cmd_full( + ["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], + 0, + format!( + "* (HEAD detached at 03a15d220) {} Commit message\n", + commit_hash + ), + "", + ), + cmd(["git", "describe", "--exact-match", "--tags"]), + ]; + let (process, _guard) = get_process_executor_mock(expectations, true, MockHandler::default()); + + let config = make_config(); + let mut guesser = make_guesser(config, process); + let version_data = guesser + .guess_version(&IndexMap::new(), "dummy/path") + .unwrap() + .expect("expected version data"); + + assert_eq!( + format!("dev-{}", commit_hash), + version_data.version.unwrap() + ); } -#[ignore = "requires getProcessExecutorMock with expects() command expectations; no ProcessExecutorMock mocking infrastructure exists"] #[test] +#[serial] fn test_tag_becomes_version() { - todo!() + set_up(); + let _td = TearDown; + + let expectations: Vec<MockExpectation> = vec![ + cmd_full(["git", "--version"], 0, "git version 2.52.0", ""), + cmd_full( + ["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], + 0, + "* (HEAD detached at v2.0.5-alpha2) 433b98d4218c181bae01865901aac045585e8a1a Commit message\n", + "", + ), + cmd_full( + ["git", "describe", "--exact-match", "--tags"], + 0, + "v2.0.5-alpha2", + "", + ), + ]; + let (process, _guard) = get_process_executor_mock(expectations, true, MockHandler::default()); + + let config = make_config(); + let mut guesser = make_guesser(config, process); + let version_data = guesser + .guess_version(&IndexMap::new(), "dummy/path") + .unwrap() + .expect("expected version data"); + + assert_eq!("2.0.5.0-alpha2", version_data.version.unwrap()); } -#[ignore = "requires getProcessExecutorMock with expects() command expectations; no ProcessExecutorMock mocking infrastructure exists"] #[test] +#[serial] fn test_tag_becomes_pretty_version() { - todo!() + set_up(); + let _td = TearDown; + + let expectations: Vec<MockExpectation> = vec![ + cmd_full(["git", "--version"], 0, "git version 2.52.0", ""), + cmd_full( + ["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], + 0, + "* (HEAD detached at 1.0.0) c006f0c12bbbf197b5c071ffb1c0e9812bb14a4d Commit message\n", + "", + ), + cmd_full( + ["git", "describe", "--exact-match", "--tags"], + 0, + "1.0.0", + "", + ), + ]; + let (process, _guard) = get_process_executor_mock(expectations, true, MockHandler::default()); + + let config = make_config(); + let mut guesser = make_guesser(config, process); + let version_data = guesser + .guess_version(&IndexMap::new(), "dummy/path") + .unwrap() + .expect("expected version data"); + + assert_eq!("1.0.0.0", version_data.version.unwrap()); + assert_eq!("1.0.0", version_data.pretty_version.unwrap()); } -#[ignore = "requires getProcessExecutorMock with expects() command expectations; no ProcessExecutorMock mocking infrastructure exists"] #[test] +#[serial] fn test_invalid_tag_becomes_version() { - todo!() + set_up(); + let _td = TearDown; + + let expectations: Vec<MockExpectation> = vec![ + cmd_full(["git", "--version"], 0, "git version 2.52.0", ""), + cmd_full( + ["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], + 0, + "* foo 03a15d220da53c52eddd5f32ffca64a7b3801bea Commit message\n", + "", + ), + ]; + let (process, _guard) = get_process_executor_mock(expectations, true, MockHandler::default()); + + let config = make_config(); + let mut guesser = make_guesser(config, process); + let version_data = guesser + .guess_version(&IndexMap::new(), "dummy/path") + .unwrap() + .expect("expected version data"); + + assert_eq!("dev-foo", version_data.version.unwrap()); } -#[ignore = "requires getProcessExecutorMock with expects() command expectations; no ProcessExecutorMock mocking infrastructure exists"] #[test] +#[serial] fn test_numeric_branches_show_nicely() { - todo!() + set_up(); + let _td = TearDown; + + let expectations: Vec<MockExpectation> = vec![ + cmd_full(["git", "--version"], 0, "git version 2.52.0", ""), + cmd_full( + ["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], + 0, + "* 1.5 03a15d220da53c52eddd5f32ffca64a7b3801bea Commit message\n", + "", + ), + ]; + let (process, _guard) = get_process_executor_mock(expectations, true, MockHandler::default()); + + let config = make_config(); + let mut guesser = make_guesser(config, process); + let version_data = guesser + .guess_version(&IndexMap::new(), "dummy/path") + .unwrap() + .expect("expected version data"); + + assert_eq!("1.5.x-dev", version_data.pretty_version.unwrap()); + assert_eq!("1.5.9999999.9999999-dev", version_data.version.unwrap()); } -#[ignore = "requires getProcessExecutorMock with expects() command expectations; no ProcessExecutorMock mocking infrastructure exists"] +#[ignore = "remote-branch feature guessing calls ProcessExecutor::execute_async, whose mock path is todo!()"] #[test] +#[serial] fn test_remote_branches_are_selected() { - todo!() + set_up(); + let _td = TearDown; + + let expectations: Vec<MockExpectation> = vec![ + cmd_full(["git", "--version"], 0, "git version 2.52.0", ""), + cmd_full( + ["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], + 0, + "* feature-branch 03a15d220da53c52eddd5f32ffca64a7b3801bea Commit message\n\ + remotes/origin/1.5 03a15d220da53c52eddd5f32ffca64a7b3801bea Commit message\n", + "", + ), + cmd_full( + ["git", "rev-list", "remotes/origin/1.5..feature-branch"], + 0, + "\n", + "", + ), + ]; + let (process, _guard) = get_process_executor_mock(expectations, true, MockHandler::default()); + + let config = make_config(); + let mut guesser = make_guesser(config, process); + let version_data = guesser + .guess_version( + &package_config(&[("version", PhpMixed::String("self.version".to_string()))]), + "dummy/path", + ) + .unwrap() + .expect("expected version data"); + + assert_eq!("1.5.x-dev", version_data.pretty_version.unwrap()); + assert_eq!("1.5.9999999.9999999-dev", version_data.version.unwrap()); } #[test] +#[serial] fn test_get_root_version_from_env() { // @dataProvider rootEnvVersionsProvider let root_env_versions: Vec<(&str, &str)> = vec![ diff --git a/crates/shirabe/tests/repository/main.rs b/crates/shirabe/tests/repository/main.rs index 15a42a3..f5ecbc6 100644 --- a/crates/shirabe/tests/repository/main.rs +++ b/crates/shirabe/tests/repository/main.rs @@ -4,6 +4,8 @@ mod config_stub; mod http_downloader_mock; #[path = "../common/io_stub.rs"] mod io_stub; +#[path = "../common/process_executor_mock.rs"] +mod process_executor_mock; #[path = "../common/test_case.rs"] mod test_case; diff --git a/crates/shirabe/tests/repository/vcs/github_driver_test.rs b/crates/shirabe/tests/repository/vcs/github_driver_test.rs index f5958a4..0469edf 100644 --- a/crates/shirabe/tests/repository/vcs/github_driver_test.rs +++ b/crates/shirabe/tests/repository/vcs/github_driver_test.rs @@ -5,13 +5,22 @@ use std::rc::Rc; use indexmap::IndexMap; use shirabe::config::Config; +use shirabe::config::ConfigSourceInterface; use shirabe::io::IOInterface; use shirabe::io::null_io::NullIO; use shirabe::repository::vcs::GitHubDriver; use shirabe::util::filesystem::Filesystem; +use shirabe::util::http_downloader::HttpDownloaderMockHandler; +use shirabe::util::process_executor::{MockHandler, ProcessExecutor}; use shirabe_php_shim::PhpMixed; use tempfile::TempDir; +use crate::http_downloader_mock::{HttpDownloaderMockGuard, expect_full, get_http_downloader_mock}; +use crate::io_stub::IOStub; +use crate::process_executor_mock::{ + ProcessExecutorMockGuard, cmd, cmd_full, get_process_executor_mock, +}; + struct SetUp { home: TempDir, config: Config, @@ -53,6 +62,82 @@ impl Drop for TearDown { } } +// No-op ConfigSourceInterface, equivalent to PHPUnit's +// `getMockBuilder(ConfigSourceInterface::class)->getMock()` whose methods all do +// nothing. Used by testPrivateRepository to satisfy the OAuth token-storing path. +#[derive(Debug)] +struct NullConfigSource; + +impl ConfigSourceInterface for NullConfigSource { + fn add_repository( + &mut self, + _name: &str, + _config: PhpMixed, + _append: bool, + ) -> anyhow::Result<()> { + Ok(()) + } + fn insert_repository( + &mut self, + _name: &str, + _config: PhpMixed, + _reference_name: &str, + _offset: i64, + ) -> anyhow::Result<()> { + Ok(()) + } + fn set_repository_url(&mut self, _name: &str, _url: &str) -> anyhow::Result<()> { + Ok(()) + } + fn remove_repository(&mut self, _name: &str) -> anyhow::Result<()> { + Ok(()) + } + fn add_config_setting(&mut self, _name: &str, _value: PhpMixed) -> anyhow::Result<()> { + Ok(()) + } + fn remove_config_setting(&mut self, _name: &str) -> anyhow::Result<()> { + Ok(()) + } + fn add_property(&mut self, _name: &str, _value: PhpMixed) -> anyhow::Result<()> { + Ok(()) + } + fn remove_property(&mut self, _name: &str) -> anyhow::Result<()> { + Ok(()) + } + fn add_link(&mut self, r#type: &str, name: &str, value: &str) -> anyhow::Result<()> { + let _ = (r#type, name, value); + Ok(()) + } + fn remove_link(&mut self, r#type: &str, name: &str) -> anyhow::Result<()> { + let _ = (r#type, name); + Ok(()) + } + fn get_name(&self) -> String { + String::new() + } +} + +// Mirrors PHP's $httpDownloader->expects([...], true): an `['url' => .., 'body' => ..]` +// entry (status defaults to 200). +fn http_body( + url: &str, + body: impl Into<String>, +) -> shirabe::util::http_downloader::HttpDownloaderMockExpectation { + expect_full(url, None, 200, body, vec![String::new()]) +} + +// Mirrors PHP's `['url' => .., 'status' => 404]` entry (no body). +fn http_status( + url: &str, + status: i64, +) -> shirabe::util::http_downloader::HttpDownloaderMockExpectation { + expect_full(url, None, status, String::new(), vec![String::new()]) +} + +fn b64(s: &str) -> String { + shirabe_php_shim::base64_encode(s) +} + fn supports_provider() -> Vec<(bool, &'static str)> { vec![ (false, "https://github.com/acme"), @@ -80,77 +165,731 @@ fn test_supports() { } } -// The remaining cases construct a GitHubDriver and mock the HttpDownloader/IO to return -// GitHub API responses; mocking is not available, and a real HttpDownloader reaches -// curl_multi_init (todo!()). #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock), ProcessExecutorMock, the IOInterface MockObject (askAndHideAnswer/setAuthentication), and setAttribute reflection are not ported"] fn test_private_repository() { - let SetUp { home, config: _ } = set_up(); + let SetUp { home, config } = set_up(); let _tear_down = TearDown::new(home.path().to_path_buf()); - todo!() + + let repo_url = "http://github.com/composer/packagist"; + let repo_api_url = "https://api.github.com/repos/composer/packagist"; + let repo_ssh_url = "git@github.com:composer/packagist.git"; + let identifier = "v0.0.0"; + let sha = "SOMESHA"; + + let config = Rc::new(RefCell::new(config)); + + let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new( + IOStub::new() + .with_is_interactive(true) + .with_ask_and_hide_answer(Some("sometoken".to_string())), + )); + + let (http_downloader, _http_guard): (_, HttpDownloaderMockGuard) = get_http_downloader_mock( + vec![ + http_status(repo_api_url, 404), + http_body("https://api.github.com/", "{}"), + http_body( + repo_api_url, + r#"{"master_branch": "test_master", "private": true, "owner": {"login": "composer"}, "name": "packagist"}"#, + ), + ], + true, + HttpDownloaderMockHandler::default(), + ); + + let (process, _process_guard): (_, ProcessExecutorMockGuard) = get_process_executor_mock( + vec![], + false, + MockHandler { + r#return: 1, + stdout: String::new(), + stderr: String::new(), + }, + ); + + config + .borrow_mut() + .set_config_source(Box::new(NullConfigSource)); + config + .borrow_mut() + .set_auth_config_source(Box::new(NullConfigSource)); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(repo_url.to_string())); + + let mut git_hub_driver = GitHubDriver::new(repo_config, io, config, http_downloader, process); + git_hub_driver.initialize().unwrap(); + let mut tags = IndexMap::new(); + tags.insert(identifier.to_string(), sha.to_string()); + git_hub_driver.__set_tags(Some(tags)); + + assert_eq!("test_master", git_hub_driver.get_root_identifier().unwrap()); + + let dist = git_hub_driver.get_dist(sha).unwrap(); + assert_eq!(Some("zip"), dist.get("type").and_then(|v| v.as_string())); + assert_eq!( + Some("https://api.github.com/repos/composer/packagist/zipball/SOMESHA"), + dist.get("url").and_then(|v| v.as_string()) + ); + assert_eq!( + Some("SOMESHA"), + dist.get("reference").and_then(|v| v.as_string()) + ); + + let source = git_hub_driver.get_source(sha); + assert_eq!(Some("git"), source.get("type").and_then(|v| v.as_string())); + assert_eq!( + Some(repo_ssh_url), + source.get("url").and_then(|v| v.as_string()) + ); + assert_eq!( + Some("SOMESHA"), + source.get("reference").and_then(|v| v.as_string()) + ); } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock), ProcessExecutorMock, the IOInterface MockObject, and setAttribute reflection are not ported"] fn test_public_repository() { - let SetUp { home, config: _ } = set_up(); + let SetUp { home, config } = set_up(); let _tear_down = TearDown::new(home.path().to_path_buf()); - todo!() + + let repo_url = "http://github.com/composer/packagist"; + let repo_api_url = "https://api.github.com/repos/composer/packagist"; + let identifier = "v0.0.0"; + let sha = "SOMESHA"; + + let config = Rc::new(RefCell::new(config)); + let io: Rc<RefCell<dyn IOInterface>> = + Rc::new(RefCell::new(IOStub::new().with_is_interactive(true))); + + let (http_downloader, _http_guard) = get_http_downloader_mock( + vec![http_body( + repo_api_url, + r#"{"master_branch": "test_master", "owner": {"login": "composer"}, "name": "packagist"}"#, + )], + true, + HttpDownloaderMockHandler::default(), + ); + + let process = Rc::new(RefCell::new(ProcessExecutor::new(None))); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(repo_url.to_string())); + let repo_url = "https://github.com/composer/packagist.git"; + + let mut git_hub_driver = GitHubDriver::new(repo_config, io, config, http_downloader, process); + git_hub_driver.initialize().unwrap(); + let mut tags = IndexMap::new(); + tags.insert(identifier.to_string(), sha.to_string()); + git_hub_driver.__set_tags(Some(tags)); + + assert_eq!("test_master", git_hub_driver.get_root_identifier().unwrap()); + + let dist = git_hub_driver.get_dist(sha).unwrap(); + assert_eq!(Some("zip"), dist.get("type").and_then(|v| v.as_string())); + assert_eq!( + Some("https://api.github.com/repos/composer/packagist/zipball/SOMESHA"), + dist.get("url").and_then(|v| v.as_string()) + ); + assert_eq!(Some(sha), dist.get("reference").and_then(|v| v.as_string())); + + let source = git_hub_driver.get_source(sha); + assert_eq!(Some("git"), source.get("type").and_then(|v| v.as_string())); + assert_eq!( + Some(repo_url), + source.get("url").and_then(|v| v.as_string()) + ); + assert_eq!( + Some(sha), + source.get("reference").and_then(|v| v.as_string()) + ); } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock), ProcessExecutorMock, the IOInterface MockObject, and setAttribute reflection are not ported"] +#[ignore = "blocked by shim: getComposerInformation -> get_change_date -> shirabe_php_shim::date_create is todo!()"] fn test_public_repository2() { - let SetUp { home, config: _ } = set_up(); + let SetUp { home, config } = set_up(); let _tear_down = TearDown::new(home.path().to_path_buf()); - todo!() + + let repo_url = "http://github.com/composer/packagist"; + let repo_api_url = "https://api.github.com/repos/composer/packagist"; + let identifier = "feature/3.2-foo"; + let sha = "SOMESHA"; + + let config = Rc::new(RefCell::new(config)); + let io: Rc<RefCell<dyn IOInterface>> = + Rc::new(RefCell::new(IOStub::new().with_is_interactive(true))); + + let (http_downloader, _http_guard) = get_http_downloader_mock( + vec![ + http_body( + repo_api_url, + r#"{"master_branch": "test_master", "owner": {"login": "composer"}, "name": "packagist"}"#, + ), + http_body( + "https://api.github.com/repos/composer/packagist/contents/composer.json?ref=feature%2F3.2-foo", + format!( + r#"{{"encoding":"base64","content":"{}"}}"#, + b64(&format!(r#"{{"support": {{"source": "{}" }}}}"#, repo_url)) + ), + ), + http_body( + "https://api.github.com/repos/composer/packagist/commits/feature%2F3.2-foo", + r#"{"commit": {"committer":{ "date": "2012-09-10"}}}"#, + ), + http_body( + "https://api.github.com/repos/composer/packagist/contents/.github/FUNDING.yml", + format!( + r#"{{"encoding": "base64", "content": "{}"}}"#, + b64("custom: https://example.com") + ), + ), + ], + true, + HttpDownloaderMockHandler::default(), + ); + + let process = Rc::new(RefCell::new(ProcessExecutor::new(None))); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(repo_url.to_string())); + let repo_url = "https://github.com/composer/packagist.git"; + + let mut git_hub_driver = GitHubDriver::new(repo_config, io, config, http_downloader, process); + git_hub_driver.initialize().unwrap(); + let mut tags = IndexMap::new(); + tags.insert(identifier.to_string(), sha.to_string()); + git_hub_driver.__set_tags(Some(tags)); + + assert_eq!("test_master", git_hub_driver.get_root_identifier().unwrap()); + + let dist = git_hub_driver.get_dist(sha).unwrap(); + assert_eq!(Some("zip"), dist.get("type").and_then(|v| v.as_string())); + assert_eq!( + Some("https://api.github.com/repos/composer/packagist/zipball/SOMESHA"), + dist.get("url").and_then(|v| v.as_string()) + ); + assert_eq!(Some(sha), dist.get("reference").and_then(|v| v.as_string())); + + let source = git_hub_driver.get_source(sha); + assert_eq!(Some("git"), source.get("type").and_then(|v| v.as_string())); + assert_eq!( + Some(repo_url), + source.get("url").and_then(|v| v.as_string()) + ); + assert_eq!( + Some(sha), + source.get("reference").and_then(|v| v.as_string()) + ); + + let data = git_hub_driver + .get_composer_information(identifier) + .unwrap() + .unwrap(); + assert!(!data.contains_key("abandoned")); } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock), ProcessExecutorMock, the IOInterface MockObject, and setAttribute reflection are not ported"] +#[ignore = "blocked by shim: getComposerInformation -> get_change_date -> shirabe_php_shim::date_create is todo!()"] fn test_invalid_support_data() { - let SetUp { home, config: _ } = set_up(); + let SetUp { home, config } = set_up(); let _tear_down = TearDown::new(home.path().to_path_buf()); - todo!() + + let repo_url = "http://github.com/composer/packagist"; + let repo_api_url = "https://api.github.com/repos/composer/packagist"; + let identifier = "feature/3.2-foo"; + let sha = "SOMESHA"; + + let config = Rc::new(RefCell::new(config)); + let io: Rc<RefCell<dyn IOInterface>> = + Rc::new(RefCell::new(IOStub::new().with_is_interactive(true))); + + let (http_downloader, _http_guard) = get_http_downloader_mock( + vec![ + http_body( + repo_api_url, + r#"{"master_branch": "test_master", "owner": {"login": "composer"}, "name": "packagist"}"#, + ), + http_body( + "https://api.github.com/repos/composer/packagist/contents/composer.json?ref=feature%2F3.2-foo", + format!( + r#"{{"encoding":"base64","content":"{}"}}"#, + b64(&format!(r#"{{"support": "{}" }}"#, repo_url)) + ), + ), + http_body( + "https://api.github.com/repos/composer/packagist/commits/feature%2F3.2-foo", + r#"{"commit": {"committer":{ "date": "2012-09-10"}}}"#, + ), + http_body( + "https://api.github.com/repos/composer/packagist/contents/.github/FUNDING.yml", + format!( + r#"{{"encoding": "base64", "content": "{}"}}"#, + b64("custom: https://example.com") + ), + ), + ], + true, + HttpDownloaderMockHandler::default(), + ); + + let process = Rc::new(RefCell::new(ProcessExecutor::new(None))); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(repo_url.to_string())); + + let mut git_hub_driver = GitHubDriver::new(repo_config, io, config, http_downloader, process); + git_hub_driver.initialize().unwrap(); + let mut tags = IndexMap::new(); + tags.insert(identifier.to_string(), sha.to_string()); + git_hub_driver.__set_tags(Some(tags)); + let mut branches = IndexMap::new(); + branches.insert("test_master".to_string(), sha.to_string()); + git_hub_driver.__set_branches(Some(branches)); + + let data = git_hub_driver + .get_composer_information(identifier) + .unwrap() + .unwrap(); + + let support = data.get("support").and_then(|v| v.as_array()).unwrap(); + assert_eq!( + Some("https://github.com/composer/packagist/tree/feature/3.2-foo"), + support.get("source").and_then(|v| v.as_string()) + ); +} + +fn funding_url_provider() -> Vec<(&'static str, Option<Vec<(&'static str, &'static str)>>)> { + let all_named_platforms = "community_bridge: project-name +github: [userA, userB] +issuehunt: userName +ko_fi: userName +liberapay: userName +open_collective: userName +patreon: userName +tidelift: Platform/Package +polar: userName +buy_me_a_coffee: userName +thanks_dev: u/gh/userName +otechie: userName"; + + vec![ + ( + all_named_platforms, + Some(vec![ + ( + "community_bridge", + "https://funding.communitybridge.org/projects/project-name", + ), + ("github", "https://github.com/userA"), + ("github", "https://github.com/userB"), + ("issuehunt", "https://issuehunt.io/r/userName"), + ("ko_fi", "https://ko-fi.com/userName"), + ("liberapay", "https://liberapay.com/userName"), + ("open_collective", "https://opencollective.com/userName"), + ("patreon", "https://www.patreon.com/userName"), + ( + "tidelift", + "https://tidelift.com/funding/github/Platform/Package", + ), + ("polar", "https://polar.sh/userName"), + ("buy_me_a_coffee", "https://www.buymeacoffee.com/userName"), + ("thanks_dev", "https://thanks.dev/u/gh/userName"), + ("otechie", "https://otechie.com/userName"), + ]), + ), + ( + "custom: example.com", + Some(vec![("custom", "https://example.com")]), + ), + ( + "custom: [example.com]", + Some(vec![("custom", "https://example.com")]), + ), + ( + "custom: \"https://example.com\"", + Some(vec![("custom", "https://example.com")]), + ), + ( + "custom: [\"https://example.com\"]", + Some(vec![("custom", "https://example.com")]), + ), + ( + "custom: [\"https://example.com\", example.org]", + Some(vec![ + ("custom", "https://example.com"), + ("custom", "https://example.org"), + ]), + ), + ( + "custom: [example.net/funding, \"https://example.com\", example.org]", + Some(vec![ + ("custom", "https://example.com"), + ("custom", "https://example.org"), + ]), + ), + ] } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock), ProcessExecutorMock, the IOInterface MockObject, setAttribute reflection, and the fundingUrlProvider data provider are not ported"] +#[ignore = "blocked by shim: getComposerInformation -> get_change_date -> shirabe_php_shim::date_create is todo!()"] fn test_funding_format() { - let SetUp { home, config: _ } = set_up(); - let _tear_down = TearDown::new(home.path().to_path_buf()); - todo!() + for (funding, expected) in funding_url_provider() { + let SetUp { home, config } = set_up(); + let _tear_down = TearDown::new(home.path().to_path_buf()); + + let repo_url = "http://github.com/composer/packagist"; + let repo_api_url = "https://api.github.com/repos/composer/packagist"; + let identifier = "feature/3.2-foo"; + let sha = "SOMESHA"; + + let config = Rc::new(RefCell::new(config)); + let io: Rc<RefCell<dyn IOInterface>> = + Rc::new(RefCell::new(IOStub::new().with_is_interactive(true))); + + let (http_downloader, _http_guard) = get_http_downloader_mock( + vec![ + http_body( + repo_api_url, + r#"{"master_branch": "test_master", "owner": {"login": "composer"}, "name": "packagist"}"#, + ), + http_body( + "https://api.github.com/repos/composer/packagist/contents/composer.json?ref=feature%2F3.2-foo", + format!( + r#"{{"encoding":"base64","content":"{}"}}"#, + b64(&format!(r#"{{"support": {{"source": "{}" }}}}"#, repo_url)) + ), + ), + http_body( + "https://api.github.com/repos/composer/packagist/commits/feature%2F3.2-foo", + r#"{"commit": {"committer":{ "date": "2012-09-10"}}}"#, + ), + http_body( + "https://api.github.com/repos/composer/packagist/contents/.github/FUNDING.yml", + format!(r#"{{"encoding": "base64", "content": "{}"}}"#, b64(funding)), + ), + ], + true, + HttpDownloaderMockHandler::default(), + ); + + let process = Rc::new(RefCell::new(ProcessExecutor::new(None))); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(repo_url.to_string())); + + let mut git_hub_driver = + GitHubDriver::new(repo_config, io, config, http_downloader, process); + git_hub_driver.initialize().unwrap(); + let mut tags = IndexMap::new(); + tags.insert(identifier.to_string(), sha.to_string()); + git_hub_driver.__set_tags(Some(tags)); + let mut branches = IndexMap::new(); + branches.insert("test_master".to_string(), sha.to_string()); + git_hub_driver.__set_branches(Some(branches)); + + let data = git_hub_driver + .get_composer_information(identifier) + .unwrap() + .unwrap(); + + match expected { + None => assert!(!data.contains_key("funding")), + Some(expected) => { + let funding_list = match data.get("funding") { + Some(PhpMixed::List(l)) => l.clone(), + Some(PhpMixed::Array(m)) => m.values().cloned().collect(), + other => panic!("unexpected funding value: {:?}", other), + }; + let actual: Vec<(String, String)> = funding_list + .iter() + .map(|entry| { + let m = entry.as_array().unwrap(); + ( + m.get("type") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(), + m.get("url") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(), + ) + }) + .collect(); + let expected_vec: Vec<(String, String)> = expected + .iter() + .map(|(t, u)| (t.to_string(), u.to_string())) + .collect(); + assert_eq!(expected_vec, actual); + } + } + } } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock), ProcessExecutorMock, the IOInterface MockObject, and setAttribute reflection are not ported"] +#[ignore = "blocked by shim: getComposerInformation -> get_change_date -> shirabe_php_shim::date_create is todo!()"] fn test_public_repository_archived() { - let SetUp { home, config: _ } = set_up(); + let SetUp { home, config } = set_up(); let _tear_down = TearDown::new(home.path().to_path_buf()); - todo!() + + let repo_url = "http://github.com/composer/packagist"; + let repo_api_url = "https://api.github.com/repos/composer/packagist"; + let identifier = "v0.0.0"; + let sha = "SOMESHA"; + let composer_json_url = format!( + "https://api.github.com/repos/composer/packagist/contents/composer.json?ref={}", + sha + ); + + let config = Rc::new(RefCell::new(config)); + let io: Rc<RefCell<dyn IOInterface>> = + Rc::new(RefCell::new(IOStub::new().with_is_interactive(true))); + + let (http_downloader, _http_guard) = get_http_downloader_mock( + vec![ + http_body( + repo_api_url, + r#"{"master_branch": "test_master", "owner": {"login": "composer"}, "name": "packagist", "archived": true}"#, + ), + http_body( + &composer_json_url, + format!( + r#"{{"encoding": "base64", "content": "{}"}}"#, + b64(r#"{"name": "composer/packagist"}"#) + ), + ), + http_body( + &format!( + "https://api.github.com/repos/composer/packagist/commits/{}", + sha + ), + r#"{"commit": {"committer":{ "date": "2012-09-10"}}}"#, + ), + http_body( + "https://api.github.com/repos/composer/packagist/contents/.github/FUNDING.yml", + format!( + r#"{{"encoding": "base64", "content": "{}"}}"#, + b64("custom: https://example.com") + ), + ), + ], + true, + HttpDownloaderMockHandler::default(), + ); + + let process = Rc::new(RefCell::new(ProcessExecutor::new(None))); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(repo_url.to_string())); + + let mut git_hub_driver = GitHubDriver::new(repo_config, io, config, http_downloader, process); + git_hub_driver.initialize().unwrap(); + let mut tags = IndexMap::new(); + tags.insert(identifier.to_string(), sha.to_string()); + git_hub_driver.__set_tags(Some(tags)); + + let data = git_hub_driver + .get_composer_information(sha) + .unwrap() + .unwrap(); + + assert_eq!(Some(true), data.get("abandoned").and_then(|v| v.as_bool())); } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock), ProcessExecutorMock, and the IOInterface MockObject are not ported"] +#[ignore = "GitDriver clone-fallback path runs an unexpected `git --version` (Git::get_version) not in the PHP mock expectation list; needs the version static seeded and the Rust sync_mirror command sequence to match"] fn test_private_repository_no_interaction() { - let SetUp { home, config: _ } = set_up(); + let SetUp { home, config } = set_up(); let _tear_down = TearDown::new(home.path().to_path_buf()); - todo!() + + let repo_url = "http://github.com/composer/packagist"; + let repo_api_url = "https://api.github.com/repos/composer/packagist"; + let repo_ssh_url = "git@github.com:composer/packagist.git"; + let identifier = "v0.0.0"; + let sha = "SOMESHA"; + + let config = Rc::new(RefCell::new(config)); + let io: Rc<RefCell<dyn IOInterface>> = + Rc::new(RefCell::new(IOStub::new().with_is_interactive(false))); + + let (http_downloader, _http_guard) = get_http_downloader_mock( + vec![http_status(repo_api_url, 404)], + true, + HttpDownloaderMockHandler::default(), + ); + + // clean local clone if present + let cache_dir = std::env::temp_dir().join("composer-test"); + let mut fs = Filesystem::new(None); + let _ = fs.remove_directory(&cache_dir); + let cache_vcs_dir = std::env::temp_dir() + .join("composer-test/cache") + .to_string_lossy() + .into_owned(); + { + let mut top: IndexMap<String, PhpMixed> = IndexMap::new(); + let mut config_section: IndexMap<String, PhpMixed> = IndexMap::new(); + config_section.insert( + "cache-vcs-dir".to_string(), + PhpMixed::String(cache_vcs_dir.clone()), + ); + top.insert("config".to_string(), PhpMixed::Array(config_section)); + config.borrow_mut().merge(&top, Config::SOURCE_UNKNOWN); + } + let resolved_cache_vcs_dir = config + .borrow_mut() + .get("cache-vcs-dir") + .as_string() + .unwrap_or("") + .to_string(); + + let (process, _process_guard): (_, ProcessExecutorMockGuard) = get_process_executor_mock( + vec![ + cmd_full(["git", "config", "github.accesstoken"], 1, "", ""), + cmd([ + "git", + "clone", + "--mirror", + "--", + repo_ssh_url, + &format!( + "{}/git-github.com-composer-packagist.git/", + resolved_cache_vcs_dir + ), + ]), + cmd(["git", "remote", "-v"]), + cmd(["git", "remote", "set-url", "origin", "--", repo_ssh_url]), + cmd_full( + ["git", "show-ref", "--tags", "--dereference"], + 0, + format!("{} refs/tags/{}", sha, identifier), + "", + ), + cmd_full( + ["git", "branch", "--no-color", "--no-abbrev", "-v"], + 0, + " test_master edf93f1fccaebd8764383dc12016d0a1a9672d89 Fix test & behavior", + "", + ), + cmd_full(["git", "branch", "--no-color"], 0, "* test_master", ""), + ], + true, + MockHandler::default(), + ); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(repo_url.to_string())); + + let mut git_hub_driver = GitHubDriver::new(repo_config, io, config, http_downloader, process); + git_hub_driver.initialize().unwrap(); + + assert_eq!("test_master", git_hub_driver.get_root_identifier().unwrap()); + + let dist = git_hub_driver.get_dist(sha).unwrap(); + assert_eq!(Some("zip"), dist.get("type").and_then(|v| v.as_string())); + assert_eq!( + Some("https://api.github.com/repos/composer/packagist/zipball/SOMESHA"), + dist.get("url").and_then(|v| v.as_string()) + ); + assert_eq!(Some(sha), dist.get("reference").and_then(|v| v.as_string())); + + let source = git_hub_driver.get_source(identifier); + assert_eq!(Some("git"), source.get("type").and_then(|v| v.as_string())); + assert_eq!( + Some(repo_ssh_url), + source.get("url").and_then(|v| v.as_string()) + ); + assert_eq!( + Some(identifier), + source.get("reference").and_then(|v| v.as_string()) + ); + + let source = git_hub_driver.get_source(sha); + assert_eq!(Some("git"), source.get("type").and_then(|v| v.as_string())); + assert_eq!( + Some(repo_ssh_url), + source.get("url").and_then(|v| v.as_string()) + ); + assert_eq!( + Some(sha), + source.get("reference").and_then(|v| v.as_string()) + ); +} + +fn invalid_url_provider() -> Vec<&'static str> { + vec![ + "https://github.com/acme", + "https://github.com/acme/repository/releases", + "https://github.com/acme/repository/pulls", + ] } #[test] -#[ignore = "IOInterface MockObject (getMockBuilder) and HttpDownloader MockObject (getMockBuilder) plus the invalidUrlProvider data provider are not ported"] fn test_initialize_invalid_repo_url() { - let SetUp { home, config: _ } = set_up(); - let _tear_down = TearDown::new(home.path().to_path_buf()); - todo!() + for url in invalid_url_provider() { + let SetUp { home, config } = set_up(); + let _tear_down = TearDown::new(home.path().to_path_buf()); + + let config = Rc::new(RefCell::new(config)); + let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(IOStub::new())); + + let (http_downloader, _http_guard) = + get_http_downloader_mock(vec![], true, HttpDownloaderMockHandler::default()); + let process = Rc::new(RefCell::new(ProcessExecutor::new(None))); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(url.to_string())); + + let mut git_hub_driver = + GitHubDriver::new(repo_config, io, config, http_downloader, process); + let result = git_hub_driver.initialize(); + assert!( + result.is_err(), + "expected InvalidArgumentException for url {}", + url + ); + } } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock), ProcessExecutorMock, and the IOInterface MockObject are not ported"] fn test_get_empty_file_content() { - let SetUp { home, config: _ } = set_up(); + let SetUp { home, config } = set_up(); let _tear_down = TearDown::new(home.path().to_path_buf()); - todo!() + + let repo_url = "http://github.com/composer/packagist"; + + let config = Rc::new(RefCell::new(config)); + let io: Rc<RefCell<dyn IOInterface>> = + Rc::new(RefCell::new(IOStub::new().with_is_interactive(true))); + + let (http_downloader, _http_guard) = get_http_downloader_mock( + vec![ + http_body( + "https://api.github.com/repos/composer/packagist", + r#"{"master_branch": "test_master", "owner": {"login": "composer"}, "name": "packagist", "archived": true}"#, + ), + http_body( + "https://api.github.com/repos/composer/packagist/contents/composer.json?ref=main", + r#"{"encoding":"base64","content":""}"#, + ), + ], + true, + HttpDownloaderMockHandler::default(), + ); + + let process = Rc::new(RefCell::new(ProcessExecutor::new(None))); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(repo_url.to_string())); + + let mut git_hub_driver = GitHubDriver::new(repo_config, io, config, http_downloader, process); + git_hub_driver.initialize().unwrap(); + + assert_eq!( + Some(String::new()), + git_hub_driver + .get_file_content("composer.json", "main") + .unwrap() + ); } diff --git a/crates/shirabe/tests/repository/vcs/gitlab_driver_test.rs b/crates/shirabe/tests/repository/vcs/gitlab_driver_test.rs index 0333a2b..e21835d 100644 --- a/crates/shirabe/tests/repository/vcs/gitlab_driver_test.rs +++ b/crates/shirabe/tests/repository/vcs/gitlab_driver_test.rs @@ -8,8 +8,15 @@ use shirabe::config::Config; use shirabe::io::IOInterface; use shirabe::io::null_io::NullIO; use shirabe::repository::vcs::GitLabDriver; +use shirabe::util::http_downloader::{HttpDownloader, HttpDownloaderMockHandler}; +use shirabe::util::process_executor::{MockHandler, ProcessExecutor}; use shirabe_php_shim::{PhpMixed, extension_loaded}; +use crate::http_downloader_mock::{ + HttpDownloaderMockGuard, expect, expect_full, get_http_downloader_mock, +}; +use crate::process_executor_mock::{ProcessExecutorMockGuard, get_process_executor_mock}; + // Mirrors GitLabDriverTest::setUp's `gitlab-domains` configuration. fn make_config() -> Config { let mut config = Config::new(true, None); @@ -30,74 +37,596 @@ fn make_config() -> Config { config } +// Common test fixtures: the IO mock (a bare NullIO, matching PHP's unconfigured +// getMock), the config and a no-op process executor mock. The PHP test passes a +// bare `getMock()` ProcessExecutor; none of these tests invoke git, so an empty +// strict expectation set both matches PHP and catches unexpected process calls. +struct Fixtures { + io: Rc<RefCell<dyn IOInterface>>, + config: Rc<RefCell<Config>>, + process: Rc<RefCell<ProcessExecutor>>, + _process_guard: ProcessExecutorMockGuard, +} + +fn fixtures() -> Fixtures { + let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(NullIO::new())); + let config = Rc::new(RefCell::new(make_config())); + let (process, _process_guard) = get_process_executor_mock(vec![], true, MockHandler::default()); + Fixtures { + io, + config, + process, + _process_guard, + } +} + +// Mirrors $this->getHttpDownloaderMock() with a single project-data expectation. +fn http_mock_with_body( + url: &str, + body: &str, +) -> (Rc<RefCell<HttpDownloader>>, HttpDownloaderMockGuard) { + get_http_downloader_mock( + vec![expect_full(url, None, 200, body, vec![String::new()])], + true, + HttpDownloaderMockHandler::default(), + ) +} + +fn provide_initialize_urls() -> Vec<(&'static str, &'static str)> { + vec![ + ( + "https://gitlab.com/mygroup/myproject", + "https://gitlab.com/api/v4/projects/mygroup%2Fmyproject", + ), + ( + "http://gitlab.com/mygroup/myproject", + "http://gitlab.com/api/v4/projects/mygroup%2Fmyproject", + ), + ( + "git@gitlab.com:mygroup/myproject", + "https://gitlab.com/api/v4/projects/mygroup%2Fmyproject", + ), + ] +} + +// Mirrors testInitialize: returns the initialized driver together with the http +// mock guard (kept alive by the caller so __assert_complete runs at scope end). +fn do_test_initialize( + fixtures: &Fixtures, + url: &str, + api_url: &str, +) -> (GitLabDriver, HttpDownloaderMockGuard) { + // @link http://doc.gitlab.com/ce/api/projects.html#get-single-project + let project_data = r#"{ + "id": 17, + "default_branch": "mymaster", + "visibility": "private", + "issues_enabled": true, + "archived": false, + "http_url_to_repo": "https://gitlab.com/mygroup/myproject.git", + "ssh_url_to_repo": "git@gitlab.com:mygroup/myproject.git", + "last_activity_at": "2014-12-01T09:17:51.000+01:00", + "name": "My Project", + "name_with_namespace": "My Group / My Project", + "path": "myproject", + "path_with_namespace": "mygroup/myproject", + "web_url": "https://gitlab.com/mygroup/myproject" +}"#; + + let (http_downloader, guard) = http_mock_with_body(api_url, project_data); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(url.to_string())); + let mut driver = GitLabDriver::new( + repo_config, + fixtures.io.clone(), + fixtures.config.clone(), + http_downloader, + fixtures.process.clone(), + ); + driver.initialize().unwrap(); + + assert_eq!( + api_url, + driver.get_api_url(), + "API URL is derived from the repository URL" + ); + assert_eq!( + "mymaster", + driver.get_root_identifier().unwrap(), + "Root identifier is the default branch in GitLab" + ); + assert_eq!( + "git@gitlab.com:mygroup/myproject.git", + driver.get_repository_url(), + "The repository URL is the SSH one by default" + ); + assert_eq!("https://gitlab.com/mygroup/myproject", driver.get_url()); + + (driver, guard) +} + +// Mirrors testInitializePublicProject. +fn do_test_initialize_public_project( + fixtures: &Fixtures, + url: &str, + api_url: &str, +) -> (GitLabDriver, HttpDownloaderMockGuard) { + // @link http://doc.gitlab.com/ce/api/projects.html#get-single-project + let project_data = r#"{ + "id": 17, + "default_branch": "mymaster", + "visibility": "public", + "http_url_to_repo": "https://gitlab.com/mygroup/myproject.git", + "ssh_url_to_repo": "git@gitlab.com:mygroup/myproject.git", + "last_activity_at": "2014-12-01T09:17:51.000+01:00", + "name": "My Project", + "name_with_namespace": "My Group / My Project", + "path": "myproject", + "path_with_namespace": "mygroup/myproject", + "web_url": "https://gitlab.com/mygroup/myproject" +}"#; + + let (http_downloader, guard) = http_mock_with_body(api_url, project_data); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(url.to_string())); + let mut driver = GitLabDriver::new( + repo_config, + fixtures.io.clone(), + fixtures.config.clone(), + http_downloader, + fixtures.process.clone(), + ); + driver.initialize().unwrap(); + + assert_eq!( + api_url, + driver.get_api_url(), + "API URL is derived from the repository URL" + ); + assert_eq!( + "mymaster", + driver.get_root_identifier().unwrap(), + "Root identifier is the default branch in GitLab" + ); + assert_eq!( + "https://gitlab.com/mygroup/myproject.git", + driver.get_repository_url(), + "The repository URL is the SSH one by default" + ); + assert_eq!("https://gitlab.com/mygroup/myproject", driver.get_url()); + + (driver, guard) +} + #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock) and the IOInterface MockObject are not ported"] fn test_initialize() { - todo!() + let fixtures = fixtures(); + for (url, api_url) in provide_initialize_urls() { + let (_driver, _guard) = do_test_initialize(&fixtures, url, api_url); + } } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock) and the IOInterface MockObject are not ported"] fn test_initialize_public_project() { - todo!() + let fixtures = fixtures(); + for (url, api_url) in provide_initialize_urls() { + let (_driver, _guard) = do_test_initialize_public_project(&fixtures, url, api_url); + } } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock) and the IOInterface MockObject are not ported"] fn test_initialize_public_project_as_anonymous() { - todo!() + let fixtures = fixtures(); + for (url, api_url) in provide_initialize_urls() { + // @link http://doc.gitlab.com/ce/api/projects.html#get-single-project + let project_data = r#"{ + "id": 17, + "default_branch": "mymaster", + "http_url_to_repo": "https://gitlab.com/mygroup/myproject.git", + "ssh_url_to_repo": "git@gitlab.com:mygroup/myproject.git", + "last_activity_at": "2014-12-01T09:17:51.000+01:00", + "name": "My Project", + "name_with_namespace": "My Group / My Project", + "path": "myproject", + "path_with_namespace": "mygroup/myproject", + "web_url": "https://gitlab.com/mygroup/myproject" +}"#; + + let (http_downloader, _guard) = http_mock_with_body(api_url, project_data); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(url.to_string())); + let mut driver = GitLabDriver::new( + repo_config, + fixtures.io.clone(), + fixtures.config.clone(), + http_downloader, + fixtures.process.clone(), + ); + driver.initialize().unwrap(); + + assert_eq!( + api_url, + driver.get_api_url(), + "API URL is derived from the repository URL" + ); + assert_eq!( + "mymaster", + driver.get_root_identifier().unwrap(), + "Root identifier is the default branch in GitLab" + ); + assert_eq!( + "https://gitlab.com/mygroup/myproject.git", + driver.get_repository_url(), + "The repository URL is the SSH one by default" + ); + assert_eq!("https://gitlab.com/mygroup/myproject", driver.get_url()); + } } +/// Also support repositories over HTTP (TLS) and has a port number. #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock) and the IOInterface MockObject are not ported"] fn test_initialize_with_port_number() { - todo!() + let fixtures = fixtures(); + let domain = "gitlab.mycompany.com"; + let port = "5443"; + let namespace = "mygroup/myproject"; + let url = format!("https://{}:{}/{}", domain, port, namespace); + // urlencode($namespace) replaces '/' with '%2F'. + let api_url = format!( + "https://{}:{}/api/v4/projects/{}", + domain, port, "mygroup%2Fmyproject" + ); + + // An incomplete single project API response payload. + // @link http://doc.gitlab.com/ce/api/projects.html#get-single-project + let project_data = format!( + r#"{{ + "default_branch": "1.0.x", + "http_url_to_repo": "https://{0}:{1}/{2}.git", + "path": "myproject", + "path_with_namespace": "{2}", + "web_url": "https://{0}:{1}/{2}" +}}"#, + domain, port, namespace + ); + + let (http_downloader, _guard) = http_mock_with_body(&api_url, &project_data); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(url.clone())); + let mut driver = GitLabDriver::new( + repo_config, + fixtures.io.clone(), + fixtures.config.clone(), + http_downloader, + fixtures.process.clone(), + ); + driver.initialize().unwrap(); + + assert_eq!( + api_url, + driver.get_api_url(), + "API URL is derived from the repository URL" + ); + assert_eq!( + "1.0.x", + driver.get_root_identifier().unwrap(), + "Root identifier is the default branch in GitLab" + ); + assert_eq!( + format!("{}.git", url), + driver.get_repository_url(), + "The repository URL is the SSH one by default" + ); + assert_eq!(url, driver.get_url()); } #[test] -#[ignore = "depends on testInitialize, which needs HttpDownloaderMock + setAttribute (\\ReflectionProperty) not ported"] fn test_invalid_support_data() { - todo!() + let fixtures = fixtures(); + let repo_url = "https://gitlab.com/mygroup/myproject"; + let (mut driver, _init_guard) = do_test_initialize( + &fixtures, + repo_url, + "https://gitlab.com/api/v4/projects/mygroup%2Fmyproject", + ); + + let mut branches: IndexMap<String, String> = IndexMap::new(); + branches.insert("main".to_string(), "SOMESHA".to_string()); + driver.__set_branches(Some(branches)); + driver.__set_tags(Some(IndexMap::new())); + + let (http_downloader, _guard) = get_http_downloader_mock( + vec![expect_full( + "https://gitlab.com/api/v4/projects/mygroup%2Fmyproject/repository/files/composer%2Ejson/raw?ref=SOMESHA", + None, + 200, + format!(r#"{{"support": "{}" }}"#, repo_url), + vec![String::new()], + )], + true, + HttpDownloaderMockHandler::default(), + ); + driver.set_http_downloader(http_downloader); + + let data = driver.get_composer_information("main").unwrap().unwrap(); + + let source = data + .get("support") + .and_then(|v| v.as_array()) + .and_then(|m| m.get("source")) + .and_then(|v| v.as_string()) + .unwrap(); + assert_eq!("https://gitlab.com/mygroup/myproject/-/tree/main", source); } #[test] -#[ignore = "depends on testInitialize, which needs HttpDownloaderMock + the IOInterface MockObject not ported"] fn test_get_dist() { - todo!() + let fixtures = fixtures(); + let (driver, _guard) = do_test_initialize( + &fixtures, + "https://gitlab.com/mygroup/myproject", + "https://gitlab.com/api/v4/projects/mygroup%2Fmyproject", + ); + + let reference = "c3ebdbf9cceddb82cd2089aaef8c7b992e536363"; + let mut expected: IndexMap<String, PhpMixed> = IndexMap::new(); + expected.insert("type".to_string(), PhpMixed::String("zip".to_string())); + expected.insert( + "url".to_string(), + PhpMixed::String(format!( + "https://gitlab.com/api/v4/projects/mygroup%2Fmyproject/repository/archive.zip?sha={}", + reference + )), + ); + expected.insert( + "reference".to_string(), + PhpMixed::String(reference.to_string()), + ); + expected.insert("shasum".to_string(), PhpMixed::String(String::new())); + + assert_eq!(Some(expected), driver.get_dist(reference)); } #[test] -#[ignore = "depends on testInitialize, which needs HttpDownloaderMock + the IOInterface MockObject not ported"] fn test_get_source() { - todo!() + let fixtures = fixtures(); + let (driver, _guard) = do_test_initialize( + &fixtures, + "https://gitlab.com/mygroup/myproject", + "https://gitlab.com/api/v4/projects/mygroup%2Fmyproject", + ); + + let reference = "c3ebdbf9cceddb82cd2089aaef8c7b992e536363"; + let mut expected: IndexMap<String, PhpMixed> = IndexMap::new(); + expected.insert("type".to_string(), PhpMixed::String("git".to_string())); + expected.insert( + "url".to_string(), + PhpMixed::String("git@gitlab.com:mygroup/myproject.git".to_string()), + ); + expected.insert( + "reference".to_string(), + PhpMixed::String(reference.to_string()), + ); + + assert_eq!(expected, driver.get_source(reference)); } #[test] -#[ignore = "depends on testInitializePublicProject, which needs HttpDownloaderMock + the IOInterface MockObject not ported"] fn test_get_source_given_public_project() { - todo!() + let fixtures = fixtures(); + let (driver, _guard) = do_test_initialize_public_project( + &fixtures, + "https://gitlab.com/mygroup/myproject", + "https://gitlab.com/api/v4/projects/mygroup%2Fmyproject", + ); + + let reference = "c3ebdbf9cceddb82cd2089aaef8c7b992e536363"; + let mut expected: IndexMap<String, PhpMixed> = IndexMap::new(); + expected.insert("type".to_string(), PhpMixed::String("git".to_string())); + expected.insert( + "url".to_string(), + PhpMixed::String("https://gitlab.com/mygroup/myproject.git".to_string()), + ); + expected.insert( + "reference".to_string(), + PhpMixed::String(reference.to_string()), + ); + + assert_eq!(expected, driver.get_source(reference)); } #[test] -#[ignore = "depends on testInitialize, which needs HttpDownloaderMock + the IOInterface MockObject not ported"] fn test_get_tags() { - todo!() + let fixtures = fixtures(); + let (mut driver, _init_guard) = do_test_initialize( + &fixtures, + "https://gitlab.com/mygroup/myproject", + "https://gitlab.com/api/v4/projects/mygroup%2Fmyproject", + ); + + let api_url = + "https://gitlab.com/api/v4/projects/mygroup%2Fmyproject/repository/tags?per_page=100"; + + // @link http://doc.gitlab.com/ce/api/repositories.html#list-project-repository-tags + let tag_data = r#"[ + { + "name": "v1.0.0", + "commit": { + "id": "092ed2c762bbae331e3f51d4a17f67310bf99a81", + "committed_date": "2012-05-28T04:42:42-07:00" + } + }, + { + "name": "v2.0.0", + "commit": { + "id": "8e8f60b3ec86d63733db3bd6371117a758027ec6", + "committed_date": "2014-07-06T12:59:11.000+02:00" + } + } +]"#; + + let (http_downloader, _guard) = http_mock_with_body(api_url, tag_data); + driver.set_http_downloader(http_downloader); + + let mut expected: IndexMap<String, String> = IndexMap::new(); + expected.insert( + "v1.0.0".to_string(), + "092ed2c762bbae331e3f51d4a17f67310bf99a81".to_string(), + ); + expected.insert( + "v2.0.0".to_string(), + "8e8f60b3ec86d63733db3bd6371117a758027ec6".to_string(), + ); + + assert_eq!(expected, driver.get_tags().unwrap()); + assert_eq!(expected, driver.get_tags().unwrap(), "Tags are cached"); } #[test] -#[ignore = "depends on testInitialize, which needs HttpDownloaderMock + the IOInterface MockObject not ported"] +#[ignore = "Response::find_header_value passes a `(?i)`-prefixed (non-delimited) pattern that compile_php_pattern (Preg) mis-translates to an invalid regex (`(?s)?i)^link:...`), so get_next_page panics when parsing the Link header. Pre-existing Preg/Response porting bug, unrelated to GitLabDriver."] fn test_get_paginated_refs() { - todo!() + let fixtures = fixtures(); + let (mut driver, _init_guard) = do_test_initialize( + &fixtures, + "https://gitlab.com/mygroup/myproject", + "https://gitlab.com/api/v4/projects/mygroup%2Fmyproject", + ); + + // @link http://doc.gitlab.com/ce/api/repositories.html#list-project-repository-branches + let mut branch_data: Vec<PhpMixed> = vec![ + branch_entry( + "mymaster", + "97eda36b5c1dd953a3792865c222d4e85e5f302e", + "2013-01-03T21:04:07.000+01:00", + ), + branch_entry( + "staging", + "502cffe49f136443f2059803f2e7192d1ac066cd", + "2013-03-09T16:35:23.000+01:00", + ), + ]; + for _ in 0..98 { + branch_data.push(branch_entry( + "stagingdupe", + "502cffe49f136443f2059803f2e7192d1ac066cd", + "2013-03-09T16:35:23.000+01:00", + )); + } + let branch_data = shirabe::json::JsonFile::encode(&PhpMixed::List(branch_data)); + + let (http_downloader, _guard) = get_http_downloader_mock( + vec![ + expect_full( + "https://gitlab.com/api/v4/projects/mygroup%2Fmyproject/repository/branches?per_page=100", + None, + 200, + branch_data.clone(), + vec!["Link: <http://gitlab.com/api/v4/projects/mygroup%2Fmyproject/repository/tags?id=mygroup%2Fmyproject&page=2&per_page=20>; rel=\"next\", <http://gitlab.com/api/v4/projects/mygroup%2Fmyproject/repository/tags?id=mygroup%2Fmyproject&page=1&per_page=20>; rel=\"first\", <http://gitlab.com/api/v4/projects/mygroup%2Fmyproject/repository/tags?id=mygroup%2Fmyproject&page=3&per_page=20>; rel=\"last\"".to_string()], + ), + expect_full( + "http://gitlab.com/api/v4/projects/mygroup%2Fmyproject/repository/tags?id=mygroup%2Fmyproject&page=2&per_page=20", + None, + 200, + branch_data.clone(), + vec!["Link: <http://gitlab.com/api/v4/projects/mygroup%2Fmyproject/repository/tags?id=mygroup%2Fmyproject&page=2&per_page=20>; rel=\"prev\", <http://gitlab.com/api/v4/projects/mygroup%2Fmyproject/repository/tags?id=mygroup%2Fmyproject&page=1&per_page=20>; rel=\"first\", <http://gitlab.com/api/v4/projects/mygroup%2Fmyproject/repository/tags?id=mygroup%2Fmyproject&page=3&per_page=20>; rel=\"last\"".to_string()], + ), + ], + true, + HttpDownloaderMockHandler::default(), + ); + driver.set_http_downloader(http_downloader); + + let mut expected: IndexMap<String, String> = IndexMap::new(); + expected.insert( + "mymaster".to_string(), + "97eda36b5c1dd953a3792865c222d4e85e5f302e".to_string(), + ); + expected.insert( + "staging".to_string(), + "502cffe49f136443f2059803f2e7192d1ac066cd".to_string(), + ); + expected.insert( + "stagingdupe".to_string(), + "502cffe49f136443f2059803f2e7192d1ac066cd".to_string(), + ); + + assert_eq!(expected, driver.get_branches().unwrap()); + assert_eq!( + expected, + driver.get_branches().unwrap(), + "Branches are cached" + ); +} + +fn branch_entry(name: &str, id: &str, committed_date: &str) -> PhpMixed { + let mut commit: IndexMap<String, PhpMixed> = IndexMap::new(); + commit.insert("id".to_string(), PhpMixed::String(id.to_string())); + commit.insert( + "committed_date".to_string(), + PhpMixed::String(committed_date.to_string()), + ); + let mut entry: IndexMap<String, PhpMixed> = IndexMap::new(); + entry.insert("name".to_string(), PhpMixed::String(name.to_string())); + entry.insert("commit".to_string(), PhpMixed::Array(commit)); + PhpMixed::Array(entry) } #[test] -#[ignore = "depends on testInitialize, which needs HttpDownloaderMock + the IOInterface MockObject not ported"] fn test_get_branches() { - todo!() + let fixtures = fixtures(); + let (mut driver, _init_guard) = do_test_initialize( + &fixtures, + "https://gitlab.com/mygroup/myproject", + "https://gitlab.com/api/v4/projects/mygroup%2Fmyproject", + ); + + let api_url = + "https://gitlab.com/api/v4/projects/mygroup%2Fmyproject/repository/branches?per_page=100"; + + // @link http://doc.gitlab.com/ce/api/repositories.html#list-project-repository-branches + let branch_data = r#"[ + { + "name": "mymaster", + "commit": { + "id": "97eda36b5c1dd953a3792865c222d4e85e5f302e", + "committed_date": "2013-01-03T21:04:07.000+01:00" + } + }, + { + "name": "staging", + "commit": { + "id": "502cffe49f136443f2059803f2e7192d1ac066cd", + "committed_date": "2013-03-09T16:35:23.000+01:00" + } + } +]"#; + + let (http_downloader, _guard) = http_mock_with_body(api_url, branch_data); + driver.set_http_downloader(http_downloader); + + let mut expected: IndexMap<String, String> = IndexMap::new(); + expected.insert( + "mymaster".to_string(), + "97eda36b5c1dd953a3792865c222d4e85e5f302e".to_string(), + ); + expected.insert( + "staging".to_string(), + "502cffe49f136443f2059803f2e7192d1ac066cd".to_string(), + ); + + assert_eq!(expected, driver.get_branches().unwrap()); + assert_eq!( + expected, + driver.get_branches().unwrap(), + "Branches are cached" + ); } #[test] -#[ignore] fn test_supports() { for (url, expected) in data_for_test_supports() { let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(NullIO::new())); @@ -147,31 +676,216 @@ fn data_for_test_supports() -> Vec<(&'static str, bool)> { } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock) and the IOInterface MockObject are not ported"] fn test_gitlab_sub_directory() { - todo!() + let fixtures = fixtures(); + let url = "https://mycompany.com/gitlab/mygroup/my-pro.ject"; + let api_url = "https://mycompany.com/gitlab/api/v4/projects/mygroup%2Fmy-pro%2Eject"; + + let project_data = r#"{ + "id": 17, + "default_branch": "mymaster", + "visibility": "private", + "http_url_to_repo": "https://gitlab.com/gitlab/mygroup/my-pro.ject", + "ssh_url_to_repo": "git@gitlab.com:mygroup/my-pro.ject.git", + "last_activity_at": "2014-12-01T09:17:51.000+01:00", + "name": "My Project", + "name_with_namespace": "My Group / My Project", + "path": "myproject", + "path_with_namespace": "mygroup/my-pro.ject", + "web_url": "https://gitlab.com/gitlab/mygroup/my-pro.ject" +}"#; + + let (http_downloader, _guard) = http_mock_with_body(api_url, project_data); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(url.to_string())); + let mut driver = GitLabDriver::new( + repo_config, + fixtures.io.clone(), + fixtures.config.clone(), + http_downloader, + fixtures.process.clone(), + ); + driver.initialize().unwrap(); + + assert_eq!( + api_url, + driver.get_api_url(), + "API URL is derived from the repository URL" + ); } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock) and the IOInterface MockObject are not ported"] fn test_gitlab_sub_group() { - todo!() + let fixtures = fixtures(); + let url = "https://gitlab.com/mygroup/mysubgroup/myproject"; + let api_url = "https://gitlab.com/api/v4/projects/mygroup%2Fmysubgroup%2Fmyproject"; + + let project_data = r#"{ + "id": 17, + "default_branch": "mymaster", + "visibility": "private", + "http_url_to_repo": "https://gitlab.com/mygroup/mysubgroup/my-pro.ject", + "ssh_url_to_repo": "git@gitlab.com:mygroup/mysubgroup/my-pro.ject.git", + "last_activity_at": "2014-12-01T09:17:51.000+01:00", + "name": "My Project", + "name_with_namespace": "My Group / My Project", + "path": "myproject", + "path_with_namespace": "mygroup/mysubgroup/my-pro.ject", + "web_url": "https://gitlab.com/mygroup/mysubgroup/my-pro.ject" +}"#; + + let (http_downloader, _guard) = http_mock_with_body(api_url, project_data); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(url.to_string())); + let mut driver = GitLabDriver::new( + repo_config, + fixtures.io.clone(), + fixtures.config.clone(), + http_downloader, + fixtures.process.clone(), + ); + driver.initialize().unwrap(); + + assert_eq!( + api_url, + driver.get_api_url(), + "API URL is derived from the repository URL" + ); } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock) and the IOInterface MockObject are not ported"] fn test_gitlab_sub_directory_sub_group() { - todo!() + let fixtures = fixtures(); + let url = "https://mycompany.com/gitlab/mygroup/mysubgroup/myproject"; + let api_url = "https://mycompany.com/gitlab/api/v4/projects/mygroup%2Fmysubgroup%2Fmyproject"; + + let project_data = r#"{ + "id": 17, + "default_branch": "mymaster", + "visibility": "private", + "http_url_to_repo": "https://mycompany.com/gitlab/mygroup/mysubgroup/my-pro.ject", + "ssh_url_to_repo": "git@mycompany.com:mygroup/mysubgroup/my-pro.ject.git", + "last_activity_at": "2014-12-01T09:17:51.000+01:00", + "name": "My Project", + "name_with_namespace": "My Group / My Project", + "path": "myproject", + "path_with_namespace": "mygroup/mysubgroup/my-pro.ject", + "web_url": "https://mycompany.com/gitlab/mygroup/mysubgroup/my-pro.ject" +}"#; + + let (http_downloader, _guard) = http_mock_with_body(api_url, project_data); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(url.to_string())); + let mut driver = GitLabDriver::new( + repo_config, + fixtures.io.clone(), + fixtures.config.clone(), + http_downloader, + fixtures.process.clone(), + ); + driver.initialize().unwrap(); + + assert_eq!( + api_url, + driver.get_api_url(), + "API URL is derived from the repository URL" + ); } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock) and the IOInterface MockObject are not ported"] fn test_forwards_options() { - todo!() + let fixtures = fixtures(); + let mut ssl: IndexMap<String, PhpMixed> = IndexMap::new(); + ssl.insert("verify_peer".to_string(), PhpMixed::Bool(false)); + let mut options: IndexMap<String, PhpMixed> = IndexMap::new(); + options.insert("ssl".to_string(), PhpMixed::Array(ssl)); + + let project_data = r#"{ + "id": 17, + "default_branch": "mymaster", + "visibility": "private", + "http_url_to_repo": "https://gitlab.mycompany.local/mygroup/myproject", + "ssh_url_to_repo": "git@gitlab.mycompany.local:mygroup/myproject.git", + "last_activity_at": "2014-12-01T09:17:51.000+01:00", + "name": "My Project", + "name_with_namespace": "My Group / My Project", + "path": "myproject", + "path_with_namespace": "mygroup/myproject", + "web_url": "https://gitlab.mycompany.local/mygroup/myproject" +}"#; + + let (http_downloader, _guard) = http_mock_with_body( + "https://gitlab.mycompany.local/api/v4/projects/mygroup%2Fmyproject", + project_data, + ); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert( + "url".to_string(), + PhpMixed::String("https://gitlab.mycompany.local/mygroup/myproject".to_string()), + ); + repo_config.insert("options".to_string(), PhpMixed::Array(options)); + let mut driver = GitLabDriver::new( + repo_config, + fixtures.io.clone(), + fixtures.config.clone(), + http_downloader, + fixtures.process.clone(), + ); + driver.initialize().unwrap(); } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock) and the IOInterface MockObject are not ported"] fn test_protocol_override_repository_url_generation() { - todo!() + let fixtures = fixtures(); + // @link http://doc.gitlab.com/ce/api/projects.html#get-single-project + let project_data = r#"{ + "id": 17, + "default_branch": "mymaster", + "visibility": "private", + "http_url_to_repo": "https://gitlab.com/mygroup/myproject.git", + "ssh_url_to_repo": "git@gitlab.com:mygroup/myproject.git", + "last_activity_at": "2014-12-01T09:17:51.000+01:00", + "name": "My Project", + "name_with_namespace": "My Group / My Project", + "path": "myproject", + "path_with_namespace": "mygroup/myproject", + "web_url": "https://gitlab.com/mygroup/myproject" +}"#; + + let api_url = "https://gitlab.com/api/v4/projects/mygroup%2Fmyproject"; + let url = "git@gitlab.com:mygroup/myproject"; + + let (http_downloader, _guard) = http_mock_with_body(api_url, project_data); + + // clone $this->config and merge gitlab-protocol => http. + let mut config = make_config(); + let mut top: IndexMap<String, PhpMixed> = IndexMap::new(); + let mut config_section: IndexMap<String, PhpMixed> = IndexMap::new(); + config_section.insert( + "gitlab-protocol".to_string(), + PhpMixed::String("http".to_string()), + ); + top.insert("config".to_string(), PhpMixed::Array(config_section)); + config.merge(&top, Config::SOURCE_UNKNOWN); + let config = Rc::new(RefCell::new(config)); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(url.to_string())); + let mut driver = GitLabDriver::new( + repo_config, + fixtures.io.clone(), + config, + http_downloader, + fixtures.process.clone(), + ); + driver.initialize().unwrap(); + assert_eq!( + "https://gitlab.com/mygroup/myproject.git", + driver.get_repository_url(), + "Repository URL matches config request for http not git" + ); } diff --git a/crates/shirabe/tests/util/git_test.rs b/crates/shirabe/tests/util/git_test.rs index 40ca9da..6d615c5 100644 --- a/crates/shirabe/tests/util/git_test.rs +++ b/crates/shirabe/tests/util/git_test.rs @@ -1,55 +1,489 @@ //! ref: composer/tests/Composer/Test/Util/GitTest.php -// These mock IO/Config/ProcessExecutor to drive Git::runCommand and mirror syncing; mocking -// is not available here. +use std::cell::RefCell; +use std::rc::Rc; -#[allow(dead_code)] -fn set_up() { - // Builds mocked IO/Config/ProcessExecutor/Filesystem and a real Git; mocking is not available. - todo!() +use indexmap::IndexMap; +use shirabe::config::Config; +use shirabe::io::IOInterface; +use shirabe::util::filesystem::Filesystem; +use shirabe::util::git::Git; +use shirabe::util::process_executor::{MockExpectation, MockHandler, ProcessExecutor}; +use shirabe_php_shim::{PhpMixed, RuntimeException}; + +use crate::config_stub::ConfigStubBuilder; +use crate::io_stub::IOStub; +use crate::process_executor_mock::{cmd, cmd_full, get_process_executor_mock}; + +// PHP's `commandCallable` returns a bare string (`'git command'`); Rust's `run_command` +// flattens each callable to a `Vec<String>` and hands it to `execute_args`, which always +// builds a `PhpMixed::List`. So the single-token string command becomes a one-element list, +// and the corresponding process expectation is a one-element list as well. +fn build_git(io: IOStub, config: Config, process: Rc<RefCell<ProcessExecutor>>) -> Git { + let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(io)); + let config = Rc::new(RefCell::new(config)); + let fs = Rc::new(RefCell::new(Filesystem::new(None))); + Git::new(io, config, process, fs) } +fn mock_config(protocol: &str) -> Config { + ConfigStubBuilder::new() + .with( + "github-domains", + PhpMixed::List(vec![PhpMixed::String("github.com".to_string())]), + ) + .with( + "github-protocols", + PhpMixed::List(vec![PhpMixed::String(protocol.to_string())]), + ) + .build() +} + +fn mock_sync_mirror_config() -> Config { + ConfigStubBuilder::new() + .with( + "github-domains", + PhpMixed::List(vec![PhpMixed::String("github.com".to_string())]), + ) + .with( + "gitlab-domains", + PhpMixed::List(vec![PhpMixed::String("gitlab.com".to_string())]), + ) + .with( + "github-protocols", + PhpMixed::List(vec![PhpMixed::String("https".to_string())]), + ) + .build() +} + +// publicGithubNoCredentialsProvider: ['ssh', 'git@github.com:acme/repo'] #[test] -#[ignore = "requires mocked Config (getMockBuilder Config) and getProcessExecutorMock with expects() command expectations; no mocking infrastructure exists"] -fn test_run_command_public_git_hub_repository_not_initial_clone() { - todo!() +fn test_run_command_public_git_hub_repository_not_initial_clone_ssh() { + let expected_url = "git@github.com:acme/repo"; + let command_callable: Box<dyn Fn(&str) -> Vec<String>> = Box::new(move |url: &str| { + assert_eq!(expected_url, url); + vec!["git command".to_string()] + }); + + let config = mock_config("ssh"); + + let (process, _guard) = + get_process_executor_mock(vec![cmd(vec!["git command"])], true, MockHandler::default()); + + let mut git = build_git(IOStub::new(), config, process); + + git.__run_command( + vec![command_callable], + "https://github.com/acme/repo", + None, + true, + None, + ) + .unwrap(); } +// publicGithubNoCredentialsProvider: ['https', 'https://github.com/acme/repo'] #[test] -#[ignore = "requires mocked Config and getProcessExecutorMock with expects() command expectations; no mocking infrastructure exists"] +fn test_run_command_public_git_hub_repository_not_initial_clone_https() { + let expected_url = "https://github.com/acme/repo"; + let command_callable: Box<dyn Fn(&str) -> Vec<String>> = Box::new(move |url: &str| { + assert_eq!(expected_url, url); + vec!["git command".to_string()] + }); + + let config = mock_config("https"); + + let (process, _guard) = + get_process_executor_mock(vec![cmd(vec!["git command"])], true, MockHandler::default()); + + let mut git = build_git(IOStub::new(), config, process); + + git.__run_command( + vec![command_callable], + "https://github.com/acme/repo", + None, + true, + None, + ) + .unwrap(); +} + +#[test] +#[ignore = "reaches Git::throw_exception -> Url::sanitize, whose preg pattern fails to parse in the regex crate (preg shim bug, unrelated to this test); production Url::sanitize must be fixed first"] fn test_run_command_private_git_hub_repository_not_initial_clone_not_interactive_without_authentication() { - todo!() + let command_callable: Box<dyn Fn(&str) -> Vec<String>> = Box::new(|url: &str| { + assert_eq!("https://github.com/acme/repo", url); + vec!["git command".to_string()] + }); + + let config = mock_config("https"); + + let (process, _guard) = get_process_executor_mock( + vec![ + cmd_full(vec!["git command"], 1, "", ""), + cmd_full(vec!["git", "--version"], 0, "", ""), + ], + true, + MockHandler::default(), + ); + + let mut git = build_git(IOStub::new(), config, process); + + let result = git.__run_command( + vec![command_callable], + "https://github.com/acme/repo", + None, + true, + None, + ); + + let err = result.expect_err("expected a RuntimeException"); + assert!(err.downcast_ref::<RuntimeException>().is_some()); +} + +// privateGithubWithCredentialsProvider helper. +fn run_command_private_github_with_authentication( + git_url: &str, + protocol: &str, + git_hub_token: &str, + expected_url: &str, + expected_failures_before_success: usize, +) { + let expected_url_owned = expected_url.to_string(); + let command_callable: Box<dyn Fn(&str) -> Vec<String>> = Box::new(move |url: &str| { + if url != expected_url_owned { + return vec!["git command failing".to_string()]; + } + vec!["git command ok".to_string()] + }); + + let config = mock_config(protocol); + + let mut expected_calls: Vec<MockExpectation> = Vec::new(); + for _ in 0..expected_failures_before_success { + expected_calls.push(cmd_full(vec!["git command failing"], 1, "", "")); + } + expected_calls.push(cmd_full(vec!["git command ok"], 0, "", "")); + + let (process, _guard) = get_process_executor_mock(expected_calls, true, MockHandler::default()); + + let mut auth: IndexMap<String, Option<String>> = IndexMap::new(); + auth.insert("username".to_string(), Some("token".to_string())); + auth.insert("password".to_string(), Some(git_hub_token.to_string())); + + let io = IOStub::new() + .with_is_interactive(false) + .with_has_authentication(true) + .with_get_authentication(auth); + + let mut git = build_git(io, config, process); + + git.__run_command(vec![command_callable], git_url, None, true, None) + .unwrap(); } #[test] -#[ignore = "requires getProcessExecutorMock and mocked IO (getMockBuilder IOInterface with hasAuthentication/getAuthentication/isInteractive expectations); no mocking infrastructure exists"] -fn test_run_command_private_git_hub_repository_not_initial_clone_not_interactive_with_authentication() +fn test_run_command_private_git_hub_repository_not_initial_clone_not_interactive_with_authentication_ssh() { - todo!() + run_command_private_github_with_authentication( + "git@github.com:acme/repo.git", + "ssh", + "MY_GITHUB_TOKEN", + "https://token:MY_GITHUB_TOKEN@github.com/acme/repo.git", + 1, + ); } #[test] -#[ignore = "requires getProcessExecutorMock and mocked IO/Config (getMockBuilder with hasAuthentication/getAuthentication/isInteractive expectations); no mocking infrastructure exists"] -fn test_run_command_private_bitbucket_repository_not_initial_clone_not_interactive_with_authentication() +fn test_run_command_private_git_hub_repository_not_initial_clone_not_interactive_with_authentication_https() { - todo!() + run_command_private_github_with_authentication( + "https://github.com/acme/repo", + "https", + "MY_GITHUB_TOKEN", + "https://token:MY_GITHUB_TOKEN@github.com/acme/repo.git", + 2, + ); +} + +// privateBitbucketWithCredentialsProvider helper. +fn run_command_private_bitbucket_with_authentication( + git_url: &str, + bitbucket_token: Option<&str>, + expected_url: &str, + expected_failures_before_success: usize, + bitbucket_git_auth_calls: usize, +) { + let expected_url_owned = expected_url.to_string(); + let command_callable: Box<dyn Fn(&str) -> Vec<String>> = Box::new(move |url: &str| { + if url != expected_url_owned { + return vec!["git command failing".to_string()]; + } + vec!["git command ok".to_string()] + }); + + let config = ConfigStubBuilder::new() + .with( + "gitlab-domains", + PhpMixed::List(vec![PhpMixed::String("gitlab.com".to_string())]), + ) + .with( + "github-domains", + PhpMixed::List(vec![PhpMixed::String("github.com".to_string())]), + ) + .build(); + + let mut expected_calls: Vec<MockExpectation> = Vec::new(); + for _ in 0..expected_failures_before_success { + expected_calls.push(cmd_full(vec!["git command failing"], 1, "", "")); + } + if bitbucket_git_auth_calls > 0 { + for _ in 0..bitbucket_git_auth_calls { + expected_calls.push(cmd_full( + vec!["git", "config", "bitbucket.accesstoken"], + 1, + "", + "", + )); + } + } + expected_calls.push(cmd_full(vec!["git command ok"], 0, "", "")); + + let (process, _guard) = get_process_executor_mock(expected_calls, true, MockHandler::default()); + + let mut io = IOStub::new().with_is_interactive(false); + if let Some(token) = bitbucket_token { + let mut auth: IndexMap<String, Option<String>> = IndexMap::new(); + auth.insert("username".to_string(), Some("token".to_string())); + auth.insert("password".to_string(), Some(token.to_string())); + io = io + .with_has_authentication(true) + .with_get_authentication(auth); + } + + let mut git = build_git(io, config, process); + + git.__run_command(vec![command_callable], git_url, None, true, None) + .unwrap(); +} + +#[test] +#[ignore = "after the first failing git command, Bitbucket::new constructs a real HttpDownloader -> CurlDownloader::new -> curl_multi_init(), which is todo!() in the curl shim; needs the curl shim or an injected HttpDownloader mock on Git"] +fn test_run_command_private_bitbucket_repository_not_initial_clone_not_interactive_with_authentication_ssh_token() + { + run_command_private_bitbucket_with_authentication( + "git@bitbucket.org:acme/repo.git", + Some("MY_BITBUCKET_TOKEN"), + "https://token:MY_BITBUCKET_TOKEN@bitbucket.org/acme/repo.git", + 1, + 0, + ); +} + +#[test] +#[ignore = "after the first failing git command, Bitbucket::new constructs a real HttpDownloader -> CurlDownloader::new -> curl_multi_init(), which is todo!() in the curl shim; needs the curl shim or an injected HttpDownloader mock on Git"] +fn test_run_command_private_bitbucket_repository_not_initial_clone_not_interactive_with_authentication_https_token() + { + run_command_private_bitbucket_with_authentication( + "https://bitbucket.org/acme/repo", + Some("MY_BITBUCKET_TOKEN"), + "https://token:MY_BITBUCKET_TOKEN@bitbucket.org/acme/repo.git", + 1, + 0, + ); +} + +#[test] +#[ignore = "after the first failing git command, Bitbucket::new constructs a real HttpDownloader -> CurlDownloader::new -> curl_multi_init(), which is todo!() in the curl shim; needs the curl shim or an injected HttpDownloader mock on Git"] +fn test_run_command_private_bitbucket_repository_not_initial_clone_not_interactive_with_authentication_https_git_token() + { + run_command_private_bitbucket_with_authentication( + "https://bitbucket.org/acme/repo.git", + Some("MY_BITBUCKET_TOKEN"), + "https://token:MY_BITBUCKET_TOKEN@bitbucket.org/acme/repo.git", + 1, + 0, + ); +} + +#[test] +fn test_run_command_private_bitbucket_repository_not_initial_clone_not_interactive_with_authentication_ssh_no_token() + { + run_command_private_bitbucket_with_authentication( + "git@bitbucket.org:acme/repo.git", + None, + "git@bitbucket.org:acme/repo.git", + 0, + 0, + ); } #[test] -#[ignore = "requires getProcessExecutorMock, getHttpDownloaderMock and mocked IO/Config with askConfirmation/askAndHideAnswer/setAuthentication expectations; no mocking infrastructure exists"] +#[ignore = "after the first failing git command, Bitbucket::new constructs a real HttpDownloader -> CurlDownloader::new -> curl_multi_init(), which is todo!() in the curl shim; needs the curl shim or an injected HttpDownloader mock on Git"] +fn test_run_command_private_bitbucket_repository_not_initial_clone_not_interactive_with_authentication_https_no_token() + { + run_command_private_bitbucket_with_authentication( + "https://bitbucket.org/acme/repo", + None, + "git@bitbucket.org:acme/repo.git", + 1, + 1, + ); +} + +#[test] +#[ignore = "after the first failing git command, Bitbucket::new constructs a real HttpDownloader -> CurlDownloader::new -> curl_multi_init(), which is todo!() in the curl shim; needs the curl shim or an injected HttpDownloader mock on Git"] +fn test_run_command_private_bitbucket_repository_not_initial_clone_not_interactive_with_authentication_https_git_no_token() + { + run_command_private_bitbucket_with_authentication( + "https://bitbucket.org/acme/repo.git", + None, + "git@bitbucket.org:acme/repo.git", + 1, + 1, + ); +} + +#[test] +#[ignore = "after the first failing git command, Bitbucket::new constructs a real HttpDownloader -> CurlDownloader::new -> curl_multi_init(), which is todo!() in the curl shim; needs the curl shim or an injected HttpDownloader mock on Git"] +fn test_run_command_private_bitbucket_repository_not_initial_clone_not_interactive_with_authentication_atat_token() + { + run_command_private_bitbucket_with_authentication( + "https://bitbucket.org/acme/repo.git", + Some("ATAT_BITBUCKET_API_TOKEN"), + "https://x-bitbucket-api-token-auth:ATAT_BITBUCKET_API_TOKEN@bitbucket.org/acme/repo.git", + 1, + 0, + ); +} + +#[test] +#[ignore = "interactive Bitbucket OAuth flow needs IOStub::askConfirmation/askAndHideAnswer/setAuthentication stateful callbacks and getHttpDownloaderMock; IOStub only supports fixed willReturn values, not the willReturnCallback-based stateful initial_config mutation this test relies on"] fn test_run_command_private_bitbucket_repository_not_initial_clone_interactive_with_oauth() { todo!() } #[test] -#[ignore = "requires getProcessExecutorMock with expects() command expectations and mocked Config/Filesystem (getMockBuilder removeDirectory); no mocking infrastructure exists"] fn test_sync_mirror_sanitizes_url_after_initial_clone() { - todo!() + let non_existent_dir = format!( + "{}/composer-test-nonexistent-{}", + std::env::temp_dir().display(), + rand_hex() + ); + + let config = mock_sync_mirror_config(); + + let (process, _guard) = get_process_executor_mock( + vec![ + cmd_full( + vec![ + "git", + "clone", + "--mirror", + "--", + "https://user:secret@example.com/repo.git", + &non_existent_dir, + ], + 0, + "", + "", + ), + cmd_full(vec!["git", "remote", "-v"], 0, "", ""), + cmd_full( + vec![ + "git", + "remote", + "set-url", + "origin", + "--", + "https://example.com/repo.git", + ], + 0, + "", + "", + ), + ], + true, + MockHandler::default(), + ); + + let mut git = build_git(IOStub::new(), config, process); + + let result = git + .sync_mirror( + "https://user:secret@example.com/repo.git", + &non_existent_dir, + ) + .unwrap(); + + assert!(result); } #[test] -#[ignore = "requires getProcessExecutorMock with expects() command expectations and mocked Config; no mocking infrastructure exists"] +#[ignore = "the failed-update branch reaches Git::throw_exception -> Url::sanitize, whose preg pattern fails to parse in the regex crate (preg shim bug, unrelated to this test); production Url::sanitize must be fixed first"] fn test_sync_mirror_sanitizes_url_even_after_failed_update() { - todo!() + let dir = std::env::temp_dir().display().to_string(); + + let config = mock_sync_mirror_config(); + + let (process, _guard) = get_process_executor_mock( + vec![ + cmd_full(vec!["git", "rev-parse", "--git-dir"], 0, ".\n", ""), + cmd_full(vec!["git", "remote", "-v"], 0, "", ""), + cmd_full( + vec![ + "git", + "remote", + "set-url", + "origin", + "--", + "https://user:secret@example.com/repo.git", + ], + 0, + "", + "", + ), + cmd_full( + vec!["git", "remote", "update", "--prune", "origin"], + 1, + "", + "", + ), + cmd_full(vec!["git", "--version"], 0, "", ""), + cmd_full(vec!["git", "remote", "-v"], 0, "", ""), + cmd_full( + vec![ + "git", + "remote", + "set-url", + "origin", + "--", + "https://example.com/repo.git", + ], + 0, + "", + "", + ), + ], + true, + MockHandler::default(), + ); + + let mut git = build_git(IOStub::new(), config, process); + + let result = git + .sync_mirror("https://user:secret@example.com/repo.git", &dir) + .unwrap(); + + assert!(!result); +} + +fn rand_hex() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("{:016x}", nanos as u64) } diff --git a/crates/shirabe/tests/util/url_test.rs b/crates/shirabe/tests/util/url_test.rs index 4adb4f2..3691d26 100644 --- a/crates/shirabe/tests/util/url_test.rs +++ b/crates/shirabe/tests/util/url_test.rs @@ -143,7 +143,6 @@ fn dist_refs_provider() -> Vec<( } #[test] -#[ignore] fn test_sanitize() { for (expected, url) in sanitize_provider() { assert_eq!(expected, Url::sanitize(url.to_string())); |
