#[cfg(test)]
mod tests {
use std::io::{Read, Write};
use std::net::TcpListener;
use crate::http_client::{Client, HttpClientError};
fn start_fake_server(handler: impl Fn(String) -> String + Send + 'static) -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = vec![0u8; 4096];
let n = stream.read(&mut buf).unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]).to_string();
let resp = handler(req);
stream.write_all(resp.as_bytes()).ok();
}
});
format!("http://127.0.0.1:{}", addr.port())
}
#[test]
fn parse_url_http() {
let base = start_fake_server(|_req| {
"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nbody".to_string()
});
let url = format!("{}/path?q=1", base);
let resp = Client::new().get(&url).send().unwrap();
assert_eq!(200, resp.status());
assert_eq!(b"body", resp.bytes());
}
#[test]
fn parse_url_https_custom_port() {
let result = Client::new()
.get("https://127.0.0.1:19999/v1")
.timeout_ms(200)
.send();
match result {
Err(HttpClientError(msg)) => {
assert!(
!msg.contains("missing URL scheme"),
"URL was not parsed (got: {msg})"
);
}
Ok(_) => { }
}
}
#[test]
fn parse_url_missing_scheme_returns_error() {
let result = Client::new().get("example.com/path").timeout_ms(100).send();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.0.contains("missing URL scheme") || err.0.contains("unsupported"),
"expected scheme error, got: {}",
err.0
);
}
#[test]
fn get_plain_http() {
let base = start_fake_server(|_req| {
"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nbody".to_string()
});
let resp = Client::new().get(&base).send().unwrap();
assert_eq!(200, resp.status());
assert_eq!("body", resp.text().unwrap());
}
#[test]
fn post_with_body() {
let base = start_fake_server(|req| {
let has_len = req.contains("Content-Length: 5");
let has_body = req.contains("hello");
let status = if has_len && has_body { "200" } else { "400" };
format!("HTTP/1.1 {status} OK\r\nContent-Length: 2\r\n\r\nok")
});
let resp = Client::new()
.post(&base)
.body(b"hello".to_vec())
.send()
.unwrap();
assert_eq!(200, resp.status());
}
#[test]
fn response_with_chunked_encoding() {
let base = start_fake_server(|_req| {
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n7\r\nHello, \r\n6\r\nWorld!\r\n0\r\n\r\n".to_string()
});
let resp = Client::new().get(&base).send().unwrap();
assert_eq!(200, resp.status());
assert_eq!("Hello, World!", resp.text().unwrap());
}
#[test]
fn is_success_true_for_200_range() {
let base = start_fake_server(|_| {
"HTTP/1.1 201 Created\r\nContent-Length: 0\r\n\r\n".to_string()
});
let resp = Client::new().get(&base).send().unwrap();
assert!(resp.is_success());
}
#[test]
fn is_success_false_for_404() {
let base = start_fake_server(|_| {
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".to_string()
});
let resp = Client::new().get(&base).send().unwrap();
assert!(!resp.is_success());
}
#[test]
fn header_lookup_case_insensitive() {
let base = start_fake_server(|_| {
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}".to_string()
});
let resp = Client::new().get(&base).send().unwrap();
assert_eq!(Some("application/json"), resp.header("content-type"));
assert_eq!(Some("application/json"), resp.header("Content-Type"));
assert_eq!(Some("application/json"), resp.header("CONTENT-TYPE"));
}
#[test]
fn redirect_followed() {
let dest = start_fake_server(|_| {
"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\ndone".to_string()
});
let dest_url = dest.clone();
let redir = start_fake_server(move |_| {
format!(
"HTTP/1.1 301 Moved Permanently\r\nLocation: {dest_url}\r\nContent-Length: 0\r\n\r\n"
)
});
let resp = Client::new().get(&redir).send().unwrap();
assert_eq!(200, resp.status());
assert_eq!("done", resp.text().unwrap());
}
#[test]
fn max_redirects_exceeded_returns_last_response() {
let server_c = start_fake_server(|_| {
"HTTP/1.1 301 Moved\r\nLocation: http://127.0.0.1:1\r\nContent-Length: 0\r\n\r\n"
.to_string()
});
let c_url = server_c.clone();
let server_b = start_fake_server(move |_| {
format!(
"HTTP/1.1 301 Moved\r\nLocation: {c_url}\r\nContent-Length: 0\r\n\r\n"
)
});
let b_url = server_b.clone();
let server_a = start_fake_server(move |_| {
format!(
"HTTP/1.1 301 Moved\r\nLocation: {b_url}\r\nContent-Length: 0\r\n\r\n"
)
});
let resp = Client::new()
.max_redirects(2)
.get(&server_a)
.send()
.unwrap();
assert!(resp.is_redirect());
}
#[test]
fn custom_header_sent() {
let base = start_fake_server(|req| {
let found = req.contains("X-Custom: value");
let body = if found { "yes" } else { "no" };
format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}", body.len(), body)
});
let resp = Client::new()
.get(&base)
.header("X-Custom", "value")
.send()
.unwrap();
assert_eq!("yes", resp.text().unwrap());
}
#[test]
fn timeout_returns_error() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
if let Ok((_stream, _)) = listener.accept() {
std::thread::sleep(std::time::Duration::from_secs(5));
}
});
let url = format!("http://127.0.0.1:{}", addr.port());
let result = Client::new().timeout_ms(150).get(&url).send();
assert!(
result.is_err(),
"expected timeout error but got a response"
);
}
#[test]
fn delete_method_sent_correctly() {
let base = start_fake_server(|req| {
let is_delete = req.starts_with("DELETE ");
let body = if is_delete { "deleted" } else { "wrong" };
format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
)
});
let resp = Client::new().delete(&base).send().unwrap();
assert_eq!(200, resp.status());
assert_eq!("deleted", resp.text().unwrap());
}
#[test]
fn form_sets_content_type_and_urlencoded_body() {
let base = start_fake_server(|req| {
let has_ct = req.contains("Content-Type: application/x-www-form-urlencoded");
let has_body = req.contains("grant_type=authorization_code&code=abc%20123");
let status = if has_ct && has_body { "200" } else { "400" };
format!("HTTP/1.1 {status} OK\r\nContent-Length: 2\r\n\r\nok")
});
let resp = Client::new()
.post(&base)
.form(&[("grant_type", "authorization_code"), ("code", "abc 123")])
.send()
.unwrap();
assert_eq!(200, resp.status());
}
#[test]
fn form_encodes_reserved_characters_in_keys_and_values() {
let base = start_fake_server(|req| {
let has_body = req.contains("redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1");
let status = if has_body { "200" } else { "400" };
format!("HTTP/1.1 {status} OK\r\nContent-Length: 2\r\n\r\nok")
});
let resp = Client::new()
.post(&base)
.form(&[("redirect_uri", "https://example.com/cb?x=1")])
.send()
.unwrap();
assert_eq!(200, resp.status());
}
#[test]
fn form_empty_pairs_sends_content_type_with_empty_body() {
let base = start_fake_server(|req| {
let has_ct = req.contains("Content-Type: application/x-www-form-urlencoded");
let ends_with_headers = req.ends_with("\r\n\r\n");
let status = if has_ct && ends_with_headers { "200" } else { "400" };
format!("HTTP/1.1 {status} OK\r\nContent-Length: 2\r\n\r\nok")
});
let resp = Client::new().post(&base).form(&[]).send().unwrap();
assert_eq!(200, resp.status());
}
}