aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--crates/shirabe-external-packages/src/symfony/console/question/question.rs12
-rw-r--r--crates/shirabe-php-shim/src/datetime.rs99
-rw-r--r--crates/shirabe-php-shim/src/zip.rs46
-rw-r--r--crates/shirabe/src/downloader/zip_downloader.rs24
-rw-r--r--crates/shirabe/src/installer/library_installer.rs26
-rw-r--r--crates/shirabe/src/package/handle.rs9
-rw-r--r--crates/shirabe/src/util/auth_helper.rs16
-rw-r--r--crates/shirabe/tests/common/downloader_stub.rs129
-rw-r--r--crates/shirabe/tests/downloader/zip_downloader_test.rs186
-rw-r--r--crates/shirabe/tests/installer/library_installer_test.rs168
-rw-r--r--crates/shirabe/tests/installer/main.rs2
-rw-r--r--crates/shirabe/tests/io/console_io_test.rs165
-rw-r--r--crates/shirabe/tests/repository/vcs/git_bitbucket_driver_test.rs341
-rw-r--r--crates/shirabe/tests/util/auth_helper_test.rs569
-rw-r--r--crates/shirabe/tests/util/main.rs2
15 files changed, 1558 insertions, 236 deletions
diff --git a/crates/shirabe-external-packages/src/symfony/console/question/question.rs b/crates/shirabe-external-packages/src/symfony/console/question/question.rs
index c93a71e..2ff5f62 100644
--- a/crates/shirabe-external-packages/src/symfony/console/question/question.rs
+++ b/crates/shirabe-external-packages/src/symfony/console/question/question.rs
@@ -304,10 +304,16 @@ impl Question {
}
// PHP: `(bool) \count(array_filter(array_keys($array), 'is_string'))`.
- // PhpMixed models a PHP array as either `List` (sequential int keys) or `Array`
- // (string-keyed map), so the "has string keys" test reduces to the `Array` variant.
+ // A `List` has only sequential int keys, so it is never associative. An `Array` is
+ // associative only when at least one key is a genuine string key; PHP normalizes
+ // canonical-integer string keys (e.g. "0", "12") back to int keys, so those do not count.
+ // The same heuristic (a key is "string" iff it does not parse as an i64) is used by
+ // ConsoleIO::select when computing `$isAssoc` over the choice map.
pub(crate) fn is_assoc(array: &PhpMixed) -> bool {
- matches!(array, PhpMixed::Array(_))
+ match array {
+ PhpMixed::Array(map) => map.keys().any(|key| key.parse::<i64>().is_err()),
+ _ => false,
+ }
}
pub fn is_trimmable(&self) -> bool {
diff --git a/crates/shirabe-php-shim/src/datetime.rs b/crates/shirabe-php-shim/src/datetime.rs
index e41028e..f44bb7b 100644
--- a/crates/shirabe-php-shim/src/datetime.rs
+++ b/crates/shirabe-php-shim/src/datetime.rs
@@ -1,11 +1,55 @@
static DEFAULT_TIMEZONE: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
-pub fn date_create<Tz: chrono::TimeZone>(s: &str) -> chrono::ParseResult<chrono::DateTime<Tz>> {
- // TODO(phase-d): PHP `date_create` accepts the full strtotime() grammar (RFC2822, ISO8601,
- // VCS-specific and relative formats), which requires a dedicated date parser not available
- // here. The generic `Tz` also has no constructor from a parsed `FixedOffset`/`Utc` value.
- let _ = s;
- todo!()
+/// Parse the subset of the strtotime()/date_create() grammar that Composer actually emits.
+///
+/// Supported: ISO8601/RFC3339 (`2023-01-15T12:34:56Z`, `...+00:00`), `Y-m-d H:i:s`, `Y-m-d`,
+/// and `@<unixtime>`. Inputs without an explicit offset are interpreted as UTC, matching the
+/// default timezone this shim assumes elsewhere. Anything else returns `None` rather than
+/// guessing, mirroring PHP returning `false` on unrecognized input.
+fn parse_to_fixed(s: &str) -> Option<chrono::DateTime<chrono::FixedOffset>> {
+ let s = s.trim();
+ if s.is_empty() {
+ return None;
+ }
+
+ // `@<unixtime>` (optionally with a fractional part) denotes a Unix timestamp in UTC.
+ if let Some(rest) = s.strip_prefix('@') {
+ let secs = if let Some((whole, _frac)) = rest.split_once('.') {
+ whole.parse::<i64>().ok()?
+ } else {
+ rest.parse::<i64>().ok()?
+ };
+ let utc = chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0)?;
+ return Some(utc.fixed_offset());
+ }
+
+ // RFC3339 / ISO8601 with an explicit offset or trailing `Z`.
+ if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
+ return Some(dt);
+ }
+
+ // `Y-m-d H:i:s` and `Y-m-d`, both interpreted as UTC.
+ let naive = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
+ .ok()
+ .or_else(|| {
+ chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
+ .ok()
+ .and_then(|d| d.and_hms_opt(0, 0, 0))
+ })?;
+ let utc = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive, chrono::Utc);
+ Some(utc.fixed_offset())
+}
+
+pub fn date_create<Tz: chrono::TimeZone>(s: &str) -> chrono::ParseResult<chrono::DateTime<Tz>>
+where
+ chrono::DateTime<Tz>: From<chrono::DateTime<chrono::FixedOffset>>,
+{
+ match parse_to_fixed(s) {
+ Some(dt) => Ok(dt.into()),
+ // PHP `date_create` returns `false` here; the closest faithful signal under chrono's
+ // `ParseResult` is a parse error, produced by replaying a deliberately invalid parse.
+ None => Err(chrono::DateTime::parse_from_rfc3339("").unwrap_err()),
+ }
}
/// PHP: \DATE_RFC3339 ("Y-m-d\TH:i:sP").
@@ -26,10 +70,45 @@ pub fn date_format_to_strftime(format: &str) -> &'static str {
}
}
-pub fn strtotime(_time: &str) -> Option<i64> {
- // TODO(phase-d): requires the full strtotime() grammar (absolute, relative and compound
- // expressions); a partial parser would silently mis-handle unsupported inputs.
- todo!()
+/// Subset of strtotime() covering what Composer passes: `now`, the absolute formats handled by
+/// `parse_to_fixed`, and simple single-unit relative offsets such as `-8 days` / `+3 hours`.
+///
+/// Returns `None` for anything else, matching PHP `strtotime()` returning `false`, rather than
+/// silently mis-handling an unsupported expression.
+pub fn strtotime(time: &str) -> Option<i64> {
+ let trimmed = time.trim();
+
+ if trimmed.eq_ignore_ascii_case("now") {
+ return Some(self::time());
+ }
+
+ if let Some(dt) = parse_to_fixed(trimmed) {
+ return Some(dt.timestamp());
+ }
+
+ parse_relative(trimmed).map(|delta| self::time() + delta)
+}
+
+/// Parse a single-unit relative offset like `-8 days`, `+3hours`, `1 week`, returning the signed
+/// number of seconds. Whitespace between the count and unit is optional, as PHP accepts both.
+fn parse_relative(s: &str) -> Option<i64> {
+ let s = s.trim();
+ let split = s
+ .find(|c: char| c.is_ascii_alphabetic())
+ .filter(|&i| i > 0)?;
+ let (count_part, unit_part) = s.split_at(split);
+ let count: i64 = count_part.trim().parse().ok()?;
+
+ let unit = unit_part.trim().to_ascii_lowercase();
+ let per_unit = match unit.as_str() {
+ "sec" | "secs" | "second" | "seconds" => 1,
+ "min" | "mins" | "minute" | "minutes" => 60,
+ "hour" | "hours" => 60 * 60,
+ "day" | "days" => 24 * 60 * 60,
+ "week" | "weeks" => 7 * 24 * 60 * 60,
+ _ => return None,
+ };
+ Some(count * per_unit)
}
pub fn time() -> i64 {
diff --git a/crates/shirabe-php-shim/src/zip.rs b/crates/shirabe-php-shim/src/zip.rs
index 6b4d87b..d1db9e5 100644
--- a/crates/shirabe-php-shim/src/zip.rs
+++ b/crates/shirabe-php-shim/src/zip.rs
@@ -1,9 +1,21 @@
+use crate::ErrorException;
use crate::PhpMixed;
use crate::{StreamBacking, StreamState};
use indexmap::IndexMap;
use std::cell::RefCell;
use zip::write::SimpleFileOptions;
+/// Test-only behaviour mirroring PHPUnit's `getMockBuilder('ZipArchive')->getMock()`, where
+/// `open`/`extractTo`/`count` are stubbed via `->willReturn(...)`/`->willThrowException(...)`.
+/// Held in [`ZipArchive::mock`]; always `None` in production.
+#[derive(Debug, Clone)]
+pub struct ZipArchiveMock {
+ pub open: Result<(), i64>,
+ pub count: i64,
+ /// `Ok(bool)` for `extractTo` returning a bool, `Err(..)` to throw an `\ErrorException`.
+ pub extract_to: Result<bool, String>,
+}
+
/// Internal backing of an opened `ZipArchive`. PHP's `ZipArchive` multiplexes
/// reading an existing archive and building a new one through the same handle;
/// the `zip` crate splits these into `ZipArchive` (read) and `ZipWriter` (write),
@@ -25,6 +37,8 @@ enum ZipState {
pub struct ZipArchive {
pub num_files: i64,
state: RefCell<ZipState>,
+ /// Test-only mock state. `None` in production; set via [`ZipArchive::__mock`] in tests.
+ mock: Option<ZipArchiveMock>,
}
impl Default for ZipArchive {
@@ -38,10 +52,24 @@ impl ZipArchive {
Self {
num_files: 0,
state: RefCell::new(ZipState::Closed),
+ mock: None,
+ }
+ }
+
+ /// For testing only. Builds a mocked ZipArchive whose `open`/`count`/`extract_to` return the
+ /// configured values, mirroring PHPUnit's `getMockBuilder('ZipArchive')->getMock()`.
+ pub fn __mock(mock: ZipArchiveMock) -> Self {
+ Self {
+ num_files: mock.count,
+ state: RefCell::new(ZipState::Closed),
+ mock: Some(mock),
}
}
pub fn open(&mut self, filename: &str, flags: i64) -> Result<(), i64> {
+ if let Some(mock) = &self.mock {
+ return mock.open;
+ }
if flags & Self::CREATE != 0 {
let file = match std::fs::File::create(filename) {
Ok(f) => f,
@@ -79,6 +107,9 @@ impl ZipArchive {
}
pub fn count(&self) -> i64 {
+ if let Some(mock) = &self.mock {
+ return mock.count;
+ }
self.num_files
}
@@ -113,12 +144,21 @@ impl ZipArchive {
Some(stat)
}
- pub fn extract_to(&self, path: &str) -> bool {
+ pub fn extract_to(&self, path: &str) -> Result<bool, ErrorException> {
+ if let Some(mock) = &self.mock {
+ return mock.extract_to.clone().map_err(|message| ErrorException {
+ message,
+ code: 0,
+ severity: 1,
+ filename: String::new(),
+ lineno: 0,
+ });
+ }
let mut state = self.state.borrow_mut();
let ZipState::Reader(archive) = &mut *state else {
- return false;
+ return Ok(false);
};
- archive.extract(path).is_ok()
+ Ok(archive.extract(path).is_ok())
}
pub fn locate_name(&self, name: &str) -> Option<i64> {
diff --git a/crates/shirabe/src/downloader/zip_downloader.rs b/crates/shirabe/src/downloader/zip_downloader.rs
index bea2b20..80ce6f9 100644
--- a/crates/shirabe/src/downloader/zip_downloader.rs
+++ b/crates/shirabe/src/downloader/zip_downloader.rs
@@ -332,7 +332,7 @@ impl ZipDownloader {
}
}
- let extract_result = zip_archive.extract_to(path);
+ let extract_result = zip_archive.extract_to(path)?;
if extract_result {
zip_archive.close();
@@ -393,6 +393,28 @@ impl ZipDownloader {
),
}
}
+
+ /// For testing only. Mirrors the test's `setPrivateProperty('hasZipArchive', ...)` reflection on
+ /// the `ZipDownloader::$hasZipArchive` static.
+ pub fn __set_has_zip_archive(value: Option<bool>) {
+ *HAS_ZIP_ARCHIVE.lock().unwrap() = value;
+ }
+
+ /// For testing only. Mirrors the test's `setPrivateProperty('isWindows', ...)` reflection.
+ pub fn __set_is_windows(value: Option<bool>) {
+ *IS_WINDOWS.lock().unwrap() = value;
+ }
+
+ /// For testing only. Mirrors the test's `setPrivateProperty('unzipCommands', ...)` reflection.
+ pub fn __set_unzip_commands(value: Option<Vec<Vec<String>>>) {
+ *UNZIP_COMMANDS.lock().unwrap() = value;
+ }
+
+ /// For testing only. Mirrors the test's `setPrivateProperty('zipArchiveObject', $zipArchive, $obj)`
+ /// reflection on the instance's `$zipArchiveObject` property.
+ pub fn __set_zip_archive_object(&mut self, value: Option<ZipArchive>) {
+ self.zip_archive_object = value;
+ }
}
impl ArchiveDownloader for ZipDownloader {
diff --git a/crates/shirabe/src/installer/library_installer.rs b/crates/shirabe/src/installer/library_installer.rs
index d2f222e..d716814 100644
--- a/crates/shirabe/src/installer/library_installer.rs
+++ b/crates/shirabe/src/installer/library_installer.rs
@@ -63,21 +63,23 @@ impl LibraryInstaller {
Some("/"),
);
let binary_installer = binary_installer.unwrap_or_else(|| {
- BinaryInstaller::new(
- io.clone(),
- rtrim(
- &composer_ref
- .get_config()
- .borrow_mut()
- .get_str("bin-dir")
- .unwrap_or_default(),
- Some("/"),
- ),
- composer_ref
+ let bin_dir = rtrim(
+ &composer_ref
.get_config()
.borrow_mut()
- .get_str("bin-compat")
+ .get_str("bin-dir")
.unwrap_or_default(),
+ Some("/"),
+ );
+ let bin_compat = composer_ref
+ .get_config()
+ .borrow_mut()
+ .get_str("bin-compat")
+ .unwrap_or_default();
+ BinaryInstaller::new(
+ io.clone(),
+ bin_dir,
+ bin_compat,
Some(filesystem.clone()),
Some(vendor_dir.clone()),
)
diff --git a/crates/shirabe/src/package/handle.rs b/crates/shirabe/src/package/handle.rs
index 8a6ed83..9405692 100644
--- a/crates/shirabe/src/package/handle.rs
+++ b/crates/shirabe/src/package/handle.rs
@@ -1231,6 +1231,15 @@ macro_rules! impl_real_package_test_setters {
.expect("real package handle invariant")
.set_dist_sha1_checksum(sha1checksum);
}
+
+ /// For testing only: mirrors PHP `Package::setTargetDir`.
+ pub fn __set_target_dir(&self, target_dir: Option<String>) {
+ self.0
+ .borrow_mut()
+ .as_package_mut()
+ .expect("real package handle invariant")
+ .set_target_dir(target_dir);
+ }
}
};
}
diff --git a/crates/shirabe/src/util/auth_helper.rs b/crates/shirabe/src/util/auth_helper.rs
index 0b1620d..f386b1e 100644
--- a/crates/shirabe/src/util/auth_helper.rs
+++ b/crates/shirabe/src/util/auth_helper.rs
@@ -534,14 +534,14 @@ impl AuthHelper {
true,
) && in_array(
PhpMixed::String(origin.to_string()),
- &PhpMixed::List(
- self.config
- .borrow_mut()
- .get("gitlab-domains")
- .as_array()
- .map(|a| a.values().map(|v| v.clone()).collect())
- .unwrap_or_default(),
- ),
+ &PhpMixed::List({
+ let gitlab_domains = self.config.borrow_mut().get("gitlab-domains");
+ match &gitlab_domains {
+ PhpMixed::List(l) => l.clone(),
+ PhpMixed::Array(a) => a.values().cloned().collect(),
+ _ => vec![],
+ }
+ }),
true,
) {
if password == "oauth2" {
diff --git a/crates/shirabe/tests/common/downloader_stub.rs b/crates/shirabe/tests/common/downloader_stub.rs
new file mode 100644
index 0000000..0c4432b
--- /dev/null
+++ b/crates/shirabe/tests/common/downloader_stub.rs
@@ -0,0 +1,129 @@
+//! No-op DownloaderInterface stub for installer tests.
+//!
+//! PHP mocks `Composer\Downloader\DownloadManager` directly and asserts its
+//! `install`/`update`/`remove` calls. The Rust DownloadManager is a concrete type
+//! that dispatches to a registered DownloaderInterface, so the equivalent stub
+//! lives one level down: a downloader registered under some dist type that records
+//! the calls it receives and resolves to null, the same way the PHP mock returns
+//! `\React\Promise\resolve(null)`.
+#![allow(dead_code)]
+
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use shirabe::downloader::DownloaderInterface;
+use shirabe::package::PackageInterfaceHandle;
+use shirabe_php_shim::PhpMixed;
+
+/// One recorded downloader operation, capturing the package pretty-names and the
+/// target path so tests can assert what the LibraryInstaller forwarded.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum DownloaderCall {
+ Install {
+ package: String,
+ path: String,
+ },
+ Update {
+ initial: String,
+ target: String,
+ path: String,
+ },
+ Remove {
+ package: String,
+ path: String,
+ },
+}
+
+#[derive(Debug, Default)]
+pub struct DownloaderStub {
+ calls: Rc<RefCell<Vec<DownloaderCall>>>,
+}
+
+impl DownloaderStub {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Shared handle to the recorded call log, so tests can inspect it after the
+ /// stub has been moved into the DownloadManager.
+ pub fn calls(&self) -> Rc<RefCell<Vec<DownloaderCall>>> {
+ self.calls.clone()
+ }
+}
+
+#[async_trait::async_trait(?Send)]
+impl DownloaderInterface for DownloaderStub {
+ fn get_installation_source(&self) -> String {
+ "dist".to_string()
+ }
+
+ async fn download(
+ &mut self,
+ _package: PackageInterfaceHandle,
+ _path: &str,
+ _prev_package: Option<PackageInterfaceHandle>,
+ _output: bool,
+ ) -> anyhow::Result<Option<PhpMixed>> {
+ Ok(None)
+ }
+
+ async fn prepare(
+ &mut self,
+ _type: &str,
+ _package: PackageInterfaceHandle,
+ _path: &str,
+ _prev_package: Option<PackageInterfaceHandle>,
+ ) -> anyhow::Result<Option<PhpMixed>> {
+ Ok(None)
+ }
+
+ async fn install(
+ &mut self,
+ package: PackageInterfaceHandle,
+ path: &str,
+ _output: bool,
+ ) -> anyhow::Result<Option<PhpMixed>> {
+ self.calls.borrow_mut().push(DownloaderCall::Install {
+ package: package.get_pretty_name(),
+ path: path.to_string(),
+ });
+ Ok(None)
+ }
+
+ async fn update(
+ &mut self,
+ initial: PackageInterfaceHandle,
+ target: PackageInterfaceHandle,
+ path: &str,
+ ) -> anyhow::Result<Option<PhpMixed>> {
+ self.calls.borrow_mut().push(DownloaderCall::Update {
+ initial: initial.get_pretty_name(),
+ target: target.get_pretty_name(),
+ path: path.to_string(),
+ });
+ Ok(None)
+ }
+
+ async fn remove(
+ &mut self,
+ package: PackageInterfaceHandle,
+ path: &str,
+ _output: bool,
+ ) -> anyhow::Result<Option<PhpMixed>> {
+ self.calls.borrow_mut().push(DownloaderCall::Remove {
+ package: package.get_pretty_name(),
+ path: path.to_string(),
+ });
+ Ok(None)
+ }
+
+ async fn cleanup(
+ &mut self,
+ _type: &str,
+ _package: PackageInterfaceHandle,
+ _path: &str,
+ _prev_package: Option<PackageInterfaceHandle>,
+ ) -> anyhow::Result<Option<PhpMixed>> {
+ Ok(None)
+ }
+}
diff --git a/crates/shirabe/tests/downloader/zip_downloader_test.rs b/crates/shirabe/tests/downloader/zip_downloader_test.rs
index 0bffa89..055e98f 100644
--- a/crates/shirabe/tests/downloader/zip_downloader_test.rs
+++ b/crates/shirabe/tests/downloader/zip_downloader_test.rs
@@ -1,32 +1,79 @@
//! ref: composer/tests/Composer/Test/Downloader/ZipDownloaderTest.php
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use serial_test::serial;
+use shirabe::config::Config;
+use shirabe::downloader::ArchiveDownloader;
+use shirabe::downloader::zip_downloader::ZipDownloader;
+use shirabe::io::IOInterface;
+use shirabe::package::handle::{CompletePackageHandle, PackageInterfaceHandle};
+use shirabe::util::HttpDownloader;
+use shirabe::util::ProcessExecutor;
use shirabe::util::filesystem::Filesystem;
+use shirabe_php_shim::{ZipArchive, ZipArchiveMock};
+use shirabe_semver::VersionParser;
use tempfile::TempDir;
+use crate::io_stub::IOStub;
+
+fn run<F: std::future::Future>(future: F) -> F::Output {
+ tokio::runtime::Builder::new_current_thread()
+ .build()
+ .unwrap()
+ .block_on(future)
+}
+
struct SetUp {
test_dir: TempDir,
+ io: Rc<RefCell<dyn IOInterface>>,
+ config: Rc<RefCell<Config>>,
+ http_downloader: Rc<RefCell<HttpDownloader>>,
+ package: PackageInterfaceHandle,
filename: std::path::PathBuf,
}
+/// ref: ZipDownloaderTest::setUp.
+///
+/// The PHP test mocks IOInterface/Config/HttpDownloader/PackageInterface via PHPUnit. Here the
+/// IO/Config use the existing stubs, the HttpDownloader is built as a mock (no network is touched on
+/// the `extract` path exercised by the ported tests), and the package is a real CompletePackage whose
+/// `getName()` is `test/pkg`, matching the PHP mock's `->method('getName')->willReturn('test/pkg')`.
fn set_up() -> SetUp {
let test_dir = TempDir::new().unwrap();
- // The IO/Config/HttpDownloader/Package mocks are not ported; HttpDownloader construction
- // additionally reaches curl_multi_init (todo!()).
- let () = todo!();
- #[allow(unreachable_code)]
- {
- let filename = test_dir.path().join("composer-test.zip");
- std::fs::write(&filename, "zip").unwrap();
- SetUp { test_dir, filename }
+
+ let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(IOStub::new()));
+ let config = Rc::new(RefCell::new(Config::new(false, None)));
+ let dl_config = Rc::new(RefCell::new(Config::new(false, None)));
+ let http_downloader = Rc::new(RefCell::new(HttpDownloader::__new_mock(
+ io.clone(),
+ dl_config,
+ )));
+
+ let norm_version = VersionParser.normalize("1.0.0", None).unwrap();
+ let package: PackageInterfaceHandle =
+ CompletePackageHandle::new("test/pkg".to_string(), norm_version, "1.0.0".to_string())
+ .into();
+
+ let filename = test_dir.path().join("composer-test.zip");
+ std::fs::write(&filename, "zip").unwrap();
+
+ SetUp {
+ test_dir,
+ io,
+ config,
+ http_downloader,
+ package,
+ filename,
}
}
fn tear_down(test_dir: &std::path::Path) {
let mut fs = Filesystem::new(None);
fs.remove_directory(test_dir).unwrap();
- // setPrivateProperty('hasZipArchive', null) resets a ZipDownloader static via reflection;
- // the static is not reachable from a test here.
- todo!()
+ // setPrivateProperty('hasZipArchive', null)
+ ZipDownloader::__set_has_zip_archive(None);
}
struct TearDown {
@@ -45,76 +92,135 @@ impl Drop for TearDown {
}
}
-// These construct a ZipDownloader with a mocked IO/HttpDownloader/ProcessExecutor and rely
-// on ZipArchive extraction (todo!() in the php-shim) plus mocked unzip behaviour.
-#[ignore = "requires PHPUnit mocks of IOInterface/Config/PackageInterface; set_up() is todo!() and real HttpDownloader reaches curl_multi_init todo!(); no mocking framework"]
+fn make_downloader(set_up: &SetUp) -> ZipDownloader {
+ let filesystem = Rc::new(RefCell::new(Filesystem::new(None)));
+ let process = Rc::new(RefCell::new(ProcessExecutor::new(Some(set_up.io.clone()))));
+ ZipDownloader::new(
+ set_up.io.clone(),
+ set_up.config.clone(),
+ set_up.http_downloader.clone(),
+ None,
+ None,
+ filesystem,
+ process,
+ )
+}
+
+// The system-unzip / non-windows-fallback paths route through ProcessExecutor::execute_async, whose
+// mock branch is an unimplemented todo!() (no Process mock seam exists in the external-packages
+// crate). The PHP tests below mock Process/ProcessExecutor::executeAsync, which is not reproducible
+// here, so they remain ignored.
+//
+// testErrorMessages drives a real HttpDownloader + Loop (curl_multi_init todo!()), also out of reach.
+
+#[ignore = "drives a real HttpDownloader + Loop (download/install), which reaches curl_multi_init todo!()"]
#[test]
fn test_error_messages() {
let set_up = set_up();
let _tear_down = TearDown::new(set_up.test_dir.path().to_path_buf());
- let _ = (&set_up.test_dir, &set_up.filename);
+ let _ = (&set_up.io, &set_up.config, &set_up.http_downloader);
todo!()
}
-#[ignore = "requires setPrivateProperty reflection on hasZipArchive/zipArchiveObject, a MockedZipDownloader subclass, and a getMockBuilder('ZipArchive') mock; no mocking/reflection framework"]
#[test]
+#[serial]
fn test_zip_archive_only_failed() {
let set_up = set_up();
let _tear_down = TearDown::new(set_up.test_dir.path().to_path_buf());
- let _ = (&set_up.test_dir, &set_up.filename);
- todo!()
+
+ ZipDownloader::__set_has_zip_archive(Some(true));
+ let mut downloader = make_downloader(&set_up);
+ let zip_archive = ZipArchive::__mock(ZipArchiveMock {
+ open: Ok(()),
+ count: 0,
+ extract_to: Ok(false),
+ });
+ downloader.__set_zip_archive_object(Some(zip_archive));
+
+ let filename = set_up.filename.to_string_lossy().into_owned();
+ let result = run(downloader.extract(set_up.package.clone(), &filename, "vendor/dir"));
+
+ let e = result.expect_err("expected RuntimeException");
+ assert!(
+ e.to_string()
+ .contains("There was an error extracting the ZIP file"),
+ "got: {e}"
+ );
}
-#[ignore = "requires setPrivateProperty reflection on hasZipArchive/zipArchiveObject, a MockedZipDownloader subclass, and a getMockBuilder('ZipArchive') mock throwing ErrorException; no mocking/reflection framework"]
#[test]
+#[serial]
fn test_zip_archive_extract_only_failed() {
let set_up = set_up();
let _tear_down = TearDown::new(set_up.test_dir.path().to_path_buf());
- let _ = (&set_up.test_dir, &set_up.filename);
- todo!()
+
+ ZipDownloader::__set_has_zip_archive(Some(true));
+ let mut downloader = make_downloader(&set_up);
+ let zip_archive = ZipArchive::__mock(ZipArchiveMock {
+ open: Ok(()),
+ count: 0,
+ extract_to: Err("Not a directory".to_string()),
+ });
+ downloader.__set_zip_archive_object(Some(zip_archive));
+
+ let filename = set_up.filename.to_string_lossy().into_owned();
+ let result = run(downloader.extract(set_up.package.clone(), &filename, "vendor/dir"));
+
+ let e = result.expect_err("expected RuntimeException");
+ assert!(
+ e.to_string().contains(
+ "The archive for \"test/pkg\" may contain identical file names with different \
+ capitalization (which fails on case insensitive filesystems): Not a directory"
+ ),
+ "got: {e}"
+ );
}
-#[ignore = "requires setPrivateProperty reflection on hasZipArchive/zipArchiveObject, a MockedZipDownloader subclass, and a getMockBuilder('ZipArchive') mock; no mocking/reflection framework"]
#[test]
+#[serial]
fn test_zip_archive_only_good() {
let set_up = set_up();
let _tear_down = TearDown::new(set_up.test_dir.path().to_path_buf());
- let _ = (&set_up.test_dir, &set_up.filename);
- todo!()
+
+ ZipDownloader::__set_has_zip_archive(Some(true));
+ let mut downloader = make_downloader(&set_up);
+ let zip_archive = ZipArchive::__mock(ZipArchiveMock {
+ open: Ok(()),
+ count: 0,
+ extract_to: Ok(true),
+ });
+ downloader.__set_zip_archive_object(Some(zip_archive));
+
+ let filename = set_up.filename.to_string_lossy().into_owned();
+ let result = run(downloader.extract(set_up.package.clone(), &filename, "vendor/dir"));
+
+ result.expect("extract should succeed");
}
-#[ignore = "requires setPrivateProperty reflection on isWindows/hasZipArchive/unzipCommands, a MockedZipDownloader subclass, and getMockBuilder mocks of Process/ProcessExecutor::executeAsync; no mocking/reflection framework"]
+#[ignore = "routes through ProcessExecutor::execute_async whose mock branch is todo!() (no Process mock seam in external-packages)"]
#[test]
fn test_system_unzip_only_failed() {
- let set_up = set_up();
- let _tear_down = TearDown::new(set_up.test_dir.path().to_path_buf());
- let _ = (&set_up.test_dir, &set_up.filename);
+ let _ = set_up();
todo!()
}
-#[ignore = "requires setPrivateProperty reflection on isWindows/hasZipArchive/unzipCommands, a MockedZipDownloader subclass, and getMockBuilder mocks of Process/ProcessExecutor::executeAsync; no mocking/reflection framework"]
+#[ignore = "routes through ProcessExecutor::execute_async whose mock branch is todo!() (no Process mock seam in external-packages)"]
#[test]
fn test_system_unzip_only_good() {
- let set_up = set_up();
- let _tear_down = TearDown::new(set_up.test_dir.path().to_path_buf());
- let _ = (&set_up.test_dir, &set_up.filename);
+ let _ = set_up();
todo!()
}
-#[ignore = "requires setPrivateProperty reflection on isWindows/hasZipArchive/zipArchiveObject, a MockedZipDownloader subclass, and getMockBuilder mocks of Process/ProcessExecutor/ZipArchive; no mocking/reflection framework"]
+#[ignore = "routes through ProcessExecutor::execute_async whose mock branch is todo!() (no Process mock seam in external-packages)"]
#[test]
fn test_non_windows_fallback_good() {
- let set_up = set_up();
- let _tear_down = TearDown::new(set_up.test_dir.path().to_path_buf());
- let _ = (&set_up.test_dir, &set_up.filename);
+ let _ = set_up();
todo!()
}
-#[ignore = "requires setPrivateProperty reflection on isWindows/hasZipArchive/zipArchiveObject, a MockedZipDownloader subclass, and getMockBuilder mocks of Process/ProcessExecutor/ZipArchive; no mocking/reflection framework"]
+#[ignore = "routes through ProcessExecutor::execute_async whose mock branch is todo!() (no Process mock seam in external-packages)"]
#[test]
fn test_non_windows_fallback_failed() {
- let set_up = set_up();
- let _tear_down = TearDown::new(set_up.test_dir.path().to_path_buf());
- let _ = (&set_up.test_dir, &set_up.filename);
+ let _ = set_up();
todo!()
}
diff --git a/crates/shirabe/tests/installer/library_installer_test.rs b/crates/shirabe/tests/installer/library_installer_test.rs
index 62a2c0c..2e4883e 100644
--- a/crates/shirabe/tests/installer/library_installer_test.rs
+++ b/crates/shirabe/tests/installer/library_installer_test.rs
@@ -16,12 +16,21 @@ use shirabe::installer::{InstallerInterface, LibraryInstaller};
use shirabe::io::IOInterface;
use shirabe::io::null_io::NullIO;
use shirabe::repository::InstalledArrayRepository;
+use shirabe::repository::RepositoryInterface;
use shirabe::repository::WritableRepositoryInterface;
use shirabe::util::filesystem::Filesystem;
use shirabe_php_shim::PhpMixed;
+use crate::downloader_stub::{DownloaderCall, DownloaderStub};
use crate::test_case::get_package;
+fn run<F: std::future::Future>(future: F) -> F::Output {
+ tokio::runtime::Builder::new_current_thread()
+ .build()
+ .unwrap()
+ .block_on(future)
+}
+
/// Mirror of setUp(): builds the Composer/Config over temp root/vendor/bin dirs
/// plus a real DownloadManager and a NullIO. The `_composer_rc` keeps the inner
/// Rc alive for the duration of the test since LibraryInstaller only holds a weak
@@ -33,6 +42,9 @@ struct SetUp {
io: Rc<RefCell<dyn IOInterface>>,
composer: PartialComposerWeakHandle,
fs: Filesystem,
+ /// Recorded downloader operations forwarded by the LibraryInstaller, mirroring the
+ /// PHP DownloadManager mock's expectations on install/update/remove.
+ downloader_calls: Rc<RefCell<Vec<DownloaderCall>>>,
_composer_rc: ComposerHandle,
}
@@ -65,7 +77,10 @@ fn set_up() -> SetUp {
let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(NullIO::new()));
- let dm = DownloadManager::new(io.clone(), false, None);
+ let mut dm = DownloadManager::new(io.clone(), false, None);
+ let downloader = DownloaderStub::new();
+ let downloader_calls = downloader.calls();
+ dm.set_downloader("fake", Rc::new(RefCell::new(downloader)));
let dm_rc = Rc::new(RefCell::new(dm));
let composer_rc = Rc::new(RefCell::new(PartialOrFullComposer::new_full()));
@@ -82,16 +97,26 @@ fn set_up() -> SetUp {
io,
composer: weak,
fs,
+ downloader_calls,
_composer_rc: composer,
}
}
+/// Builds a `dist`-installable test package backed by the `fake` downloader stub, so the
+/// real DownloadManager dispatches install/update/remove to the recording stub instead of
+/// erroring on a package with no installation source.
+fn get_installable_package(name: &str, version: &str) -> shirabe::package::PackageInterfaceHandle {
+ let package = get_package(name, version);
+ package.set_installation_source(Some("dist".to_string()));
+ package.set_dist_type(Some("fake".to_string()));
+ package
+}
+
fn tear_down(setup: &mut SetUp) {
let root = setup.root.path().to_path_buf();
setup.fs.remove_directory(&root).ok();
}
-#[ignore]
#[test]
fn test_installer_creation_should_not_create_vendor_directory() {
let mut setup = set_up();
@@ -103,7 +128,6 @@ fn test_installer_creation_should_not_create_vendor_directory() {
tear_down(&mut setup);
}
-#[ignore]
#[test]
fn test_installer_creation_should_not_create_bin_directory() {
let mut setup = set_up();
@@ -115,7 +139,6 @@ fn test_installer_creation_should_not_create_bin_directory() {
tear_down(&mut setup);
}
-#[ignore]
#[test]
fn test_is_installed() {
let mut setup = set_up();
@@ -142,24 +165,129 @@ fn test_is_installed() {
}
#[test]
-#[ignore = "requires PHPUnit mock of DownloadManager (expects(once)->method('install')->will(resolve(null))) and InstalledRepositoryInterface (expects(once)->addPackage); a real DownloadManager would attempt a download"]
fn test_install() {
- todo!()
+ let mut setup = set_up();
+ let mut library =
+ LibraryInstaller::new(setup.io.clone(), setup.composer.clone(), None, None, None);
+ let package = get_installable_package("some/package", "1.0.0");
+
+ // PHP asserts the DownloadManager mock's install() is called once with
+ // ($package, vendorDir/some/package); here the recording downloader stub
+ // captures the forwarded call instead.
+ let mut repository = InstalledArrayRepository::new().unwrap();
+
+ run(library.install(&mut repository, package.clone())).unwrap();
+
+ assert_eq!(
+ vec![DownloaderCall::Install {
+ package: "some/package".to_string(),
+ path: format!("{}/some/package", setup.vendor_dir),
+ }],
+ *setup.downloader_calls.borrow()
+ );
+ // PHP asserts repository->addPackage was called once with $package.
+ assert!(repository.has_package(package));
+
+ assert!(
+ std::path::Path::new(&setup.vendor_dir).exists(),
+ "Vendor dir should be created"
+ );
+ assert!(
+ std::path::Path::new(&setup.bin_dir).exists(),
+ "Bin dir should be created"
+ );
+
+ tear_down(&mut setup);
}
#[test]
-#[ignore = "requires PHPUnit mocks (Filesystem::rename, DownloadManager::update, repository hasPackage/add/remove with onConsecutiveCalls) and Package::setTargetDir, which is not exposed on PackageInterfaceHandle"]
fn test_update() {
- todo!()
+ let mut setup = set_up();
+
+ let initial = get_installable_package("vendor/package1", "1.0.0");
+ let target = get_installable_package("vendor/package1", "2.0.0");
+
+ initial.__set_target_dir(Some("oldtarget".to_string()));
+ target.__set_target_dir(Some("newtarget".to_string()));
+
+ // PHP mocks Filesystem::rename; here a real Filesystem renames the actual oldtarget
+ // dir, so it must exist first. The install path embeds the pretty-name twice because
+ // the package is named vendor/package1 and lives under vendorDir.
+ let old_target_dir = format!("{}/vendor/package1/oldtarget", setup.vendor_dir);
+ let new_target_dir = format!("{}/vendor/package1/newtarget", setup.vendor_dir);
+ fs::create_dir_all(&old_target_dir).unwrap();
+
+ let mut repository = InstalledArrayRepository::new().unwrap();
+ repository.add_package(initial.clone()).unwrap();
+
+ // The default Filesystem is fine; the LibraryInstaller's own filesystem performs the rename.
+ let mut library =
+ LibraryInstaller::new(setup.io.clone(), setup.composer.clone(), None, None, None);
+
+ run(library.update(&mut repository, initial.clone(), target.clone())).unwrap();
+
+ assert!(
+ std::path::Path::new(&new_target_dir).exists(),
+ "oldtarget should have been renamed to newtarget"
+ );
+ assert!(!std::path::Path::new(&old_target_dir).exists());
+
+ assert_eq!(
+ vec![DownloaderCall::Update {
+ initial: "vendor/package1".to_string(),
+ target: "vendor/package1".to_string(),
+ path: new_target_dir.clone(),
+ }],
+ *setup.downloader_calls.borrow()
+ );
+ assert!(!repository.has_package(initial.clone()));
+ assert!(repository.has_package(target.clone()));
+
+ assert!(
+ std::path::Path::new(&setup.vendor_dir).exists(),
+ "Vendor dir should be created"
+ );
+ assert!(
+ std::path::Path::new(&setup.bin_dir).exists(),
+ "Bin dir should be created"
+ );
+
+ // Updating again, with the initial package no longer installed, fails.
+ assert!(run(library.update(&mut repository, initial, target)).is_err());
+
+ tear_down(&mut setup);
}
#[test]
-#[ignore = "requires PHPUnit mock of DownloadManager (expects(once)->method('remove')->will(resolve(null))) and InstalledRepositoryInterface (hasPackage onConsecutiveCalls, removePackage)"]
fn test_uninstall() {
- todo!()
+ let mut setup = set_up();
+ let mut library =
+ LibraryInstaller::new(setup.io.clone(), setup.composer.clone(), None, None, None);
+ let package = get_installable_package("vendor/pkg", "1.0.0");
+
+ // PHP mocks hasPackage to return (true, false) over two calls; a real repository
+ // seeded with the package reproduces this naturally: present, then absent after
+ // the first uninstall removes it.
+ let mut repository = InstalledArrayRepository::new().unwrap();
+ repository.add_package(package.clone()).unwrap();
+
+ run(library.uninstall(&mut repository, package.clone())).unwrap();
+
+ assert_eq!(
+ vec![DownloaderCall::Remove {
+ package: "vendor/pkg".to_string(),
+ path: format!("{}/vendor/pkg", setup.vendor_dir),
+ }],
+ *setup.downloader_calls.borrow()
+ );
+ assert!(!repository.has_package(package.clone()));
+
+ // Uninstalling again, with the package no longer installed, fails.
+ assert!(run(library.uninstall(&mut repository, package)).is_err());
+
+ tear_down(&mut setup);
}
-#[ignore]
#[test]
fn test_get_install_path_without_target_dir() {
let mut setup = set_up();
@@ -176,9 +304,23 @@ fn test_get_install_path_without_target_dir() {
}
#[test]
-#[ignore = "Package::setTargetDir is not exposed on PackageInterfaceHandle, so the target-dir cannot be set on the test package"]
fn test_get_install_path_with_target_dir() {
- todo!()
+ let mut setup = set_up();
+ let mut library =
+ LibraryInstaller::new(setup.io.clone(), setup.composer.clone(), None, None, None);
+ let package = get_package("Foo/Bar", "1.0.0");
+ package.__set_target_dir(Some("Some/Namespace".to_string()));
+
+ assert_eq!(
+ format!(
+ "{}/{}/Some/Namespace",
+ setup.vendor_dir,
+ package.get_pretty_name()
+ ),
+ library.get_install_path(package).unwrap()
+ );
+
+ tear_down(&mut setup);
}
#[test]
diff --git a/crates/shirabe/tests/installer/main.rs b/crates/shirabe/tests/installer/main.rs
index f5b8c32..931e70b 100644
--- a/crates/shirabe/tests/installer/main.rs
+++ b/crates/shirabe/tests/installer/main.rs
@@ -1,3 +1,5 @@
+#[path = "../common/downloader_stub.rs"]
+mod downloader_stub;
#[path = "../common/test_case.rs"]
mod test_case;
diff --git a/crates/shirabe/tests/io/console_io_test.rs b/crates/shirabe/tests/io/console_io_test.rs
index 3c8242a..f96aec1 100644
--- a/crates/shirabe/tests/io/console_io_test.rs
+++ b/crates/shirabe/tests/io/console_io_test.rs
@@ -1,17 +1,28 @@
//! ref: composer/tests/Composer/Test/IO/ConsoleIOTest.php
-// These mock Symfony's InputInterface/OutputInterface/HelperSet (and QuestionHelper) to
-// drive ConsoleIO; those console abstractions are not ported.
+// PHP drives ConsoleIO with PHPUnit mocks of Symfony's InputInterface/OutputInterface/HelperSet
+// (and QuestionHelper). There is no mocking framework here, so the mocks are reproduced with real,
+// side-effect-free Symfony console types:
+// * `expects()->method('write')->with(...)` call/argument expectations are replaced by reading
+// back what a real `BufferedOutput` collected and asserting on it.
+// * `isInteractive` consecutive return values are reproduced by toggling a real `ArrayInput`'s
+// interactive flag between calls.
+// * QuestionHelper `ask`/`get` expectations are replaced by feeding a prepared answer through a
+// `php://memory` input stream (exactly as StrictConfirmationQuestionTest does) and asserting on
+// the returned value.
use indexmap::IndexMap;
use shirabe::io::ConsoleIO;
use shirabe::io::IOInterfaceImmutable;
use shirabe::io::IOInterfaceMutable;
+use shirabe::io::io_interface::NORMAL;
use shirabe_external_packages::symfony::console::helper::QuestionHelper;
use shirabe_external_packages::symfony::console::input::array_input::ArrayInput;
use shirabe_external_packages::symfony::console::input::input_interface::InputInterface;
+use shirabe_external_packages::symfony::console::input::streamable_input_interface::StreamableInputInterface;
use shirabe_external_packages::symfony::console::output::buffered_output::BufferedOutput;
use shirabe_external_packages::symfony::console::output::output_interface::OutputInterface;
+use shirabe_php_shim::PhpMixed;
use std::cell::RefCell;
use std::rc::Rc;
@@ -26,58 +37,176 @@ fn make_console_io() -> ConsoleIO {
ConsoleIO::new(input, output, QuestionHelper::default())
}
-#[ignore = "requires a PHPUnit mock of InputInterface::isInteractive with willReturnOnConsecutiveCalls; no mocking framework"]
+/// Opens a `php://memory` stream pre-filled with `input`, mirroring
+/// StrictConfirmationQuestionTest::getInputStream.
+fn get_input_stream(input: &str) -> shirabe_php_shim::PhpResource {
+ let stream = shirabe_php_shim::php_fopen_resource("php://memory", "r+");
+ shirabe_php_shim::fwrite_resource(&stream, input);
+ shirabe_php_shim::rewind(&stream);
+ stream
+}
+
+/// Builds a ConsoleIO whose interactive input stream yields `answer`, plus a handle on the
+/// `BufferedOutput` so the prompt can be inspected. The input is an `ArrayInput` (interactive by
+/// default) with the answer stream attached, exactly as the question-helper tests set things up.
+fn make_console_io_with_answer(answer: &str) -> (ConsoleIO, Rc<RefCell<BufferedOutput>>) {
+ let mut array_input = ArrayInput::new(vec![], None).unwrap();
+ array_input.set_stream(get_input_stream(answer));
+ let input: Rc<RefCell<dyn InputInterface>> = Rc::new(RefCell::new(array_input));
+ let buffered = Rc::new(RefCell::new(BufferedOutput::new(None, false, None)));
+ let output: Rc<RefCell<dyn OutputInterface>> = buffered.clone();
+ (
+ ConsoleIO::new(input, output, QuestionHelper::default()),
+ buffered,
+ )
+}
+
#[test]
fn test_is_interactive() {
- todo!()
+ // PHP mocks isInteractive to return true then false on consecutive calls. A real ArrayInput
+ // exposes a mutable interactive flag, so toggling it between the two asserts is equivalent.
+ let array_input = ArrayInput::new(vec![], None).unwrap();
+ let input: Rc<RefCell<dyn InputInterface>> = Rc::new(RefCell::new(array_input));
+ let output: Rc<RefCell<dyn OutputInterface>> =
+ Rc::new(RefCell::new(BufferedOutput::new(None, false, None)));
+ let console_io = ConsoleIO::new(input.clone(), output, QuestionHelper::default());
+
+ input.borrow_mut().set_interactive(true);
+ assert!(console_io.is_interactive());
+ input.borrow_mut().set_interactive(false);
+ assert!(!console_io.is_interactive());
}
-#[ignore = "requires a PHPUnit mock of OutputInterface with expects()->method('write')->with() expectation verification; no mocking framework"]
#[test]
fn test_write() {
- todo!()
+ // PHP: expects write('some information about something', false) at VERBOSITY_NORMAL.
+ // A plain (non-Console) OutputInterface routes write to the main output, so the message lands
+ // in the BufferedOutput verbatim (no trailing EOL since newline is false).
+ let input: Rc<RefCell<dyn InputInterface>> =
+ Rc::new(RefCell::new(ArrayInput::new(vec![], None).unwrap()));
+ let buffered = Rc::new(RefCell::new(BufferedOutput::new(None, false, None)));
+ let output: Rc<RefCell<dyn OutputInterface>> = buffered.clone();
+ let console_io = ConsoleIO::new(input, output, QuestionHelper::default());
+
+ console_io.write3("some information about something", false, NORMAL);
+
+ assert_eq!(
+ "some information about something",
+ buffered.borrow().fetch()
+ );
}
-#[ignore = "requires a PHPUnit mock of ConsoleOutputInterface with getErrorOutput/write expectation verification; no mocking framework"]
+#[ignore = "PHP mocks ConsoleOutputInterface so getErrorOutput returns the same mock; a real ConsoleOutput's error StreamOutput writes to php://stderr, which cannot be read back, and the trait offers no seam to inject a BufferedOutput error sink"]
#[test]
fn test_write_error() {
todo!()
}
-#[ignore = "requires a PHPUnit mock of OutputInterface with a write() callback expectation verification; no mocking framework"]
+#[ignore = "ConsoleIO::write3 takes a single &str; the test feeds a 2-element array ['First line','Second lines'] and asserts a per-element regex on the debugging-prefixed messages array, which the &str signature cannot represent"]
#[test]
fn test_write_with_multiple_line_string_when_debugging() {
todo!()
}
-#[ignore = "requires a PHPUnit mock of OutputInterface with willReturnCallback series expectation verification; no mocking framework"]
#[test]
fn test_overwrite() {
- todo!()
+ // PHP asserts the exact series of write() calls overwrite produces (backspaces, padding, etc.).
+ // PHP's mock intercepts each raw write() argument, before the output formatter runs. A real
+ // BufferedOutput captures the *rendered* text, so the `<question>/<comment>/<info>` tags are
+ // stripped while the backspace/space runs are identical. The backspace counts still derive from
+ // `strlen(strip_tags(lastMessage))`, so they match PHP exactly. The series, in order, is:
+ // 'something (strlen = 23)' + "\n" (initial write, newline defaults true)
+ // "\x08" * 23 (clear previous, strip_tags len = 23)
+ // 'shorter (12)' (new message, strip_tags len = 12)
+ // " " * 11 (fill: 23 - 12)
+ // "\x08" * 11 (move cursor back)
+ // "\x08" * 12 (clear previous, len = 12)
+ // 'something longer than initial (34)' (new message)
+ // The final overwrite needs no fill and newline defaults true, appending a trailing "\n".
+ let input: Rc<RefCell<dyn InputInterface>> =
+ Rc::new(RefCell::new(ArrayInput::new(vec![], None).unwrap()));
+ let buffered = Rc::new(RefCell::new(BufferedOutput::new(None, false, None)));
+ let output: Rc<RefCell<dyn OutputInterface>> = buffered.clone();
+ let console_io = ConsoleIO::new(input, output, QuestionHelper::default());
+
+ console_io.write3("something (<question>strlen = 23</question>)", true, NORMAL);
+ console_io.overwrite4("shorter (<comment>12</comment>)", false, None, NORMAL);
+ console_io.overwrite4(
+ "something longer than initial (<info>34</info>)",
+ true,
+ None,
+ NORMAL,
+ );
+
+ let bs = |n: usize| "\u{08}".repeat(n);
+ let expected = format!(
+ "{}\n{}{}{}{}{}{}\n",
+ "something (strlen = 23)",
+ bs(23),
+ "shorter (12)",
+ " ".repeat(11),
+ bs(11),
+ bs(12),
+ "something longer than initial (34)",
+ );
+ assert_eq!(expected, buffered.borrow().fetch());
}
-#[ignore = "requires a PHPUnit mock of QuestionHelper/HelperSet with ask/get expectation verification; no mocking framework"]
#[test]
fn test_ask() {
- todo!()
+ // PHP asserts QuestionHelper::ask receives a Question. Behaviorally, an interactive input whose
+ // stream yields the answer makes ConsoleIO::ask return that answer.
+ let (console_io, _output) = make_console_io_with_answer("answer\n");
+ let result = console_io.ask("Why?".to_string(), PhpMixed::String("default".to_string()));
+ assert_eq!(PhpMixed::String("answer".to_string()), result);
}
-#[ignore = "requires a PHPUnit mock of QuestionHelper/HelperSet with ask/get expectation verification; no mocking framework"]
#[test]
fn test_ask_confirmation() {
- todo!()
+ // PHP asserts the helper receives a StrictConfirmationQuestion. Behaviorally, a "yes" answer
+ // confirms and a "no" answer denies via the StrictConfirmationQuestion ConsoleIO builds.
+ let (console_io, _output) = make_console_io_with_answer("yes\n");
+ assert!(console_io.ask_confirmation("Why?".to_string(), false));
+
+ let (console_io, _output) = make_console_io_with_answer("no\n");
+ assert!(!console_io.ask_confirmation("Why?".to_string(), false));
}
-#[ignore = "requires a PHPUnit mock of QuestionHelper/HelperSet with ask/get expectation verification; no mocking framework"]
#[test]
fn test_ask_and_validate() {
- todo!()
+ // PHP asserts the helper receives a Question with a validator. Behaviorally, an always-true
+ // validator passes the answer straight through.
+ let (console_io, _output) = make_console_io_with_answer("answer\n");
+ let validator: Box<dyn Fn(PhpMixed) -> anyhow::Result<PhpMixed>> = Box::new(|value| Ok(value));
+ let result = console_io
+ .ask_and_validate(
+ "Why?".to_string(),
+ validator,
+ Some(10),
+ PhpMixed::String("default".to_string()),
+ )
+ .unwrap();
+ assert_eq!(PhpMixed::String("answer".to_string()), result);
}
-#[ignore = "requires a PHPUnit mock of QuestionHelper/HelperSet with ask/get expectation verification; no mocking framework"]
#[test]
fn test_select() {
- todo!()
+ // PHP mocks the helper to return ['item2'] and asserts select maps it to ['1']. Behaviorally,
+ // feeding "item2" to a multiselect ChoiceQuestion over the non-associative list
+ // ["item1","item2"] resolves to that choice, which select maps back to its index '1'.
+ let (console_io, _output) = make_console_io_with_answer("item2\n");
+ let result = console_io.select(
+ "Select item".to_string(),
+ vec!["item1".to_string(), "item2".to_string()],
+ PhpMixed::String("item1".to_string()),
+ PhpMixed::Bool(false),
+ "Error message".to_string(),
+ true,
+ );
+ assert_eq!(
+ PhpMixed::List(vec![PhpMixed::String("1".to_string())]),
+ result
+ );
}
#[test]
diff --git a/crates/shirabe/tests/repository/vcs/git_bitbucket_driver_test.rs b/crates/shirabe/tests/repository/vcs/git_bitbucket_driver_test.rs
index 2fedba1..944ef9a 100644
--- a/crates/shirabe/tests/repository/vcs/git_bitbucket_driver_test.rs
+++ b/crates/shirabe/tests/repository/vcs/git_bitbucket_driver_test.rs
@@ -9,21 +9,20 @@ use shirabe::io::IOInterface;
use shirabe::io::null_io::NullIO;
use shirabe::repository::vcs::GitBitbucketDriver;
use shirabe::util::filesystem::Filesystem;
-use shirabe_php_shim::PhpMixed;
+use shirabe::util::http_downloader::{HttpDownloader, HttpDownloaderMockHandler};
+use shirabe::util::process_executor::ProcessExecutor;
+use shirabe_php_shim::{InvalidArgumentException, PhpMixed, RuntimeException};
use tempfile::TempDir;
+use crate::http_downloader_mock::{HttpDownloaderMockGuard, expect_full, get_http_downloader_mock};
+use crate::io_stub::IOStub;
+
struct SetUp {
home: TempDir,
config: Config,
- // The IOInterface mock and the HttpDownloaderMock are not ported.
- io: (),
- http_downloader: (),
}
fn set_up() -> SetUp {
- // The IOInterface mock is created via PHPUnit's getMockBuilder, which is not ported.
- let io: () = todo!();
-
let home = TempDir::new().unwrap();
let mut config = Config::new(true, None);
let mut top: IndexMap<String, PhpMixed> = IndexMap::new();
@@ -35,15 +34,7 @@ fn set_up() -> SetUp {
top.insert("config".to_string(), PhpMixed::Array(config_section));
config.merge(&top, Config::SOURCE_UNKNOWN);
- // The HttpDownloaderMock is created via getHttpDownloaderMock, which is not ported.
- let http_downloader: () = todo!();
-
- SetUp {
- home,
- config,
- io,
- http_downloader,
- }
+ SetUp { home, config }
}
fn tear_down(home: &std::path::Path) {
@@ -67,6 +58,256 @@ impl Drop for TearDown {
}
}
+// 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 getDriver: constructs the driver from the given repo url with a mocked
+// IOInterface, the test config, the mocked HttpDownloader, and a real ProcessExecutor,
+// then calls initialize().
+fn get_driver(
+ url: &str,
+ io: Rc<RefCell<dyn IOInterface>>,
+ config: Rc<RefCell<Config>>,
+ http_downloader: Rc<RefCell<HttpDownloader>>,
+) -> anyhow::Result<GitBitbucketDriver> {
+ let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new();
+ repo_config.insert("url".to_string(), PhpMixed::String(url.to_string()));
+
+ let process = Rc::new(RefCell::new(ProcessExecutor::new(Some(io.clone()))));
+
+ let mut driver = GitBitbucketDriver::new(repo_config, io, config, http_downloader, process);
+ driver.initialize()?;
+ Ok(driver)
+}
+
+#[test]
+fn test_get_root_identifier_wrong_scm_type() {
+ 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): (_, HttpDownloaderMockGuard) = get_http_downloader_mock(
+ vec![http_body(
+ "https://api.bitbucket.org/2.0/repositories/user/repo?fields=-project%2C-owner",
+ r#"{"scm":"hg","website":"","has_wiki":false,"name":"repo","links":{"branches":{"href":"https:\/\/api.bitbucket.org\/2.0\/repositories\/user\/repo\/refs\/branches"},"tags":{"href":"https:\/\/api.bitbucket.org\/2.0\/repositories\/user\/repo\/refs\/tags"},"clone":[{"href":"https:\/\/user@bitbucket.org\/user\/repo","name":"https"},{"href":"ssh:\/\/hg@bitbucket.org\/user\/repo","name":"ssh"}],"html":{"href":"https:\/\/bitbucket.org\/user\/repo"}},"language":"php","created_on":"2015-02-18T16:22:24.688+00:00","updated_on":"2016-05-17T13:20:21.993+00:00","is_private":true,"has_issues":false}"#,
+ )],
+ true,
+ HttpDownloaderMockHandler::default(),
+ );
+
+ let mut driver = get_driver(
+ "https://bitbucket.org/user/repo.git",
+ io,
+ config,
+ http_downloader,
+ )
+ .unwrap();
+
+ let err = driver.get_root_identifier().unwrap_err();
+ let runtime = err
+ .downcast_ref::<RuntimeException>()
+ .expect("expected RuntimeException");
+ assert_eq!(
+ "https://bitbucket.org/user/repo.git does not appear to be a git repository, use https://bitbucket.org/user/repo but remember that Bitbucket no longer supports the mercurial repositories. https://bitbucket.org/blog/sunsetting-mercurial-support-in-bitbucket",
+ runtime.message
+ );
+}
+
+#[test]
+#[ignore = "blocked by shim: getComposerInformation -> get_change_date -> shirabe_php_shim::date_create is todo!()"]
+fn test_driver() {
+ 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 urls = [
+ "https://api.bitbucket.org/2.0/repositories/user/repo?fields=-project%2C-owner",
+ "https://api.bitbucket.org/2.0/repositories/user/repo/refs/tags?pagelen=100&fields=values.name%2Cvalues.target.hash%2Cnext&sort=-target.date",
+ "https://api.bitbucket.org/2.0/repositories/user/repo/refs/branches?pagelen=100&fields=values.name%2Cvalues.target.hash%2Cvalues.heads%2Cnext&sort=-target.date",
+ "https://api.bitbucket.org/2.0/repositories/user/repo/src/main/composer.json",
+ "https://api.bitbucket.org/2.0/repositories/user/repo/commit/main?fields=date",
+ ];
+ let (http_downloader, _http_guard) = get_http_downloader_mock(
+ vec![
+ http_body(
+ urls[0],
+ r#"{"mainbranch": {"name": "main"}, "scm":"git","website":"","has_wiki":false,"name":"repo","links":{"branches":{"href":"https:\/\/api.bitbucket.org\/2.0\/repositories\/user\/repo\/refs\/branches"},"tags":{"href":"https:\/\/api.bitbucket.org\/2.0\/repositories\/user\/repo\/refs\/tags"},"clone":[{"href":"https:\/\/user@bitbucket.org\/user\/repo.git","name":"https"},{"href":"ssh:\/\/git@bitbucket.org\/user\/repo.git","name":"ssh"}],"html":{"href":"https:\/\/bitbucket.org\/user\/repo"}},"language":"php","created_on":"2015-02-18T16:22:24.688+00:00","updated_on":"2016-05-17T13:20:21.993+00:00","is_private":true,"has_issues":false}"#,
+ ),
+ http_body(
+ urls[1],
+ r#"{"values":[{"name":"1.0.1","target":{"hash":"9b78a3932143497c519e49b8241083838c8ff8a1"}},{"name":"1.0.0","target":{"hash":"d3393d514318a9267d2f8ebbf463a9aaa389f8eb"}}]}"#,
+ ),
+ http_body(
+ urls[2],
+ r#"{"values":[{"name":"main","target":{"hash":"937992d19d72b5116c3e8c4a04f960e5fa270b22"}}]}"#,
+ ),
+ http_body(
+ urls[3],
+ r#"{"name": "user/repo","description": "test repo","license": "GPL","authors": [{"name": "Name","email": "local@domain.tld"}],"require": {"creator/package": "^1.0"},"require-dev": {"phpunit/phpunit": "~4.8"}}"#,
+ ),
+ http_body(urls[4], r#"{"date": "2016-05-17T13:19:52+00:00"}"#),
+ ],
+ true,
+ HttpDownloaderMockHandler::default(),
+ );
+
+ let mut driver = get_driver(
+ "https://bitbucket.org/user/repo.git",
+ io,
+ config,
+ http_downloader,
+ )
+ .unwrap();
+
+ assert_eq!("main", driver.get_root_identifier().unwrap());
+
+ let mut expected_tags: IndexMap<String, String> = IndexMap::new();
+ expected_tags.insert(
+ "1.0.1".to_string(),
+ "9b78a3932143497c519e49b8241083838c8ff8a1".to_string(),
+ );
+ expected_tags.insert(
+ "1.0.0".to_string(),
+ "d3393d514318a9267d2f8ebbf463a9aaa389f8eb".to_string(),
+ );
+ assert_eq!(expected_tags, driver.get_tags().unwrap());
+
+ let mut expected_branches: IndexMap<String, String> = IndexMap::new();
+ expected_branches.insert(
+ "main".to_string(),
+ "937992d19d72b5116c3e8c4a04f960e5fa270b22".to_string(),
+ );
+ assert_eq!(expected_branches, driver.get_branches().unwrap());
+
+ let data = driver.get_composer_information("main").unwrap().unwrap();
+ assert_eq!(
+ Some("user/repo"),
+ data.get("name").and_then(|v| v.as_string())
+ );
+ assert_eq!(
+ Some("test repo"),
+ data.get("description").and_then(|v| v.as_string())
+ );
+ assert_eq!(
+ Some("2016-05-17T13:19:52+00:00"),
+ data.get("time").and_then(|v| v.as_string())
+ );
+ let support = data.get("support").and_then(|v| v.as_array()).unwrap();
+ assert_eq!(
+ Some(
+ "https://bitbucket.org/user/repo/src/937992d19d72b5116c3e8c4a04f960e5fa270b22/?at=main"
+ ),
+ support.get("source").and_then(|v| v.as_string())
+ );
+ assert_eq!(
+ Some("https://bitbucket.org/user/repo"),
+ data.get("homepage").and_then(|v| v.as_string())
+ );
+
+ // testGetParams (PHP @depends testDriver): assertions over the same driver.
+ let url = "https://bitbucket.org/user/repo.git";
+ assert_eq!(url, driver.get_url());
+
+ let dist = driver.get_dist("reference").unwrap();
+ assert_eq!(Some("zip"), dist.get("type").map(String::as_str));
+ assert_eq!(
+ Some("https://bitbucket.org/user/repo/get/reference.zip"),
+ dist.get("url").map(String::as_str)
+ );
+ assert_eq!(Some("reference"), dist.get("reference").map(String::as_str));
+ assert_eq!(Some(""), dist.get("shasum").map(String::as_str));
+
+ let source = driver.get_source("reference");
+ assert_eq!(Some("git"), source.get("type").map(String::as_str));
+ assert_eq!(Some(url), source.get("url").map(String::as_str));
+ assert_eq!(
+ Some("reference"),
+ source.get("reference").map(String::as_str)
+ );
+}
+
+#[test]
+fn test_initialize_invalid_repository_url() {
+ 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 result = get_driver("https://bitbucket.org/acme", io, config, http_downloader);
+ let err = result.unwrap_err();
+ assert!(
+ err.downcast_ref::<InvalidArgumentException>().is_some(),
+ "expected InvalidArgumentException, got: {err:?}"
+ );
+}
+
+#[test]
+#[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 _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 repo_url = "https://bitbucket.org/user/repo.git";
+
+ let urls = [
+ "https://api.bitbucket.org/2.0/repositories/user/repo?fields=-project%2C-owner",
+ "https://api.bitbucket.org/2.0/repositories/user/repo/src/main/composer.json",
+ "https://api.bitbucket.org/2.0/repositories/user/repo/commit/main?fields=date",
+ "https://api.bitbucket.org/2.0/repositories/user/repo/refs/tags?pagelen=100&fields=values.name%2Cvalues.target.hash%2Cnext&sort=-target.date",
+ "https://api.bitbucket.org/2.0/repositories/user/repo/refs/branches?pagelen=100&fields=values.name%2Cvalues.target.hash%2Cvalues.heads%2Cnext&sort=-target.date",
+ ];
+ let (http_downloader, _http_guard) = get_http_downloader_mock(
+ vec![
+ http_body(
+ urls[0],
+ r#"{"mainbranch": {"name": "main"}, "scm":"git","website":"","has_wiki":false,"name":"repo","links":{"branches":{"href":"https:\/\/api.bitbucket.org\/2.0\/repositories\/user\/repo\/refs\/branches"},"tags":{"href":"https:\/\/api.bitbucket.org\/2.0\/repositories\/user\/repo\/refs\/tags"},"clone":[{"href":"https:\/\/user@bitbucket.org\/user\/repo.git","name":"https"},{"href":"ssh:\/\/git@bitbucket.org\/user\/repo.git","name":"ssh"}],"html":{"href":"https:\/\/bitbucket.org\/user\/repo"}},"language":"php","created_on":"2015-02-18T16:22:24.688+00:00","updated_on":"2016-05-17T13:20:21.993+00:00","is_private":true,"has_issues":false}"#,
+ ),
+ http_body(urls[1], format!(r#"{{"support": "{repo_url}"}}"#)),
+ http_body(urls[2], r#"{"date": "2016-05-17T13:19:52+00:00"}"#),
+ http_body(
+ urls[3],
+ r#"{"values":[{"name":"1.0.1","target":{"hash":"9b78a3932143497c519e49b8241083838c8ff8a1"}},{"name":"1.0.0","target":{"hash":"d3393d514318a9267d2f8ebbf463a9aaa389f8eb"}}]}"#,
+ ),
+ http_body(
+ urls[4],
+ r#"{"values":[{"name":"main","target":{"hash":"937992d19d72b5116c3e8c4a04f960e5fa270b22"}}]}"#,
+ ),
+ ],
+ true,
+ HttpDownloaderMockHandler::default(),
+ );
+
+ let mut driver = get_driver(repo_url, io, config, http_downloader).unwrap();
+
+ driver.get_root_identifier().unwrap();
+ let data = driver.get_composer_information("main").unwrap().unwrap();
+
+ let support = data.get("support").and_then(|v| v.as_array()).unwrap();
+ assert_eq!(
+ Some(
+ "https://bitbucket.org/user/repo/src/937992d19d72b5116c3e8c4a04f960e5fa270b22/?at=main"
+ ),
+ support.get("source").and_then(|v| v.as_string())
+ );
+}
+
#[test]
fn test_supports() {
let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(NullIO::new()));
@@ -98,71 +339,3 @@ fn test_supports() {
.unwrap()
);
}
-
-// The remaining cases construct a GitBitbucketDriver and mock the HttpDownloader to return
-// Bitbucket API responses; mocking is not available, and a real HttpDownloader reaches
-// curl_multi_init (todo!()).
-#[test]
-#[ignore = "needs IOInterface mock (getMockBuilder) and getHttpDownloaderMock/HttpDownloaderMock (not ported); set_up()'s io and http_downloader are todo!() and real HttpDownloader hits todo!() curl I/O"]
-fn test_get_root_identifier_wrong_scm_type() {
- let SetUp {
- home,
- config: _,
- io: _,
- http_downloader: _,
- } = set_up();
- let _tear_down = TearDown::new(home.path().to_path_buf());
- todo!()
-}
-
-#[test]
-#[ignore = "needs IOInterface mock (getMockBuilder) and getHttpDownloaderMock/HttpDownloaderMock (not ported); set_up()'s io and http_downloader are todo!() and real HttpDownloader hits todo!() curl I/O"]
-fn test_driver() {
- let SetUp {
- home,
- config: _,
- io: _,
- http_downloader: _,
- } = set_up();
- let _tear_down = TearDown::new(home.path().to_path_buf());
- todo!()
-}
-
-#[test]
-#[ignore = "needs IOInterface mock (getMockBuilder) and getHttpDownloaderMock/HttpDownloaderMock (not ported); depends on test_driver's driver and set_up()'s todo!() mocks"]
-fn test_get_params() {
- let SetUp {
- home,
- config: _,
- io: _,
- http_downloader: _,
- } = set_up();
- let _tear_down = TearDown::new(home.path().to_path_buf());
- todo!()
-}
-
-#[test]
-#[ignore = "needs IOInterface mock (getMockBuilder) and getHttpDownloaderMock/HttpDownloaderMock (not ported); set_up()'s io and http_downloader are todo!()"]
-fn test_initialize_invalid_repository_url() {
- let SetUp {
- home,
- config: _,
- io: _,
- http_downloader: _,
- } = set_up();
- let _tear_down = TearDown::new(home.path().to_path_buf());
- todo!()
-}
-
-#[test]
-#[ignore = "needs IOInterface mock (getMockBuilder) and getHttpDownloaderMock/HttpDownloaderMock (not ported); set_up()'s io and http_downloader are todo!() and real HttpDownloader hits todo!() curl I/O"]
-fn test_invalid_support_data() {
- let SetUp {
- home,
- config: _,
- io: _,
- http_downloader: _,
- } = set_up();
- let _tear_down = TearDown::new(home.path().to_path_buf());
- todo!()
-}
diff --git a/crates/shirabe/tests/util/auth_helper_test.rs b/crates/shirabe/tests/util/auth_helper_test.rs
index 21bf1c6..133fd75 100644
--- a/crates/shirabe/tests/util/auth_helper_test.rs
+++ b/crates/shirabe/tests/util/auth_helper_test.rs
@@ -1,130 +1,611 @@
//! ref: composer/tests/Composer/Test/Util/AuthHelperTest.php
-// These mock IO/Config to drive AuthHelper's header/option building and interactive auth
-// storage; mocking is not available here.
+use std::cell::RefCell;
+use std::rc::Rc;
-#[allow(dead_code)]
-fn set_up() {
- // Builds mocked IOInterface/Config and a real AuthHelper; mocking is not available.
- todo!()
+use indexmap::IndexMap;
+use shirabe::config::ConfigSourceInterface;
+use shirabe::io::IOInterface;
+use shirabe::io::io_interface;
+use shirabe::util::{AuthHelper, Bitbucket, StoreAuth};
+use shirabe_php_shim::{PhpMixed, base64_encode, json_encode};
+
+use crate::config_stub::ConfigStubBuilder;
+use crate::io_mock::{Expectation, IOMock, get_io_mock};
+
+// Mirrors AuthHelperTest::setUp: a DEBUG-verbosity IOMock plus a real Config, both
+// shared with the AuthHelper under test. The IOMockGuard runs assert_complete on drop.
+struct Fixture {
+ io: Rc<RefCell<IOMock>>,
+ config: Rc<RefCell<shirabe::config::Config>>,
+ auth_helper: AuthHelper,
+ _guard: crate::io_mock::IOMockGuard,
+}
+
+fn set_up_with_config(config: Rc<RefCell<shirabe::config::Config>>) -> Fixture {
+ let (mock, guard) = get_io_mock(io_interface::DEBUG).unwrap();
+ let io: Rc<RefCell<dyn IOInterface>> = mock.clone();
+ let auth_helper = AuthHelper::new(io, config.clone());
+ Fixture {
+ io: mock,
+ config,
+ auth_helper,
+ _guard: guard,
+ }
+}
+
+fn set_up() -> Fixture {
+ set_up_with_config(ConfigStubBuilder::new().build_shared())
+}
+
+// Mirrors AuthHelperTest::expectsAuthentication: pre-seed the IO so hasAuthentication
+// and getAuthentication return the given credentials for `origin`.
+fn expects_authentication(io: &Rc<RefCell<IOMock>>, origin: &str, username: &str, password: &str) {
+ use shirabe::io::IOInterfaceMutable;
+ io.borrow_mut().set_authentication(
+ origin.to_string(),
+ username.to_string(),
+ Some(password.to_string()),
+ );
+}
+
+fn header_strings(options: &IndexMap<String, PhpMixed>) -> Vec<String> {
+ options
+ .get("http")
+ .and_then(|v| v.as_array())
+ .and_then(|http| http.get("header"))
+ .and_then(|v| v.as_list())
+ .map(|list| {
+ list.iter()
+ .map(|v| v.as_string().unwrap_or("").to_string())
+ .collect()
+ })
+ .unwrap_or_default()
+}
+
+fn base_headers() -> Vec<PhpMixed> {
+ vec![
+ PhpMixed::String("Accept-Encoding: gzip".to_string()),
+ PhpMixed::String("Connection: close".to_string()),
+ ]
+}
+
+fn http_options_with_headers() -> IndexMap<String, PhpMixed> {
+ let mut http = IndexMap::new();
+ http.insert("header".to_string(), PhpMixed::List(base_headers()));
+ let mut options = IndexMap::new();
+ options.insert("http".to_string(), PhpMixed::Array(http));
+ options
}
#[test]
-#[ignore = "requires PHPUnit mock IOInterface with expects()/willReturn(); no mocking framework"]
fn test_add_authentication_header_without_auth_credentials() {
- todo!()
+ let mut f = set_up();
+ let options = http_options_with_headers();
+ let origin = "http://example.org";
+ let url = "file://example";
+
+ let options = f
+ .auth_helper
+ .add_authentication_options(options, origin, url)
+ .unwrap();
+
+ assert_eq!(
+ header_strings(&options),
+ vec!["Accept-Encoding: gzip", "Connection: close"]
+ );
}
#[test]
-#[ignore = "requires PHPUnit mock IOInterface with expects()/willReturn(); no mocking framework"]
fn test_add_authentication_header_with_bearer_password() {
- todo!()
+ let mut f = set_up();
+ let options = http_options_with_headers();
+ let origin = "http://example.org";
+ let url = "file://example";
+ expects_authentication(&f.io, origin, "my_username", "bearer");
+
+ let options = f
+ .auth_helper
+ .add_authentication_options(options, origin, url)
+ .unwrap();
+
+ assert_eq!(
+ header_strings(&options),
+ vec![
+ "Accept-Encoding: gzip",
+ "Connection: close",
+ "Authorization: Bearer my_username",
+ ]
+ );
}
#[test]
-#[ignore = "requires PHPUnit mock IOInterface with expects()/willReturn(); no mocking framework"]
fn test_add_authentication_header_with_github_token() {
- todo!()
+ let mut f = set_up();
+ f.io.borrow_mut()
+ .expects(
+ vec![Expectation::text("Using GitHub token authentication")],
+ false,
+ )
+ .unwrap();
+ let options = http_options_with_headers();
+ let origin = "github.com";
+ let url = "https://api.github.com/";
+ expects_authentication(&f.io, origin, "my_username", "x-oauth-basic");
+
+ let options = f
+ .auth_helper
+ .add_authentication_options(options, origin, url)
+ .unwrap();
+
+ assert_eq!(
+ header_strings(&options),
+ vec![
+ "Accept-Encoding: gzip",
+ "Connection: close",
+ "Authorization: token my_username",
+ ]
+ );
}
#[test]
-#[ignore = "requires PHPUnit mock IOInterface/Config with expects()/willReturn(); no mocking framework"]
fn test_add_authentication_header_with_gitlab_oath_token() {
- todo!()
+ let config = ConfigStubBuilder::new()
+ .with(
+ "gitlab-domains",
+ PhpMixed::List(vec![PhpMixed::String("gitlab.com".to_string())]),
+ )
+ .build_shared();
+ let mut f = set_up_with_config(config);
+ f.io.borrow_mut()
+ .expects(
+ vec![Expectation::text("Using GitLab OAuth token authentication")],
+ false,
+ )
+ .unwrap();
+ let options = http_options_with_headers();
+ let origin = "gitlab.com";
+ let url = "https://api.gitlab.com/";
+ expects_authentication(&f.io, origin, "my_username", "oauth2");
+
+ let options = f
+ .auth_helper
+ .add_authentication_options(options, origin, url)
+ .unwrap();
+
+ assert_eq!(
+ header_strings(&options),
+ vec![
+ "Accept-Encoding: gzip",
+ "Connection: close",
+ "Authorization: Bearer my_username",
+ ]
+ );
}
#[test]
-#[ignore = "requires PHPUnit mock IOInterface with expects()/willReturn(); no mocking framework"]
fn test_add_authentication_options_for_client_certificate() {
- todo!()
+ let mut f = set_up();
+ let options = IndexMap::new();
+ let origin = "example.org";
+ let url = "file://example";
+
+ let mut certificate_configuration = IndexMap::new();
+ certificate_configuration.insert(
+ "local_cert".to_string(),
+ PhpMixed::String("certificate value".to_string()),
+ );
+ certificate_configuration.insert(
+ "local_pk".to_string(),
+ PhpMixed::String("key value".to_string()),
+ );
+ certificate_configuration.insert(
+ "passphrase".to_string(),
+ PhpMixed::String("passphrase value".to_string()),
+ );
+ let password = json_encode(&PhpMixed::Array(certificate_configuration.clone())).unwrap();
+ expects_authentication(&f.io, origin, "client-certificate", &password);
+
+ let options = f
+ .auth_helper
+ .add_authentication_options(options, origin, url)
+ .unwrap();
+
+ assert_eq!(
+ options.get("ssl"),
+ Some(&PhpMixed::Array(certificate_configuration))
+ );
+}
+
+fn add_authentication_header_with_gitlab_private_token(password: &str) {
+ let config = ConfigStubBuilder::new()
+ .with(
+ "gitlab-domains",
+ PhpMixed::List(vec![PhpMixed::String("gitlab.com".to_string())]),
+ )
+ .build_shared();
+ let mut f = set_up_with_config(config);
+ f.io.borrow_mut()
+ .expects(
+ vec![Expectation::text(
+ "Using GitLab private token authentication",
+ )],
+ false,
+ )
+ .unwrap();
+ let options = http_options_with_headers();
+ let origin = "gitlab.com";
+ let url = "https://api.gitlab.com/";
+ expects_authentication(&f.io, origin, "my_username", password);
+
+ let options = f
+ .auth_helper
+ .add_authentication_options(options, origin, url)
+ .unwrap();
+
+ assert_eq!(
+ header_strings(&options),
+ vec![
+ "Accept-Encoding: gzip",
+ "Connection: close",
+ "PRIVATE-TOKEN: my_username",
+ ]
+ );
}
#[test]
-#[ignore = "requires PHPUnit mock IOInterface/Config with expects()/willReturn(); no mocking framework"]
fn test_add_authentication_header_with_gitlab_private_token() {
- todo!()
+ add_authentication_header_with_gitlab_private_token("private-token");
+ add_authentication_header_with_gitlab_private_token("gitlab-ci-token");
}
#[test]
-#[ignore = "requires PHPUnit mock IOInterface with expects()/willReturn(); no mocking framework"]
fn test_add_authentication_header_with_bitbucket_oath_token() {
- todo!()
+ let mut f = set_up();
+ f.io.borrow_mut()
+ .expects(
+ vec![Expectation::text(
+ "Using Bitbucket OAuth token authentication",
+ )],
+ false,
+ )
+ .unwrap();
+ let options = http_options_with_headers();
+ let origin = "bitbucket.org";
+ let url = "https://bitbucket.org/site/oauth2/authorize";
+ expects_authentication(&f.io, origin, "x-token-auth", "my_password");
+
+ let options = f
+ .auth_helper
+ .add_authentication_options(options, origin, url)
+ .unwrap();
+
+ assert_eq!(
+ header_strings(&options),
+ vec![
+ "Accept-Encoding: gzip",
+ "Connection: close",
+ "Authorization: Bearer my_password",
+ ]
+ );
+}
+
+fn add_authentication_header_with_bitbucket_public_url(url: &str) {
+ let mut f = set_up();
+ let options = http_options_with_headers();
+ let origin = "bitbucket.org";
+ expects_authentication(&f.io, origin, "x-token-auth", "my_password");
+
+ let options = f
+ .auth_helper
+ .add_authentication_options(options, origin, url)
+ .unwrap();
+
+ assert_eq!(
+ header_strings(&options),
+ vec!["Accept-Encoding: gzip", "Connection: close"]
+ );
}
#[test]
-#[ignore = "requires PHPUnit mock IOInterface with expects()/willReturn(); no mocking framework"]
fn test_add_authentication_header_with_bitbucket_public_url() {
- todo!()
+ add_authentication_header_with_bitbucket_public_url(
+ "https://bitbucket.org/user/repo/downloads/whatever",
+ );
+ add_authentication_header_with_bitbucket_public_url(
+ "https://bbuseruploads.s3.amazonaws.com/9421ee72-638e-43a9-82ea-39cfaae2bfaa/downloads/b87c59d9-54f3-4922-b711-d89059ec3bcf",
+ );
}
-#[test]
-#[ignore = "requires PHPUnit mock IOInterface with expects()/willReturn(); no mocking framework"]
-fn test_add_authentication_header_with_basic_http_authentication() {
- todo!()
+fn add_authentication_header_with_basic_http_authentication(
+ url: &str,
+ origin: &str,
+ username: &str,
+ password: &str,
+) {
+ let mut f = set_up();
+ f.io.borrow_mut()
+ .expects(
+ vec![Expectation::text(format!(
+ "Using HTTP basic authentication with username \"{}\"",
+ username
+ ))],
+ false,
+ )
+ .unwrap();
+ let options = http_options_with_headers();
+ expects_authentication(&f.io, origin, username, password);
+
+ let expected = format!(
+ "Authorization: Basic {}",
+ base64_encode(&format!("{}:{}", username, password))
+ );
+
+ let options = f
+ .auth_helper
+ .add_authentication_options(options, origin, url)
+ .unwrap();
+
+ assert_eq!(
+ header_strings(&options),
+ vec![
+ "Accept-Encoding: gzip".to_string(),
+ "Connection: close".to_string(),
+ expected,
+ ]
+ );
}
#[test]
-#[ignore = "requires PHPUnit mock IOInterface with expects()/willReturn(); no mocking framework"]
-fn test_add_authentication_header_with_custom_headers() {
- todo!()
+fn test_add_authentication_header_with_basic_http_authentication() {
+ add_authentication_header_with_basic_http_authentication(
+ Bitbucket::OAUTH2_ACCESS_TOKEN_URL,
+ "bitbucket.org",
+ "x-token-auth",
+ "my_password",
+ );
+ add_authentication_header_with_basic_http_authentication(
+ "https://some-api.url.com",
+ "some-api.url.com",
+ "my_username",
+ "my_password",
+ );
+ add_authentication_header_with_basic_http_authentication(
+ "https://gitlab.com",
+ "gitlab.com",
+ "my_username",
+ "my_password",
+ );
}
#[test]
-#[ignore = "requires PHPUnit mock IOInterface/Config in setUp; no mocking framework"]
fn test_is_public_bit_bucket_download_with_bitbucket_public_url() {
- todo!()
+ let f = set_up();
+ assert!(
+ f.auth_helper
+ .is_public_bit_bucket_download("https://bitbucket.org/user/repo/downloads/whatever")
+ );
+ assert!(f.auth_helper.is_public_bit_bucket_download(
+ "https://bbuseruploads.s3.amazonaws.com/9421ee72-638e-43a9-82ea-39cfaae2bfaa/downloads/b87c59d9-54f3-4922-b711-d89059ec3bcf",
+ ));
}
#[test]
-#[ignore = "requires PHPUnit mock IOInterface/Config in setUp; no mocking framework"]
fn test_is_public_bit_bucket_download_with_non_bitbucket_public_url() {
- todo!()
+ let f = set_up();
+ assert!(
+ !f.auth_helper
+ .is_public_bit_bucket_download("https://bitbucket.org/site/oauth2/authorize")
+ );
+}
+
+// Records addConfigSetting calls and serves a configurable getName, mirroring the
+// PHPUnit mock of ConfigSourceInterface used by the storeAuth tests.
+#[derive(Debug)]
+struct ConfigSourceMock {
+ name: String,
+ added: Rc<RefCell<Vec<(String, PhpMixed)>>>,
+}
+
+impl ConfigSourceInterface for ConfigSourceMock {
+ fn add_repository(
+ &mut self,
+ _name: &str,
+ _config: PhpMixed,
+ _append: bool,
+ ) -> anyhow::Result<()> {
+ unreachable!()
+ }
+ fn insert_repository(
+ &mut self,
+ _name: &str,
+ _config: PhpMixed,
+ _reference_name: &str,
+ _offset: i64,
+ ) -> anyhow::Result<()> {
+ unreachable!()
+ }
+ fn set_repository_url(&mut self, _name: &str, _url: &str) -> anyhow::Result<()> {
+ unreachable!()
+ }
+ fn remove_repository(&mut self, _name: &str) -> anyhow::Result<()> {
+ unreachable!()
+ }
+ fn add_config_setting(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> {
+ self.added.borrow_mut().push((name.to_string(), value));
+ Ok(())
+ }
+ fn remove_config_setting(&mut self, _name: &str) -> anyhow::Result<()> {
+ unreachable!()
+ }
+ fn add_property(&mut self, _name: &str, _value: PhpMixed) -> anyhow::Result<()> {
+ unreachable!()
+ }
+ fn remove_property(&mut self, _name: &str) -> anyhow::Result<()> {
+ unreachable!()
+ }
+ fn add_link(&mut self, _type: &str, _name: &str, _value: &str) -> anyhow::Result<()> {
+ unreachable!()
+ }
+ fn remove_link(&mut self, _type: &str, _name: &str) -> anyhow::Result<()> {
+ unreachable!()
+ }
+ fn get_name(&self) -> String {
+ self.name.clone()
+ }
+}
+
+fn expected_auth_setting(username: &str, password: &str) -> PhpMixed {
+ let mut auth = IndexMap::new();
+ auth.insert(
+ "username".to_string(),
+ PhpMixed::String(username.to_string()),
+ );
+ auth.insert(
+ "password".to_string(),
+ PhpMixed::String(password.to_string()),
+ );
+ PhpMixed::Array(auth)
}
#[test]
-#[ignore = "requires PHPUnit mock Config/ConfigSourceInterface with expects()/willReturn(); no mocking framework"]
fn test_store_auth_automatically() {
- todo!()
+ let mut f = set_up();
+ let origin = "github.com";
+ expects_authentication(&f.io, origin, "my_username", "my_password");
+
+ let added = Rc::new(RefCell::new(Vec::new()));
+ f.config
+ .borrow_mut()
+ .set_auth_config_source(Box::new(ConfigSourceMock {
+ name: "https://api.gitlab.com/source".to_string(),
+ added: added.clone(),
+ }));
+
+ f.auth_helper
+ .store_auth(origin, StoreAuth::Bool(true))
+ .unwrap();
+
+ assert_eq!(
+ *added.borrow(),
+ vec![(
+ "http-basic.github.com".to_string(),
+ expected_auth_setting("my_username", "my_password"),
+ )]
+ );
}
#[test]
-#[ignore = "requires PHPUnit mock IOInterface/Config/ConfigSourceInterface with willReturnCallback(); no mocking framework"]
fn test_store_auth_with_prompt_yes_answer() {
- todo!()
+ let mut f = set_up();
+ let origin = "github.com";
+ expects_authentication(&f.io, origin, "my_username", "my_password");
+ let config_source_name = "https://api.gitlab.com/source";
+
+ f.io.borrow_mut()
+ .expects(
+ vec![Expectation::ask(
+ format!(
+ "Do you want to store credentials for {} in {} ? [Yn] ",
+ origin, config_source_name
+ ),
+ "y",
+ )],
+ false,
+ )
+ .unwrap();
+
+ let added = Rc::new(RefCell::new(Vec::new()));
+ f.config
+ .borrow_mut()
+ .set_auth_config_source(Box::new(ConfigSourceMock {
+ name: config_source_name.to_string(),
+ added: added.clone(),
+ }));
+
+ f.auth_helper.store_auth(origin, StoreAuth::Prompt).unwrap();
+
+ assert_eq!(
+ *added.borrow(),
+ vec![(
+ "http-basic.github.com".to_string(),
+ expected_auth_setting("my_username", "my_password"),
+ )]
+ );
}
#[test]
-#[ignore = "requires PHPUnit mock IOInterface/Config/ConfigSourceInterface with willReturnCallback(); no mocking framework"]
fn test_store_auth_with_prompt_no_answer() {
- todo!()
+ let mut f = set_up();
+ let origin = "github.com";
+ let config_source_name = "https://api.gitlab.com/source";
+
+ f.io.borrow_mut()
+ .expects(
+ vec![Expectation::ask(
+ format!(
+ "Do you want to store credentials for {} in {} ? [Yn] ",
+ origin, config_source_name
+ ),
+ "n",
+ )],
+ false,
+ )
+ .unwrap();
+
+ let added = Rc::new(RefCell::new(Vec::new()));
+ f.config
+ .borrow_mut()
+ .set_auth_config_source(Box::new(ConfigSourceMock {
+ name: config_source_name.to_string(),
+ added: added.clone(),
+ }));
+
+ f.auth_helper.store_auth(origin, StoreAuth::Prompt).unwrap();
+
+ assert!(added.borrow().is_empty());
}
#[test]
-#[ignore = "requires PHPUnit mock IOInterface/Config/ConfigSourceInterface with willReturnCallback(); no mocking framework"]
+#[ignore = "PHP catches a RuntimeException from the validator; the Rust QuestionHelper exhausts \
+input and panics via expect() rather than returning Err, so it cannot be represented as a \
+recoverable Result"]
fn test_store_auth_with_prompt_invalid_answer() {
todo!()
}
#[test]
-#[ignore = "requires PHPUnit mock IOInterface/Config with expects()/willReturnMap(); no mocking framework"]
+#[ignore = "needs the extra prompt_auth_if_needed params (headers/retry_count/response_body) and \
+GitLab::authorize_oauth which shells out to real `git config`; depends on host git state and is a \
+design-level port concern"]
fn test_prompt_auth_if_needed_git_lab_no_auth_change() {
todo!()
}
#[test]
-#[ignore = "requires PHPUnit mock IOInterface/Config with willReturnMap()/willReturnCallback(); no mocking framework"]
+#[ignore = "drives Bitbucket::request_token over the network and relies on willReturnCallback \
+sequencing of getAuthentication; design-level port concern"]
fn test_prompt_auth_if_needed_multiple_bitbucket_downloads() {
todo!()
}
#[test]
-#[ignore = "requires PHPUnit mock IOInterface with expects()/willReturn() plus set_error_handler; no mocking framework"]
+#[ignore = "exercises the deprecated addAuthenticationHeader wrapper (not ported) which relies on \
+trigger_error/E_USER_DEPRECATED; the PHP error-handler subsystem is not modeled"]
+fn test_add_authentication_header_with_custom_headers() {
+ todo!()
+}
+
+#[test]
+#[ignore = "exercises the deprecated addAuthenticationHeader wrapper (not ported) which relies on \
+trigger_error/E_USER_DEPRECATED; the PHP error-handler subsystem is not modeled"]
fn test_add_authentication_header_is_working() {
todo!()
}
#[test]
-#[ignore = "requires PHPUnit mock IOInterface in setUp plus set_error_handler deprecation capture; no mocking framework"]
+#[ignore = "exercises the deprecated addAuthenticationHeader wrapper (not ported) which relies on \
+trigger_error/E_USER_DEPRECATED converted to a RuntimeException via set_error_handler; not modeled"]
fn test_add_authentication_header_deprecation() {
todo!()
}
diff --git a/crates/shirabe/tests/util/main.rs b/crates/shirabe/tests/util/main.rs
index d72bcc0..0208ca4 100644
--- a/crates/shirabe/tests/util/main.rs
+++ b/crates/shirabe/tests/util/main.rs
@@ -2,6 +2,8 @@
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"]