diff options
Diffstat (limited to 'crates/shirabe')
| -rw-r--r-- | crates/shirabe/src/util/http_downloader.rs | 196 | ||||
| -rw-r--r-- | crates/shirabe/tests/command/main.rs | 6 | ||||
| -rw-r--r-- | crates/shirabe/tests/common/config_stub.rs | 77 | ||||
| -rw-r--r-- | crates/shirabe/tests/common/http_downloader_mock.rs | 77 | ||||
| -rw-r--r-- | crates/shirabe/tests/common/io_stub.rs | 211 | ||||
| -rw-r--r-- | crates/shirabe/tests/downloader/main.rs | 7 | ||||
| -rw-r--r-- | crates/shirabe/tests/repository/main.rs | 6 | ||||
| -rw-r--r-- | crates/shirabe/tests/util/main.rs | 6 |
8 files changed, 586 insertions, 0 deletions
diff --git a/crates/shirabe/src/util/http_downloader.rs b/crates/shirabe/src/util/http_downloader.rs index 0c7308d..edc84cc 100644 --- a/crates/shirabe/src/util/http_downloader.rs +++ b/crates/shirabe/src/util/http_downloader.rs @@ -54,6 +54,52 @@ pub struct HttpDownloader { disabled: bool, /// @var bool allow_async: bool, + /// Test-only internal hook. `None` in production. When `Some`, the request methods + /// (`get`/`copy`/`add`/`add_copy`) short-circuit to canned responses from the + /// expectation queue instead of performing real I/O. See ADR 0005 for the rationale + /// (the same internal-hook pattern used for `ProcessExecutor`). Mirrors + /// composer/tests/Composer/Test/Mock/HttpDownloaderMock.php. + mock: Option<HttpDownloaderMockState>, +} + +/// For testing only. State backing the `HttpDownloaderMock`: an optional expectation queue, +/// strict flag, default handler for undefined requests, and a log of received URLs. +#[derive(Debug, Clone)] +pub struct HttpDownloaderMockState { + expectations: Option<Vec<HttpDownloaderMockExpectation>>, + strict: bool, + default_handler: HttpDownloaderMockHandler, + log: Vec<String>, +} + +/// For testing only. A single expected HTTP request and the canned response to return. +/// `options` of `None` means "match any options"; otherwise the executed options must be +/// exactly equal (PHP `===`). +#[derive(Debug, Clone)] +pub struct HttpDownloaderMockExpectation { + pub url: String, + pub options: Option<IndexMap<String, PhpMixed>>, + pub status: i64, + pub body: String, + pub headers: Vec<String>, +} + +/// For testing only. The default response handler used for undefined requests when not strict. +#[derive(Debug, Clone)] +pub struct HttpDownloaderMockHandler { + pub status: i64, + pub body: String, + pub headers: Vec<String>, +} + +impl Default for HttpDownloaderMockHandler { + fn default() -> Self { + Self { + status: 200, + body: String::new(), + headers: Vec::new(), + } + } } struct Job { @@ -168,11 +214,15 @@ impl HttpDownloader { id_gen: 0, disabled, allow_async: false, + mock: None, } } /// Download a file synchronously pub fn get(&mut self, url: &str, options: IndexMap<String, PhpMixed>) -> Result<Response> { + if self.mock.is_some() { + return self.mock_get(url, &options); + } if url.is_empty() { return Err(InvalidArgumentException { message: "$url must not be an empty string".to_string(), @@ -201,6 +251,9 @@ impl HttpDownloader { url: &str, options: IndexMap<String, PhpMixed>, ) -> Result<Response> { + if self.mock.is_some() { + return self.mock_get(url, &options); + } if url.is_empty() { return Err(InvalidArgumentException { message: "$url must not be an empty string".to_string(), @@ -228,6 +281,9 @@ impl HttpDownloader { to: &str, options: IndexMap<String, PhpMixed>, ) -> Result<Response> { + if self.mock.is_some() { + return self.mock_get(url, &options); + } if url.is_empty() { return Err(InvalidArgumentException { message: "$url must not be an empty string".to_string(), @@ -255,6 +311,9 @@ impl HttpDownloader { to: &str, options: IndexMap<String, PhpMixed>, ) -> Result<Response> { + if self.mock.is_some() { + return self.mock_get(url, &options); + } if url.is_empty() { return Err(InvalidArgumentException { message: "$url must not be an empty string".to_string(), @@ -823,6 +882,143 @@ impl HttpDownloader { && function_exists("curl_multi_exec") && function_exists("curl_multi_init") } + + /// For testing only. Mirrors HttpDownloaderMock::expects: installs the expectation queue, + /// strict flag and default handler used by the mock request path. + pub fn __expects( + &mut self, + expectations: Vec<HttpDownloaderMockExpectation>, + strict: bool, + default_handler: HttpDownloaderMockHandler, + ) { + self.mock = Some(HttpDownloaderMockState { + expectations: Some(expectations), + strict, + default_handler, + log: Vec::new(), + }); + } + + /// For testing only. Mirrors HttpDownloaderMock::assertComplete: panics if any expected + /// HTTP requests remain unconsumed. + pub fn __assert_complete(&self) { + let Some(mock) = &self.mock else { + return; + }; + // this was not configured to expect anything, so no need to react here + let Some(expectations) = &mock.expectations else { + return; + }; + if !expectations.is_empty() { + let urls: Vec<String> = expectations.iter().map(|e| e.url.clone()).collect(); + panic!( + "There are still {} expected HTTP requests which have not been consumed:{}{}{}{}Received calls:{}{}", + expectations.len(), + shirabe_php_shim::PHP_EOL, + urls.join(shirabe_php_shim::PHP_EOL), + shirabe_php_shim::PHP_EOL, + shirabe_php_shim::PHP_EOL, + shirabe_php_shim::PHP_EOL, + mock.log.join(shirabe_php_shim::PHP_EOL), + ); + } + } + + /// For testing only. Mirrors HttpDownloaderMock::get: consumes the next matching expectation + /// (or falls back to the default handler when not strict) and returns the canned response. + fn mock_get( + &mut self, + file_url: &str, + options: &IndexMap<String, PhpMixed>, + ) -> Result<Response> { + if file_url.is_empty() { + return Err(LogicException { + message: "url cannot be an empty string".to_string(), + code: 0, + } + .into()); + } + + let mock = self.mock.as_mut().expect("mock_get called without a mock"); + mock.log.push(file_url.to_string()); + + let matches_first = mock + .expectations + .as_ref() + .and_then(|e| e.first()) + .is_some_and(|first| { + first.url == file_url + && (first.options.is_none() || first.options.as_ref() == Some(options)) + }); + + if matches_first { + let expect = mock.expectations.as_mut().unwrap().remove(0); + return Self::mock_respond(file_url, expect.status, expect.headers, expect.body); + } + + if !mock.strict { + let handler = mock.default_handler.clone(); + return Self::mock_respond(file_url, handler.status, handler.headers, handler.body); + } + + let next_expected = mock + .expectations + .as_ref() + .and_then(|e| e.first()) + .map(|first| { + let opts = if first.options.is_some() { + format!( + "\" with options \"{}", + serde_json::to_string(&first.options).unwrap_or_default() + ) + } else { + String::new() + }; + format!("Expected \"{}{}\" at this point.", first.url, opts) + }) + .unwrap_or_else(|| "Expected no more calls at this point.".to_string()); + let prior_calls = if mock.log.len() > 1 { + mock.log[..mock.log.len() - 1].join(shirabe_php_shim::PHP_EOL) + } else { + String::new() + }; + panic!( + "Received unexpected request for \"{}\" with options \"{}\"{}{}{}Received calls:{}{}", + file_url, + serde_json::to_string(options).unwrap_or_default(), + shirabe_php_shim::PHP_EOL, + next_expected, + shirabe_php_shim::PHP_EOL, + shirabe_php_shim::PHP_EOL, + prior_calls, + ); + } + + /// For testing only. Mirrors HttpDownloaderMock::respond. + fn mock_respond( + url: &str, + status: i64, + headers: Vec<String>, + body: String, + ) -> Result<Response> { + if status < 400 { + return Ok(Response::new( + url.to_string(), + Some(status), + headers, + Some(body), + )); + } + + let mut e = TransportException::new( + format!("The \"{}\" file could not be downloaded", url), + status, + ); + e.set_headers(headers); + e.set_response(Some(body)); + + Err(e.into()) + } } #[derive(Debug, Clone, Copy)] diff --git a/crates/shirabe/tests/command/main.rs b/crates/shirabe/tests/command/main.rs index 78345fe..13efc0d 100644 --- a/crates/shirabe/tests/command/main.rs +++ b/crates/shirabe/tests/command/main.rs @@ -1,3 +1,9 @@ +#[path = "../common/config_stub.rs"] +mod config_stub; +#[path = "../common/http_downloader_mock.rs"] +mod http_downloader_mock; +#[path = "../common/io_stub.rs"] +mod io_stub; #[path = "../common/test_case.rs"] mod test_case; diff --git a/crates/shirabe/tests/common/config_stub.rs b/crates/shirabe/tests/common/config_stub.rs new file mode 100644 index 0000000..7566b89 --- /dev/null +++ b/crates/shirabe/tests/common/config_stub.rs @@ -0,0 +1,77 @@ +//! Configurable Config stub, equivalent to PHPUnit's +//! `getMockBuilder(Config::class)` where `get('x')` is stubbed via +//! `->method('get')->willReturn(y)`. +//! +//! `Config` is a concrete (non-trait) struct, so rather than mocking it we build a +//! real `Config` and seed the requested keys through `Config::merge`, which is how +//! `get('key')` actually resolves its value. This keeps the stub faithful to the +//! real resolution logic instead of intercepting `get`. +#![allow(dead_code)] + +use std::cell::RefCell; +use std::rc::Rc; + +use indexmap::IndexMap; +use shirabe::config::Config; +use shirabe_php_shim::PhpMixed; + +pub struct ConfigStubBuilder { + use_environment: bool, + base_dir: Option<String>, + // Config values to seed, applied via Config::merge under the `config` section. + values: IndexMap<String, PhpMixed>, + source: String, +} + +impl Default for ConfigStubBuilder { + fn default() -> Self { + Self { + use_environment: false, + base_dir: None, + values: IndexMap::new(), + source: Config::SOURCE_UNKNOWN.to_string(), + } + } +} + +impl ConfigStubBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn use_environment(mut self, value: bool) -> Self { + self.use_environment = value; + self + } + + pub fn base_dir(mut self, base_dir: impl Into<String>) -> Self { + self.base_dir = Some(base_dir.into()); + self + } + + /// Sets the value `Config::get(key)` will return. Equivalent to PHPUnit's + /// `->method('get')->with(key)->willReturn(value)`. + pub fn with(mut self, key: impl Into<String>, value: PhpMixed) -> Self { + self.values.insert(key.into(), value); + self + } + + pub fn build(self) -> Config { + let mut config = Config::new(self.use_environment, self.base_dir); + if !self.values.is_empty() { + let mut merge: IndexMap<String, PhpMixed> = IndexMap::new(); + merge.insert("config".to_string(), PhpMixed::Array(self.values)); + config.merge(&merge, &self.source); + } + config + } + + pub fn build_shared(self) -> Rc<RefCell<Config>> { + Rc::new(RefCell::new(self.build())) + } +} + +// For testing only. Convenience for the common case of a default `Config(false)`. +pub fn get_config_stub() -> Config { + ConfigStubBuilder::new().build() +} diff --git a/crates/shirabe/tests/common/http_downloader_mock.rs b/crates/shirabe/tests/common/http_downloader_mock.rs new file mode 100644 index 0000000..249585c --- /dev/null +++ b/crates/shirabe/tests/common/http_downloader_mock.rs @@ -0,0 +1,77 @@ +//! ref: composer/tests/Composer/Test/Mock/HttpDownloaderMock.php +#![allow(dead_code)] + +use std::cell::RefCell; +use std::rc::Rc; + +use indexmap::IndexMap; +use shirabe::config::Config; +use shirabe::io::IOInterface; +use shirabe::io::null_io::NullIO; +use shirabe::util::http_downloader::{ + HttpDownloader, HttpDownloaderMockExpectation, HttpDownloaderMockHandler, +}; +use shirabe_php_shim::PhpMixed; + +// A single HTTP request expectation as written in the PHP tests: a `url` plus an +// optional response (`status`/`body`/`headers`). `options` of `None` matches any +// options; `Some(..)` requires an exact match against the executed options. +pub fn expect(url: impl Into<String>) -> HttpDownloaderMockExpectation { + HttpDownloaderMockExpectation { + url: url.into(), + options: None, + status: 200, + body: String::new(), + headers: vec![String::new()], + } +} + +pub fn expect_full( + url: impl Into<String>, + options: Option<IndexMap<String, PhpMixed>>, + status: i64, + body: impl Into<String>, + headers: Vec<String>, +) -> HttpDownloaderMockExpectation { + HttpDownloaderMockExpectation { + url: url.into(), + options, + status, + body: body.into(), + headers, + } +} + +pub struct HttpDownloaderMockGuard(Rc<RefCell<HttpDownloader>>); + +impl Drop for HttpDownloaderMockGuard { + fn drop(&mut self) { + // Avoid aborting on a double panic when a test assertion is already unwinding. + if std::thread::panicking() { + return; + } + self.0.borrow().__assert_complete(); + } +} + +// For testing only. Mirrors TestCase::getHttpDownloaderMock: returns a shared +// HttpDownloader handle configured with the given expectations, plus a guard that +// runs `__assert_complete` when it drops at the end of the test scope. +pub fn get_http_downloader_mock( + expectations: Vec<HttpDownloaderMockExpectation>, + strict: bool, + default_handler: HttpDownloaderMockHandler, +) -> (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, + ))); + downloader + .borrow_mut() + .__expects(expectations, strict, default_handler); + (downloader.clone(), HttpDownloaderMockGuard(downloader)) +} diff --git a/crates/shirabe/tests/common/io_stub.rs b/crates/shirabe/tests/common/io_stub.rs new file mode 100644 index 0000000..9318d45 --- /dev/null +++ b/crates/shirabe/tests/common/io_stub.rs @@ -0,0 +1,211 @@ +//! Configurable IOInterface stub, equivalent to PHPUnit's +//! `getMockBuilder(IOInterface::class)->getMock()` where individual methods are +//! configured via `->method('x')->willReturn(y)`. +//! +//! Each configurable method is backed by an `Option<ReturnValue>` field plus a +//! builder-style setter. Unset methods fall back to NullIO-equivalent defaults. +#![allow(dead_code)] + +use shirabe::config::Config; +use shirabe::io::{BaseIO, IOInterface, IOInterfaceImmutable, IOInterfaceMutable}; +use shirabe_php_shim::PhpMixed; + +#[derive(Debug, Default)] +pub struct IOStub { + authentications: indexmap::IndexMap<String, indexmap::IndexMap<String, Option<String>>>, + + is_interactive: Option<bool>, + is_decorated: Option<bool>, + is_verbose: Option<bool>, + is_very_verbose: Option<bool>, + is_debug: Option<bool>, + + // `hasAuthentication` is keyed: PHPUnit tests usually configure a single bool + // willReturn that applies to any argument, so a single Option<bool> matches. + has_authentication: Option<bool>, + // `getAuthentication` willReturn is a `['username' => ..., 'password' => ...]` array. + get_authentication: Option<indexmap::IndexMap<String, Option<String>>>, + + ask: Option<PhpMixed>, + ask_confirmation: Option<bool>, + ask_and_hide_answer: Option<Option<String>>, +} + +impl IOStub { + pub fn new() -> Self { + Self::default() + } + + pub fn with_is_interactive(mut self, value: bool) -> Self { + self.is_interactive = Some(value); + self + } + pub fn with_is_decorated(mut self, value: bool) -> Self { + self.is_decorated = Some(value); + self + } + pub fn with_is_verbose(mut self, value: bool) -> Self { + self.is_verbose = Some(value); + self + } + pub fn with_is_very_verbose(mut self, value: bool) -> Self { + self.is_very_verbose = Some(value); + self + } + pub fn with_is_debug(mut self, value: bool) -> Self { + self.is_debug = Some(value); + self + } + pub fn with_has_authentication(mut self, value: bool) -> Self { + self.has_authentication = Some(value); + self + } + pub fn with_get_authentication( + mut self, + value: indexmap::IndexMap<String, Option<String>>, + ) -> Self { + self.get_authentication = Some(value); + self + } + pub fn with_ask(mut self, value: PhpMixed) -> Self { + self.ask = Some(value); + self + } + pub fn with_ask_confirmation(mut self, value: bool) -> Self { + self.ask_confirmation = Some(value); + self + } + pub fn with_ask_and_hide_answer(mut self, value: Option<String>) -> Self { + self.ask_and_hide_answer = Some(value); + self + } +} + +impl IOInterfaceImmutable for IOStub { + fn is_interactive(&self) -> bool { + self.is_interactive.unwrap_or(false) + } + fn is_verbose(&self) -> bool { + self.is_verbose.unwrap_or(false) + } + fn is_very_verbose(&self) -> bool { + self.is_very_verbose.unwrap_or(false) + } + fn is_debug(&self) -> bool { + self.is_debug.unwrap_or(false) + } + fn is_decorated(&self) -> bool { + self.is_decorated.unwrap_or(false) + } + + fn write3(&self, _message: &str, _newline: bool, _verbosity: i64) {} + fn write_error3(&self, _message: &str, _newline: bool, _verbosity: i64) {} + fn write_raw3(&self, _message: &str, _newline: bool, _verbosity: i64) {} + fn write_error_raw3(&self, _message: &str, _newline: bool, _verbosity: i64) {} + fn overwrite4(&self, _message: &str, _newline: bool, _size: Option<i64>, _verbosity: i64) {} + fn overwrite_error4( + &self, + _message: &str, + _newline: bool, + _size: Option<i64>, + _verbosity: i64, + ) { + } + + fn ask(&self, _question: String, default: PhpMixed) -> PhpMixed { + self.ask.clone().unwrap_or(default) + } + fn ask_confirmation(&self, _question: String, default: bool) -> bool { + self.ask_confirmation.unwrap_or(default) + } + fn ask_and_validate( + &self, + _question: String, + _validator: Box<dyn Fn(PhpMixed) -> anyhow::Result<PhpMixed>>, + _attempts: Option<i64>, + default: PhpMixed, + ) -> anyhow::Result<PhpMixed> { + Ok(default) + } + fn ask_and_hide_answer(&self, _question: String) -> Option<String> { + self.ask_and_hide_answer.clone().unwrap_or(None) + } + fn select( + &self, + _question: String, + _choices: Vec<String>, + default: PhpMixed, + _attempts: PhpMixed, + _error_message: String, + _multiselect: bool, + ) -> PhpMixed { + default + } + + fn get_authentications( + &self, + ) -> indexmap::IndexMap<String, indexmap::IndexMap<String, Option<String>>> { + <Self as BaseIO>::get_authentications(self) + } + fn has_authentication(&self, repository_name: &str) -> bool { + match self.has_authentication { + Some(value) => value, + None => <Self as BaseIO>::has_authentication(self, repository_name), + } + } + fn get_authentication( + &self, + repository_name: &str, + ) -> indexmap::IndexMap<String, Option<String>> { + match &self.get_authentication { + Some(value) => value.clone(), + None => <Self as BaseIO>::get_authentication(self, repository_name), + } + } + + fn error(&self, message: &str, context: &[(&str, &str)]) { + <Self as BaseIO>::error(self, message, context); + } + fn warning(&self, message: &str, context: &[(&str, &str)]) { + <Self as BaseIO>::warning(self, message, context); + } + fn debug(&self, message: &str, context: &[(&str, &str)]) { + <Self as BaseIO>::debug(self, message, context); + } +} + +impl IOInterfaceMutable for IOStub { + fn set_authentication( + &mut self, + repository_name: String, + username: String, + password: Option<String>, + ) { + <Self as BaseIO>::set_authentication(self, repository_name, username, password) + } + fn load_configuration(&mut self, config: &mut Config) -> anyhow::Result<()> { + <Self as BaseIO>::load_configuration(self, config) + } +} + +impl IOInterface for IOStub { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn as_base_io_mut(&mut self) -> Option<&mut dyn BaseIO> { + Some(self) + } +} + +impl BaseIO for IOStub { + fn authentications( + &self, + ) -> &indexmap::IndexMap<String, indexmap::IndexMap<String, Option<String>>> { + &self.authentications + } + fn authentications_mut( + &mut self, + ) -> &mut indexmap::IndexMap<String, indexmap::IndexMap<String, Option<String>>> { + &mut self.authentications + } +} diff --git a/crates/shirabe/tests/downloader/main.rs b/crates/shirabe/tests/downloader/main.rs index 6e39b85..a2bbab4 100644 --- a/crates/shirabe/tests/downloader/main.rs +++ b/crates/shirabe/tests/downloader/main.rs @@ -1,3 +1,10 @@ +#[path = "../common/config_stub.rs"] +mod config_stub; +#[path = "../common/http_downloader_mock.rs"] +mod http_downloader_mock; +#[path = "../common/io_stub.rs"] +mod io_stub; + mod archive_downloader_test; mod download_manager_test; mod file_downloader_test; diff --git a/crates/shirabe/tests/repository/main.rs b/crates/shirabe/tests/repository/main.rs index 2995b24..15a42a3 100644 --- a/crates/shirabe/tests/repository/main.rs +++ b/crates/shirabe/tests/repository/main.rs @@ -1,3 +1,9 @@ +#[path = "../common/config_stub.rs"] +mod config_stub; +#[path = "../common/http_downloader_mock.rs"] +mod http_downloader_mock; +#[path = "../common/io_stub.rs"] +mod io_stub; #[path = "../common/test_case.rs"] mod test_case; diff --git a/crates/shirabe/tests/util/main.rs b/crates/shirabe/tests/util/main.rs index bf23c6b..d72bcc0 100644 --- a/crates/shirabe/tests/util/main.rs +++ b/crates/shirabe/tests/util/main.rs @@ -1,3 +1,9 @@ +#[path = "../common/config_stub.rs"] +mod config_stub; +#[path = "../common/http_downloader_mock.rs"] +mod http_downloader_mock; +#[path = "../common/io_stub.rs"] +mod io_stub; #[path = "../common/process_executor_mock.rs"] mod process_executor_mock; |
