use std::process::{Command, Stdio};
const SIZE: usize = 4 * 1024 * 1024;
fn body() -> Vec<u8> {
(0..SIZE).map(|i| (i % 251) as u8).collect()
}
fn run_against(
get: impl FnOnce(&mut mockito::Server) -> mockito::Mock,
connections: &str,
) -> (Option<i32>, Option<Vec<u8>>) {
let (code, file, _) = run_capturing(get, connections);
(code, file)
}
fn run_with_args(
get: impl FnOnce(&mut mockito::Server) -> mockito::Mock,
connections: &str,
extra: &[&str],
) -> (Option<i32>, Option<Vec<u8>>, String) {
run_capturing_with_args(get, connections, extra)
}
fn run_capturing(
get: impl FnOnce(&mut mockito::Server) -> mockito::Mock,
connections: &str,
) -> (Option<i32>, Option<Vec<u8>>, String) {
run_capturing_with_args(get, connections, &[])
}
fn run_capturing_with_args(
get: impl FnOnce(&mut mockito::Server) -> mockito::Mock,
connections: &str,
extra: &[&str],
) -> (Option<i32>, Option<Vec<u8>>, String) {
let mut server = mockito::Server::new();
let url = format!("{}/file", server.url());
let _head = server
.mock("HEAD", "/file")
.with_status(200)
.with_header("content-length", &SIZE.to_string())
.with_header("accept-ranges", "bytes")
.with_header("etag", "rangeetag")
.create();
let _get = get(&mut server);
let data_dir = tempfile::tempdir().unwrap();
let save_dir = tempfile::tempdir().unwrap();
let out = save_dir.path().join("file");
let status = Command::new(env!("CARGO_BIN_EXE_odl"))
.arg(&url)
.arg("-o")
.arg(&out)
.arg("--download-dir")
.arg(data_dir.path())
.arg("--max-connections")
.arg(connections)
.arg("--max-retries")
.arg("0")
.arg("--format")
.arg("json")
.args(extra)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("failed to spawn odl binary");
let reported = String::from_utf8_lossy(&status.stderr).into_owned()
+ &String::from_utf8_lossy(&status.stdout);
(status.status.code(), std::fs::read(&out).ok(), reported)
}
#[test]
fn a_transfer_failure_is_reported_as_one() {
let (code, file, reported) = run_capturing(
|s| {
s.mock("GET", "/file")
.expect_at_least(1)
.with_status(500)
.with_body("boom")
.create()
},
"4",
);
assert!(
!reported.contains("shorter than recorded size"),
"the assembler should not be the one to notice: {reported}"
);
assert_eq!(code, Some(3), "a 500 is a network failure: {reported}");
assert!(
reported.contains("HTTP 500"),
"expected the status in the error, got: {reported}"
);
assert!(
file.is_none(),
"a failed download must leave no output file"
);
}
fn ignores_range(s: &mut mockito::Server) -> mockito::Mock {
s.mock("GET", "/file")
.expect_at_least(1)
.with_status(200)
.with_header("content-length", &SIZE.to_string())
.with_body(body())
.create()
}
#[test]
fn a_whole_file_answer_to_a_ranged_request_is_refused() {
let (code, file, _) = run_with_args(ignores_range, "4", &["--on-not-resumable", "abort"]);
assert_eq!(code, Some(4), "must surface as a conflict, not success");
assert!(
file.is_none(),
"no file may be delivered from a bad response"
);
}
#[test]
fn the_restart_after_an_unranged_answer_happens_at_most_once() {
let short = SIZE / 2;
let (code, file, reported) = run_with_args(
|s| {
s.mock("GET", "/file")
.expect_at_least(2)
.with_status(200)
.with_header("content-length", &short.to_string())
.with_body(&body()[..short])
.create()
},
"4",
&[],
);
assert_eq!(code, Some(4), "the second refusal must end it: {reported}");
assert!(file.is_none(), "no file may be delivered: {reported}");
}
#[test]
fn a_server_that_stops_honouring_range_is_re_downloaded_whole() {
let (code, file, reported) = run_with_args(ignores_range, "4", &[]);
assert_eq!(code, Some(0), "the restart should succeed: {reported}");
assert_eq!(
file.as_deref(),
Some(body().as_slice()),
"the restarted download must deliver the real bytes"
);
}
#[test]
fn an_error_page_is_not_written_as_part_data() {
let (code, file) = run_against(
|s| {
s.mock("GET", "/file")
.expect_at_least(1)
.with_status(500)
.with_body("<html>internal server error</html>")
.create()
},
"2",
);
assert_ne!(code, Some(0), "a failed transfer must not report success");
assert!(
file.is_none(),
"a failed download must leave no output file"
);
}
#[test]
fn a_chunked_answer_without_a_length_is_accepted() {
let (code, file) = run_against(
|s| {
s.mock("GET", "/file")
.expect_at_least(1)
.with_status(200)
.with_chunked_body(|w| w.write_all(&body()))
.create()
},
"1",
);
assert_eq!(code, Some(0), "a chunked whole-file answer is valid");
assert_eq!(file.as_deref(), Some(body().as_slice()));
}
#[test]
fn a_200_may_carry_content_range_and_is_judged_on_it() {
let starting_at_zero = run_against(
|s| {
s.mock("GET", "/file")
.expect_at_least(1)
.with_status(200)
.with_header("content-range", &format!("bytes 0-{}/{}", SIZE - 1, SIZE))
.with_header("content-length", &SIZE.to_string())
.with_body(body())
.create()
},
"1",
);
assert_eq!(
starting_at_zero.0,
Some(0),
"the whole file, plainly stated"
);
assert_eq!(starting_at_zero.1.as_deref(), Some(body().as_slice()));
let starting_elsewhere = run_against(
|s| {
s.mock("GET", "/file")
.expect_at_least(1)
.with_status(200)
.with_header(
"content-range",
&format!("bytes 1024-{}/{}", SIZE - 1, SIZE),
)
.with_chunked_body(|w| w.write_all(&body()[1024..]))
.create()
},
"1",
);
assert_eq!(
starting_elsewhere.0,
Some(4),
"a slice is not the whole file"
);
assert!(starting_elsewhere.1.is_none(), "no output from a refusal");
}
#[test]
fn a_single_connection_download_still_works_without_range_support() {
let (code, file) = run_against(
|s| {
s.mock("GET", "/file")
.expect_at_least(1)
.with_status(200)
.with_header("content-length", &SIZE.to_string())
.with_body(body())
.create()
},
"1",
);
assert_eq!(code, Some(0), "a whole-file answer to one part is valid");
assert_eq!(file.as_deref(), Some(body().as_slice()));
}