aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe/src/util/http_downloader.rs
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-06-25 14:57:23 +0900
committernsfisis <nsfisis@gmail.com>2026-06-26 00:20:05 +0900
commit8dd3d67884a204ca40e3364206868fea77a312be (patch)
treeea0b603220112dc5d6a512796cda616250b50213 /crates/shirabe/src/util/http_downloader.rs
parent230319ae3db47ed9c35dc7faa733a84f9ce87ff9 (diff)
downloadphp-shirabe-8dd3d67884a204ca40e3364206868fea77a312be.tar.gz
php-shirabe-8dd3d67884a204ca40e3364206868fea77a312be.tar.zst
php-shirabe-8dd3d67884a204ca40e3364206868fea77a312be.zip
feat(test): add HttpDownloaderMock, IOStub, and Config stub helpers
IOStub and ConfigStubBuilder provide getMockBuilder-style configurable stubs. Wired into util/repository/downloader/command test targets. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe/src/util/http_downloader.rs')
-rw-r--r--crates/shirabe/src/util/http_downloader.rs196
1 files changed, 196 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)]