#![allow(
clippy::too_many_lines,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::similar_names
)]
use std::convert::Infallible;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use fast_down_api::{
Event, PartialConfig, Rx, StateError, WriteMethod, create_cancellation_token, create_channel,
download, download_from_fd, resume,
};
use futures::StreamExt;
use futures::stream::unfold;
use http_body_util::combinators::BoxBody;
use http_body_util::{BodyExt, Empty, StreamBody};
use hyper::body::{Frame, Incoming};
use hyper::header::{
ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, ETAG, LAST_MODIFIED, LOCATION, RANGE,
};
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
use tokio::sync::RwLock;
use tokio::time::timeout;
use tokio_util::sync::CancellationToken;
use url::Url;
const FILE_SIZE: usize = 5 * 1024 * 1024;
const THROTTLE_CHUNK: usize = 64 * 1024;
const THROTTLE_MS: u64 = 150;
type RespBody = BoxBody<Bytes, Infallible>;
struct FileData {
body: Vec<u8>,
etag: String,
last_modified: String,
supports_range: bool,
}
#[derive(Clone)]
struct TestServer {
data: Arc<RwLock<FileData>>,
}
impl TestServer {
async fn serve(&self) -> String {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind server socket");
let addr = listener.local_addr().expect("resolve local addr");
let server = self.clone();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.expect("accept connection");
let conn_server = server.clone();
tokio::spawn(async move {
let io = TokioIo::new(stream);
let service = service_fn(move |req| {
let s = conn_server.clone();
handle(s, req)
});
let _ = http1::Builder::new().serve_connection(io, service).await;
});
}
});
format!("http://{addr}")
}
async fn set_content(&self, body: Vec<u8>, etag: &str, last_modified: &str) {
let mut data = self.data.write().await;
data.body = body;
data.etag = etag.to_string();
data.last_modified = last_modified.to_string();
}
}
async fn handle(
server: TestServer,
req: Request<Incoming>,
) -> Result<Response<RespBody>, Infallible> {
if req.uri().path() == "/redirect" {
return Ok(Response::builder()
.status(StatusCode::FOUND)
.header(LOCATION, "/real")
.body(BoxBody::new(Empty::<Bytes>::new()))
.expect("build 302 response"));
}
let data = server.data.read().await;
let total = data.body.len();
let supports_range = data.supports_range;
let etag = data.etag.clone();
let last_modified = data.last_modified.clone();
let body = data.body.clone();
drop(data);
let range = req
.headers()
.get(RANGE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
if supports_range
&& let Some(header) = range
&& let Some((start, end)) = parse_range(&header, total)
{
let chunk = body[start..end].to_vec();
let end_inclusive = end - 1;
let content_range = format!("bytes {start}-{end_inclusive}/{total}");
return Ok(Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(CONTENT_RANGE, content_range)
.header(ACCEPT_RANGES, "bytes")
.header(CONTENT_LENGTH, chunk.len().to_string())
.header(ETAG, etag.as_str())
.header(LAST_MODIFIED, last_modified.as_str())
.body(throttled_stream(chunk))
.expect("build 206 response"));
}
Ok(Response::builder()
.status(StatusCode::OK)
.header(CONTENT_LENGTH, body.len().to_string())
.header(ETAG, etag.as_str())
.header(LAST_MODIFIED, last_modified.as_str())
.body(throttled_stream(body))
.expect("build 200 response"))
}
fn parse_range(header: &str, total: usize) -> Option<(usize, usize)> {
let spec = header.trim().strip_prefix("bytes=")?;
let (start_s, end_s) = spec.split_once('-')?;
let start = if start_s.is_empty() {
0
} else {
start_s.trim().parse().ok()?
};
let end = if end_s.is_empty() {
total
} else {
end_s.trim().parse::<usize>().ok()?.saturating_add(1)
};
let end = end.min(total);
if start >= end {
return None;
}
Some((start, end))
}
fn throttled_stream(data: Vec<u8>) -> RespBody {
let stream = unfold((0usize, data), |(pos, data)| async move {
if pos >= data.len() {
return None;
}
tokio::time::sleep(Duration::from_millis(THROTTLE_MS)).await;
let end = (pos + THROTTLE_CHUNK).min(data.len());
Some((
Ok::<_, Infallible>(Bytes::copy_from_slice(&data[pos..end])),
(end, data),
))
});
let framed = stream.map(|result| result.map(Frame::data));
BodyExt::boxed(StreamBody::new(framed))
}
async fn start_server(
body: Vec<u8>,
etag: &str,
last_modified: &str,
supports_range: bool,
) -> (TestServer, String) {
let server = TestServer {
data: Arc::new(RwLock::new(FileData {
body,
etag: etag.to_string(),
last_modified: last_modified.to_string(),
supports_range,
})),
};
let url = server.serve().await;
(server, url)
}
fn temp_dir(name: &str) -> PathBuf {
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("fast_down_api_resume_{name}_{n}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn make_config(save_dir: &Path) -> PartialConfig {
make_config_with(save_dir, 32, 1024 * 1024)
}
fn make_config_with(save_dir: &Path, threads: usize, min_chunk_size: u64) -> PartialConfig {
PartialConfig {
save_dir: Some(save_dir.to_path_buf()),
filename: Some("out.bin".to_string()),
parse_filename: Some(false),
overwrite: Some(true),
write_method: Some(WriteMethod::Mmap),
min_chunk_size: Some(min_chunk_size),
threads: Some(threads),
cache_high_watermark: Some(1),
cache_low_watermark: Some(0),
write_buffer_size: Some(1),
..Default::default()
}
}
async fn drain(rx: Rx) -> Vec<Event> {
let mut events = Vec::new();
while let Ok(e) = rx.recv().await {
events.push(e);
}
events
}
const CANCEL_AFTER_BYTES: u64 = (THROTTLE_CHUNK * 2) as u64;
const MULTI_RANGE_GAP: u64 = 2 * 1024 * 1024;
async fn partial_download_via_cancel(
url: &str,
save_dir: &Path,
cancel: CancellationToken,
) -> Vec<Event> {
partial_download_via_cancel_with(url, make_config(save_dir), cancel, CANCEL_AFTER_BYTES).await
}
async fn partial_download_via_cancel_with(
url: &str,
cfg: PartialConfig,
cancel: CancellationToken,
cancel_after: u64,
) -> Vec<Event> {
let (tx, rx) = create_channel();
download(Url::parse(url).expect("valid url"), cfg, tx, cancel.clone());
let mut events = Vec::new();
let mut started = false;
let mut written = 0u64;
let mut cancelled_at = None;
let mut seen_far = false;
while let Ok(e) = rx.recv().await {
if matches!(e, Event::Start { .. }) {
started = true;
}
if let Event::PushProgress(ref p) = e {
written += p.end - p.start;
if p.start >= MULTI_RANGE_GAP {
seen_far = true;
}
if cancelled_at.is_none() && written >= cancel_after && seen_far {
cancelled_at = Some(written);
cancel.cancel();
}
}
events.push(e);
}
assert!(started, "expected Event::Start during the partial download");
let Some(cancelled_at) = cancelled_at else {
panic!("expected at least {cancel_after} bytes written before cancelling (got {written})")
};
assert!(
cancelled_at < FILE_SIZE as u64,
"cancel must land mid-download, but all {cancelled_at} of {FILE_SIZE} bytes \
had already reached the sink when it fired"
);
assert!(
!events.iter().any(|e| matches!(e, Event::Renamed(_))),
"a cancelled download must not rename the .part file"
);
events
}
fn original_bytes() -> Vec<u8> {
vec![0xAA; FILE_SIZE]
}
fn new_bytes() -> Vec<u8> {
vec![0xBB; FILE_SIZE]
}
#[tokio::test]
async fn test_resume_success() {
let dir = temp_dir("resume_success");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cancel = create_cancellation_token();
partial_download_via_cancel(&url, &dir, cancel).await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
assert!(part.exists(), ".part must exist after cancel");
assert!(fd.exists(), ".fd must exist after cancel");
assert!(
!final_path.exists(),
"final file must NOT exist after a cancelled download"
);
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let resume_cancel = create_cancellation_token();
resume(
part.clone(),
Some(Url::parse(&url).expect("valid url")),
cfg,
tx,
resume_cancel,
);
let events = drain(rx).await;
let (_, size) = events
.iter()
.find_map(|e| match e {
Event::Resumed { progress, size, .. } => Some((progress, *size)),
_ => None,
})
.expect("expected Event::Resumed");
assert_eq!(
size, FILE_SIZE as u64,
"Event::Resumed.size must equal file size"
);
let renamed = events
.iter()
.find_map(|e| match e {
Event::Renamed(p) => Some(p.clone()),
_ => None,
})
.expect("expected Event::Renamed");
assert!(
!events.iter().any(|e| matches!(e, Event::ResumeError(_))),
"resume() must not emit a ResumeError on a valid state"
);
let got = tokio::fs::read(&renamed).await.expect("read final file");
assert_eq!(got, original_bytes(), "resumed file content mismatch");
assert!(
!fd.exists(),
".fd must be removed after a successful resume (contract §1)"
);
}
#[tokio::test]
async fn test_file_changed_download_falls_back() {
let dir = temp_dir("file_changed_download");
let (server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cancel = create_cancellation_token();
partial_download_via_cancel(&url, &dir, cancel).await;
server.set_content(new_bytes(), "new", "LM-B").await;
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let dl_cancel = create_cancellation_token();
download(Url::parse(&url).expect("valid url"), cfg, tx, dl_cancel);
let events = drain(rx).await;
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"download() must complete (Renamed) after a silent fallback"
);
assert!(
!events.iter().any(|e| matches!(e, Event::ResumeError(_))),
"download() must NOT emit a ResumeError"
);
let final_path = dir.join("out.bin");
let got = tokio::fs::read(&final_path)
.await
.expect("final file should exist");
assert_eq!(got, new_bytes(), "final file must be the NEW content");
let fd = final_path.with_added_extension("fd");
assert!(
!fd.exists(),
".fd must be removed after a successful download (contract §1)"
);
}
#[tokio::test]
async fn test_file_changed_resume_reports_error() {
let dir = temp_dir("file_changed_resume");
let (server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cancel = create_cancellation_token();
partial_download_via_cancel(&url, &dir, cancel).await;
server.set_content(new_bytes(), "new", "LM-B").await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let resume_cancel = create_cancellation_token();
resume(
part.clone(),
Some(Url::parse(&url).expect("valid url")),
cfg,
tx,
resume_cancel,
);
let events = drain(rx).await;
let err = events
.iter()
.find_map(|e| match e {
Event::ResumeError(r) => Some(r),
_ => None,
})
.expect("expected Event::ResumeError");
assert!(
matches!(err, StateError::FileChanged { .. }),
"expected Event::ResumeError(StateError::FileChanged), got {err:?}"
);
assert!(
!events.iter().any(|e| matches!(e, Event::Renamed(_))),
"resume() must NOT rename on FileChanged"
);
assert!(part.exists(), ".part must be kept on resume error");
assert!(fd.exists(), ".fd must be kept on resume error");
assert!(
!final_path.exists(),
"final file must not exist on resume error"
);
}
#[tokio::test]
async fn test_resume_no_state_file() {
let dir = temp_dir("resume_no_state_file");
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
let _ = tokio::fs::remove_file(&fd).await;
let _ = std::fs::File::create(&part).expect("create .part");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
resume(
part.clone(),
Some(Url::parse(&url).expect("valid url")),
cfg,
tx,
cancel,
);
let events = drain(rx).await;
let err = events
.iter()
.find_map(|e| match e {
Event::ResumeError(r) => Some(r),
_ => None,
})
.expect("expected Event::ResumeError");
assert!(
matches!(err, StateError::Open(_)),
"expected Event::ResumeError(StateError::Open) for missing .fd, got {err:?}"
);
assert!(
!events.iter().any(|e| matches!(e, Event::Renamed(_))),
"resume() must NOT rename when there is no .fd"
);
}
#[tokio::test]
async fn test_resume_not_resumable() {
let dir = temp_dir("resume_not_resumable");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", false).await;
let cancel = create_cancellation_token();
partial_download_via_cancel(&url, &dir, cancel).await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
resume(
part.clone(),
Some(Url::parse(&url).expect("valid url")),
cfg,
tx,
cancel,
);
let events = drain(rx).await;
let err = events
.iter()
.find_map(|e| match e {
Event::ResumeError(r) => Some(r),
_ => None,
})
.expect("expected Event::ResumeError");
assert!(
matches!(err, StateError::NotResumable(..)),
"expected Event::ResumeError(StateError::NotResumable), got {err:?}"
);
}
#[tokio::test]
async fn test_cancel_keeps_part_and_fd_then_resume() {
let dir = temp_dir("cancel_keeps_state");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cancel = create_cancellation_token();
partial_download_via_cancel(&url, &dir, cancel).await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
assert!(
part.exists() && fd.exists(),
"cancel must preserve both .part and .fd"
);
assert!(
!final_path.exists(),
"cancel must NOT create the final file"
);
let fd_text = std::fs::read_to_string(&fd).expect("read .fd");
assert!(
fd_text.contains("downloaded_chunk"),
"cancelled .fd must carry resume progress, got:\n{fd_text}"
);
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let resume_cancel = create_cancellation_token();
resume(
part.clone(),
Some(Url::parse(&url).expect("valid url")),
cfg,
tx,
resume_cancel,
);
let events = drain(rx).await;
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"resume() must complete with Renamed after a cancel"
);
let resumed_from = events.iter().find_map(|e| match e {
Event::Resumed { progress, .. } => Some(progress.clone()),
_ => None,
});
let resumed_from = resumed_from.expect("resume() must emit Event::Resumed");
let resumed_bytes: u64 = resumed_from.iter().map(|r| r.end - r.start).sum();
assert!(
resumed_bytes >= CANCEL_AFTER_BYTES,
"resume must inherit the cancelled run's progress, got {resumed_bytes} bytes \
from {resumed_from:?}"
);
assert!(
!fd.exists(),
".fd must be removed after the resume completes"
);
let got = tokio::fs::read(&final_path)
.await
.expect("final file should exist after resume");
assert_eq!(
got,
original_bytes(),
"final content must match after resume"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_concurrent_resume_with_fragmented_progress() {
let dir = temp_dir("concurrent_fragmented");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cancel = create_cancellation_token();
partial_download_via_cancel_with(
&url,
make_config_with(&dir, 8, 64 * 1024),
cancel,
(THROTTLE_CHUNK * 6) as u64,
)
.await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
assert!(
part.exists() && fd.exists(),
"cancel must preserve both .part and .fd"
);
let fd_text = std::fs::read_to_string(&fd).expect("read .fd");
let chunk_line = fd_text
.lines()
.find(|l| l.trim_start().starts_with("downloaded_chunk"))
.unwrap_or_else(|| panic!("concurrent .fd must carry progress, got:\n{fd_text}"));
assert!(
chunk_line.contains(','),
"a cancelled concurrent run must persist multiple ranges, got: {chunk_line}"
);
let (tx, rx) = create_channel();
let resume_cancel = create_cancellation_token();
resume(
part.clone(),
Some(Url::parse(&url).expect("valid url")),
make_config_with(&dir, 8, 64 * 1024),
tx,
resume_cancel,
);
let events = drain(rx).await;
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"resume() must complete with Renamed"
);
let got = tokio::fs::read(&final_path)
.await
.expect("final file should exist after resume");
assert_eq!(
got,
original_bytes(),
"resuming fragmented progress must reassemble the file exactly"
);
}
#[tokio::test]
async fn test_resume_with_rotated_url_succeeds() {
let dir = temp_dir("resume_rotated_url");
let (_server, base) = start_server(original_bytes(), "orig", "LM-A", true).await;
let initial_url = format!("{base}/redirect");
let final_url = format!("{base}/real");
let cancel = create_cancellation_token();
partial_download_via_cancel_with(
&initial_url,
make_config(&dir),
cancel,
(THROTTLE_CHUNK * 6) as u64,
)
.await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
assert!(
part.exists() && fd.exists(),
"cancel must leave .part and .fd"
);
let fd_text = std::fs::read_to_string(&fd).expect("read .fd");
let seed_url_line = fd_text
.lines()
.find(|l| l.trim_start().starts_with("url = "))
.expect("precondition: .fd must record a url");
assert!(
seed_url_line.contains("/redirect"),
"seed .fd must store the initial URL, got: {seed_url_line}"
);
assert!(
!seed_url_line.contains(final_url.as_str()),
"seed .fd must NOT contain the final URL, got: {seed_url_line}"
);
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let resume_cancel = create_cancellation_token();
resume(
part.clone(),
Some(Url::parse(&initial_url).expect("valid url")),
cfg,
tx,
resume_cancel.clone(),
);
let mut started = false;
while let Ok(e) = rx.recv().await {
if matches!(e, Event::Start { .. }) {
started = true;
resume_cancel.cancel();
}
if matches!(e, Event::Renamed(_) | Event::ResumeError(_)) {
break;
}
}
assert!(started, "resume must emit Event::Start");
assert!(
part.exists() && fd.exists(),
".part/.fd must survive a mid-resume cancel"
);
let fd_text = std::fs::read_to_string(&fd).expect("read .fd after cancel");
let resume_url_line = fd_text
.lines()
.find(|l| l.trim_start().starts_with("url = "))
.expect("post-resume .fd must record a url");
assert!(
resume_url_line.contains("/redirect"),
"after resume the .fd must STILL record the initial URL, got: {resume_url_line}"
);
assert!(
!resume_url_line.contains(final_url.as_str()),
"after resume the .fd must NOT have been rewritten to the final URL, got: {resume_url_line}"
);
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let resume_cancel = create_cancellation_token();
resume(
part.clone(),
Some(Url::parse(&initial_url).expect("valid url")),
cfg,
tx,
resume_cancel,
);
let events = drain(rx).await;
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"resume() must complete with Renamed against a rotated URL"
);
assert!(
!events.iter().any(|e| matches!(e, Event::ResumeError(_))),
"a rotated URL must NOT be a resume error (content identity still matches)"
);
let renamed = events
.iter()
.find_map(|e| match e {
Event::Renamed(p) => Some(p.clone()),
_ => None,
})
.expect("resume() must complete with Renamed");
let got = tokio::fs::read(&renamed)
.await
.expect("final file should exist after resume");
assert_eq!(
got,
original_bytes(),
"resuming against a rotated URL must reassemble the file exactly"
);
}
#[tokio::test]
async fn test_resume_missing_tmp_path_falls_back_to_download() {
let dir = temp_dir("resume_missing_tmp");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
assert!(
!part.exists(),
"tmp_path must not exist before resume (the point of this test)"
);
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
resume(
part.clone(),
Some(Url::parse(&url).expect("valid url")),
cfg,
tx,
cancel,
);
let events = drain(rx).await;
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"missing tmp_path must fall back to a full download (Renamed)"
);
assert!(
!events.iter().any(|e| matches!(e, Event::ResumeError(_))),
"missing tmp_path must NOT emit a ResumeError"
);
let got = tokio::fs::read(&final_path)
.await
.expect("final file should exist after the fallback download");
assert_eq!(
got,
original_bytes(),
"fallback download must produce correct content"
);
assert!(
!part.exists(),
".part must be renamed away after a successful fallback download"
);
}
#[tokio::test]
async fn test_fresh_download_writes_full_file() {
let dir = temp_dir("fresh_download");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
download(Url::parse(&url).expect("valid url"), cfg, tx, cancel);
let events = drain(rx).await;
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"a fresh download must complete with Renamed"
);
assert!(
!events.iter().any(|e| matches!(e, Event::ResumeError(_))),
"a fresh download must NOT emit a ResumeError"
);
let final_path = dir.join("out.bin");
assert!(
final_path.exists(),
"final file must exist after a fresh download"
);
let got = tokio::fs::read(&final_path).await.expect("read final file");
assert_eq!(
got.len(),
FILE_SIZE,
"fresh download must write the full file (BUG-1 regression guard)"
);
assert_eq!(
got,
original_bytes(),
"fresh download content must match source exactly"
);
let fd = final_path.with_added_extension("fd");
assert!(
!fd.exists(),
".fd must be removed after a successful fresh download"
);
}
#[tokio::test]
async fn test_progress_event_emitted() {
let dir = temp_dir("progress_event");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let mut cfg = make_config(&dir);
cfg.progress_emit_gap = Some(Duration::from_millis(30));
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
download(Url::parse(&url).expect("valid url"), cfg, tx, cancel);
let events = drain(rx).await;
let progresses: Vec<_> = events
.iter()
.filter_map(|e| match e {
Event::Progress(s) => Some((
s.progress.clone(),
s.bps,
s.avg_bps,
s.downloaded,
s.percent,
s.total,
s.elapsed,
s.eta,
)),
_ => None,
})
.collect();
assert!(
!progresses.is_empty(),
"expected at least one Event::Progress"
);
assert!(
progresses.len() >= 2,
"Event::Progress must fire on its own cadence, not only once (got {})",
progresses.len()
);
assert!(
progresses
.iter()
.all(|(_, _, _, _, _, total, _, _)| *total == FILE_SIZE as u64),
"Event::Progress.total must equal the file size"
);
for (p, _, avg_bps, downloaded, percent, _, elapsed, _) in &progresses {
let sum: u64 = p.iter().map(|r| r.end - r.start).sum();
assert_eq!(
sum, *downloaded,
"Event::Progress.downloaded must equal sum(progress ranges)"
);
assert!(
(*percent >= 0.0) && (*percent <= 100.0),
"percent out of range: {percent}"
);
assert!(
*avg_bps <= (*downloaded).saturating_mul(1000).max(1),
"avg_bps implausibly high: {avg_bps}"
);
assert!(
*elapsed >= Duration::ZERO,
"elapsed must be non-negative: {elapsed:?}"
);
}
let mut prev_elapsed = Duration::ZERO;
for (_, _, _, _, _, _, el, _) in &progresses {
assert!(
*el >= prev_elapsed,
"Event::Progress.elapsed must be non-decreasing: {el:?} < {prev_elapsed:?}"
);
prev_elapsed = *el;
}
assert!(
progresses
.iter()
.any(|(_, _, _, d, _, t, _, eta)| *d < *t && eta.is_some_and(|e| e > Duration::ZERO)),
"expected at least one intermediate Progress with eta > 0"
);
for (_, bps, avg_bps, downloaded, _, total, _, eta) in &progresses {
let remaining = total.saturating_sub(*downloaded);
let rate = if *bps > 0 { *bps } else { *avg_bps };
if remaining == 0 {
assert!(
eta.map_or(rate == 0, |e| e == Duration::ZERO),
"eta must be ZERO once fully downloaded, got {eta:?}"
);
} else if rate > 0 {
let Some(d) = eta else {
panic!("eta must be Some while downloading with a measurable rate");
};
let expected_ms = u128::from(
remaining
.checked_mul(1000)
.and_then(|x| x.checked_div(rate))
.expect("rate > 0"),
);
let got_ms = d.as_millis();
let diff = got_ms.abs_diff(expected_ms);
assert!(
diff <= 1,
"eta {got_ms}ms disagrees with computed {expected_ms}ms"
);
} else {
assert!(eta.is_none(), "eta must be None with no measurable rate");
}
}
let max_downloaded: u64 = progresses
.iter()
.map(|(_, _, _, d, _, _, _, _)| *d)
.max()
.unwrap_or(0);
assert_eq!(
max_downloaded, FILE_SIZE as u64,
"downloaded must reach the full file size"
);
assert!(
progresses.iter().any(|(_, bps, _, _, _, _, _, _)| *bps > 0),
"expected at least one Event::Progress with bps > 0"
);
let last = progresses.last().expect("last Progress");
assert_eq!(
last.0,
vec![0u64..FILE_SIZE as u64],
"final Event::Progress must cover the whole file"
);
assert_eq!(last.5, FILE_SIZE as u64);
assert!(
(last.4 - 100.0).abs() < f64::EPSILON,
"final Event::Progress.percent must be 100.0, got {}",
last.4
);
}
#[tokio::test]
async fn test_progress_elapsed_persisted_across_resume() {
let dir = temp_dir("progress_elapsed_resume");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cancel = create_cancellation_token();
let partial_events = partial_download_via_cancel(&url, &dir, cancel).await;
let partial_elapsed_max = partial_events
.iter()
.filter_map(|e| match e {
Event::Progress(s) => Some(s.elapsed),
_ => None,
})
.max()
.expect("partial run must emit at least one Progress with elapsed");
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
assert!(
part.exists() && fd.exists(),
"cancel must preserve both .part and .fd"
);
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let resume_cancel = create_cancellation_token();
resume(
part.clone(),
Some(Url::parse(&url).expect("valid url")),
cfg,
tx,
resume_cancel,
);
let events = drain(rx).await;
let resume_progress: Vec<(Vec<fast_down::ProgressEntry>, u64, Duration, f64, u64)> = events
.iter()
.filter_map(|e| match e {
Event::Progress(s) => Some((
s.progress.clone(),
s.downloaded,
s.elapsed,
s.percent,
s.total,
)),
_ => None,
})
.collect();
assert!(
!resume_progress.is_empty(),
"resume must emit Event::Progress"
);
let resume_elapsed_min = resume_progress
.iter()
.map(|(_, _, el, _, _)| *el)
.min()
.expect("resume Progress must carry elapsed");
assert!(
resume_elapsed_min >= partial_elapsed_max,
"resume elapsed ({resume_elapsed_min:?}) must carry over partial elapsed ({partial_elapsed_max:?})"
);
let last = resume_progress.last().expect("last resume Progress");
assert_eq!(
last.0,
vec![0u64..FILE_SIZE as u64],
"resume must cover the whole file"
);
assert_eq!(last.4, FILE_SIZE as u64);
assert!(
(last.3 - 100.0).abs() < f64::EPSILON,
"resume percent must be 100.0, got {}",
last.3
);
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"resume must complete with Renamed"
);
}
#[tokio::test]
async fn test_download_join_resolves() {
let dir = temp_dir("join");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
download(Url::parse(&url).expect("valid url"), cfg, tx, cancel);
let events = drain(rx).await;
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"fresh download must complete with Renamed"
);
}
fn make_config_no_overwrite(save_dir: &Path) -> PartialConfig {
PartialConfig {
save_dir: Some(save_dir.to_path_buf()),
filename: Some("out.bin".to_string()),
parse_filename: Some(false),
overwrite: Some(false),
write_method: Some(WriteMethod::Mmap),
min_chunk_size: Some(1024 * 1024),
threads: Some(32),
..Default::default()
}
}
#[tokio::test]
async fn test_handle_join_completes() {
let dir = temp_dir("handle_join");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
download(Url::parse(&url).expect("valid url"), cfg, tx, cancel);
let events = timeout(Duration::from_secs(30), drain(rx))
.await
.expect("drain timed out");
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"download must complete"
);
}
#[tokio::test]
async fn test_download_silent_resume_overwrite() {
let dir = temp_dir("silent_resume_overwrite");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cancel = create_cancellation_token();
partial_download_via_cancel(&url, &dir, cancel).await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
assert!(
part.exists() && fd.exists(),
"cancel must leave .part and .fd"
);
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel2 = create_cancellation_token();
download(Url::parse(&url).expect("valid url"), cfg, tx, cancel2);
let events = timeout(Duration::from_secs(30), drain(rx))
.await
.expect("drain timed out");
assert!(
events.iter().any(|e| matches!(e, Event::Resumed { .. })),
"download() must silently resume (emit Event::Resumed)"
);
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"download() must complete after a silent resume"
);
assert!(
!events.iter().any(|e| matches!(e, Event::ResumeError(_))),
"download() must NOT emit a ResumeError"
);
let got = tokio::fs::read(&final_path).await.expect("read final file");
assert_eq!(got, original_bytes(), "resumed content mismatch");
}
#[tokio::test]
async fn test_download_resume_without_overwrite() {
let dir = temp_dir("resume_no_overwrite");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cancel = create_cancellation_token();
let cfg1 = make_config_no_overwrite(&dir);
partial_download_via_cancel_with(&url, cfg1, cancel, CANCEL_AFTER_BYTES).await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
assert!(
part.exists() && fd.exists(),
"cancel must leave .part and .fd"
);
let cfg2 = make_config_no_overwrite(&dir);
let (tx, rx) = create_channel();
let cancel2 = create_cancellation_token();
download(Url::parse(&url).expect("valid url"), cfg2, tx, cancel2);
let events = timeout(Duration::from_secs(30), drain(rx))
.await
.expect("drain timed out");
assert!(
events.iter().any(|e| matches!(e, Event::Resumed { .. })),
"download() must silently resume (emit Event::Resumed)"
);
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"download() must complete after resume"
);
assert!(
!events.iter().any(|e| matches!(e, Event::ResumeError(_))),
"download() must NOT emit a ResumeError"
);
let got = tokio::fs::read(&final_path).await.expect("read final file");
assert_eq!(got, original_bytes(), "resumed content mismatch");
}
#[tokio::test]
async fn test_resume_with_directory_part_fails_pipeline() {
let dir = temp_dir("resume_dir_part");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cancel = create_cancellation_token();
partial_download_via_cancel(&url, &dir, cancel).await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
assert!(part.exists() && fd.exists());
std::fs::remove_file(&part).unwrap();
std::fs::create_dir(&part).unwrap();
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel2 = create_cancellation_token();
resume(
part.clone(),
Some(Url::parse(&url).expect("valid url")),
cfg,
tx,
cancel2,
);
let events = timeout(Duration::from_secs(30), drain(rx))
.await
.expect("drain timed out");
assert!(
events
.iter()
.any(|e| matches!(e, Event::BuildPusherError(_))),
"opening a directory as the sink must emit Event::BuildPusherError"
);
assert!(
!events.iter().any(|e| matches!(e, Event::Renamed(_))),
"must NOT rename when the pipeline fails to build"
);
assert!(
!events.iter().any(|e| matches!(e, Event::ResumeError(_))),
"a file-open failure is not a StateError resume failure"
);
assert!(part.is_dir(), "the directory .part must remain");
let _ = std::fs::remove_dir_all(&part);
let _ = std::fs::remove_file(&fd);
}
#[tokio::test]
async fn test_prefetch_retries_then_gives_up() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind server socket");
let addr = listener.local_addr().expect("resolve local addr");
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.expect("accept connection");
let io = TokioIo::new(stream);
tokio::spawn(async move {
let service = service_fn(|_req: Request<Incoming>| async move {
Ok::<_, Infallible>(
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(BoxBody::new(http_body_util::Empty::<Bytes>::new()))
.expect("build 500 response"),
)
});
let _ = http1::Builder::new().serve_connection(io, service).await;
});
}
});
let url = format!("http://{addr}");
let dir = temp_dir("prefetch_retry");
let mut cfg = make_config(&dir);
cfg.retry_times = Some(2);
cfg.retry_gap = Some(Duration::from_millis(10));
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
download(Url::parse(&url).expect("valid url"), cfg, tx, cancel);
let events = timeout(Duration::from_secs(20), drain(rx))
.await
.expect("drain timed out");
assert!(
events.iter().any(|e| matches!(e, Event::PrefetchError(_))),
"expected Event::PrefetchError from the failing server"
);
assert!(
events
.iter()
.filter(|e| matches!(e, Event::PrefetchError(_)))
.count()
>= 2,
"prefetch must retry up to retry_times times"
);
assert!(
!events.iter().any(|e| matches!(e, Event::Renamed(_))),
"download must not complete against a failing server"
);
}
#[tokio::test]
async fn test_progress_zero_total_file() {
let dir = temp_dir("progress_zero");
let (_server, url) = start_server(vec![], "etag0", "LM-0", true).await;
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
download(Url::parse(&url).expect("valid url"), cfg, tx, cancel);
let events = timeout(Duration::from_secs(20), drain(rx))
.await
.expect("drain timed out");
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"a 0-byte download must still complete"
);
let progresses: Vec<_> = events
.iter()
.filter_map(|e| {
if let Event::Progress(s) = e {
Some(s)
} else {
None
}
})
.collect();
assert!(!progresses.is_empty(), "expected Event::Progress events");
assert!(
progresses.iter().all(|s| s.percent == 0.0),
"percent must be exactly 0.0 for a zero-byte file (line 134)"
);
let final_path = dir.join("out.bin");
let got = tokio::fs::read(&final_path).await.expect("read final file");
assert!(got.is_empty(), "0-byte file must produce a 0-byte file");
}
#[tokio::test]
async fn test_corrupt_fd_silently_redownloads() {
let dir = temp_dir("corrupt_fd");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let final_path = dir.join("out.bin");
let fd = final_path.with_added_extension("fd");
tokio::fs::write(&fd, "not valid toml {{{")
.await
.expect("write corrupt .fd");
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
download(Url::parse(&url).expect("valid url"), cfg, tx, cancel);
let events = timeout(Duration::from_secs(30), drain(rx))
.await
.expect("drain timed out");
assert!(
!events.iter().any(|e| matches!(e, Event::Resumed { .. })),
"a corrupt .fd must not be resumed"
);
assert!(
!events.iter().any(|e| matches!(e, Event::ResumeError(_))),
"download() must silently fall back, not report a ResumeError"
);
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"download() must complete via the silent fallback"
);
let got = tokio::fs::read(&final_path).await.expect("read final file");
assert_eq!(got, original_bytes(), "fallback content mismatch");
}
#[tokio::test]
async fn test_missing_part_with_valid_fd_silently_redownloads() {
let dir = temp_dir("missing_part");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cancel = create_cancellation_token();
partial_download_via_cancel(&url, &dir, cancel).await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
assert!(fd.exists(), "precondition: .fd must exist");
tokio::fs::remove_file(&part).await.expect("delete .part");
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel2 = create_cancellation_token();
download(Url::parse(&url).expect("valid url"), cfg, tx, cancel2);
let events = timeout(Duration::from_secs(30), drain(rx))
.await
.expect("drain timed out");
assert!(
!events.iter().any(|e| matches!(e, Event::Resumed { .. })),
"without the .part, silent resume must not kick in"
);
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"download() must complete via a fresh download"
);
let got = tokio::fs::read(&final_path).await.expect("read final file");
assert_eq!(got, original_bytes(), "fresh download content mismatch");
}
#[tokio::test]
async fn test_resume_disabled_ignores_valid_state() {
let dir = temp_dir("resume_disabled");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cancel = create_cancellation_token();
partial_download_via_cancel(&url, &dir, cancel).await;
let final_path = dir.join("out.bin");
let fd = final_path.with_added_extension("fd");
assert!(fd.exists(), "precondition: valid .fd must exist");
let mut cfg = make_config(&dir);
cfg.resume = Some(false);
let (tx, rx) = create_channel();
let cancel2 = create_cancellation_token();
download(Url::parse(&url).expect("valid url"), cfg, tx, cancel2);
let events = timeout(Duration::from_secs(30), drain(rx))
.await
.expect("drain timed out");
assert!(
!events.iter().any(|e| matches!(e, Event::Resumed { .. })),
"resume=false must never emit Resumed"
);
assert!(
!events.iter().any(|e| matches!(e, Event::ResumeError(_))),
"resume=false must not report a ResumeError"
);
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"download() must complete"
);
let got = tokio::fs::read(&final_path).await.expect("read final file");
assert_eq!(got, original_bytes(), "content mismatch");
}
#[tokio::test]
async fn test_non_overwrite_stray_part_advances_iter_stem() {
let dir = temp_dir("stray_part");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let final_path = dir.join("out.bin");
let stray = final_path.with_added_extension("part");
tokio::fs::write(&stray, b"stray")
.await
.expect("create stray .part");
let cfg = make_config_no_overwrite(&dir);
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
download(Url::parse(&url).expect("valid url"), cfg, tx, cancel);
let events = timeout(Duration::from_secs(30), drain(rx))
.await
.expect("drain timed out");
assert!(
!events
.iter()
.any(|e| matches!(e, Event::BuildPusherError(_))),
"AlreadyExists must advance iter_stem, not error out"
);
assert!(
!events.iter().any(|e| matches!(e, Event::Resumed { .. })),
"a stray .part without .fd cannot be resumed"
);
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"download() must complete on the next stem variant"
);
let got = tokio::fs::read(&final_path).await.expect("read final file");
assert_eq!(got, original_bytes(), "content mismatch");
let stray_bytes = tokio::fs::read(&stray).await.expect("read stray .part");
assert_eq!(
stray_bytes, b"stray",
"the stray .part must be left untouched"
);
}
#[tokio::test]
async fn test_resume_corrupt_fd_reports_decode_error() {
let dir = temp_dir("resume_corrupt_fd");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
tokio::fs::write(&part, b"partial")
.await
.expect("create .part");
tokio::fs::write(&fd, "not valid toml {{{")
.await
.expect("write corrupt .fd");
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
resume(
part.clone(),
Some(Url::parse(&url).expect("valid url")),
cfg,
tx,
cancel,
);
let events = timeout(Duration::from_secs(30), drain(rx))
.await
.expect("drain timed out");
let err = events
.iter()
.find_map(|e| match e {
Event::ResumeError(r) => Some(r),
_ => None,
})
.expect("expected Event::ResumeError");
assert!(
matches!(err, StateError::Decode(_)),
"expected Event::ResumeError(StateError::Decode) for a corrupt .fd, got {err:?}"
);
assert!(
!events.iter().any(|e| matches!(e, Event::Renamed(_))),
"resume() must NOT rename on a decode error"
);
assert!(part.exists() && fd.exists(), "partial files must be kept");
}
#[tokio::test]
async fn test_resume_never_overwrites_existing_final_file() {
let dir = temp_dir("resume_no_clobber");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cancel = create_cancellation_token();
partial_download_via_cancel(&url, &dir, cancel).await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
assert!(part.exists(), "cancel must leave the .part");
tokio::fs::write(&final_path, b"sentinel")
.await
.expect("create pre-existing final file");
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let resume_cancel = create_cancellation_token();
resume(
part.clone(),
Some(Url::parse(&url).expect("valid url")),
cfg,
tx,
resume_cancel,
);
let events = timeout(Duration::from_secs(30), drain(rx))
.await
.expect("drain timed out");
let renamed = events
.iter()
.find_map(|e| match e {
Event::Renamed(p) => Some(p.clone()),
_ => None,
})
.expect("resume must complete with Renamed");
assert_ne!(
renamed, final_path,
"resume must NOT overwrite the existing final file"
);
let sentinel = tokio::fs::read(&final_path)
.await
.expect("pre-existing file must remain readable");
assert_eq!(
sentinel, b"sentinel",
"the pre-existing final file content must be untouched"
);
let got = tokio::fs::read(&renamed).await.expect("read renamed file");
assert_eq!(got, original_bytes(), "renamed content mismatch");
}
#[tokio::test]
async fn test_resume_overrides_user_resume_false() {
let dir = temp_dir("resume_forces_true");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cancel = create_cancellation_token();
partial_download_via_cancel(&url, &dir, cancel).await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
assert!(part.exists(), "cancel must leave the .part");
let mut cfg = make_config(&dir);
cfg.resume = Some(false);
let (tx, rx) = create_channel();
let resume_cancel = create_cancellation_token();
resume(
part.clone(),
Some(Url::parse(&url).expect("valid url")),
cfg,
tx,
resume_cancel,
);
let events = timeout(Duration::from_secs(30), drain(rx))
.await
.expect("drain timed out");
assert!(
events.iter().any(|e| matches!(e, Event::Resumed { .. })),
"resume() must override resume=false and resume anyway"
);
assert!(
!events.iter().any(|e| matches!(e, Event::ResumeError(_))),
"no ResumeError when the state is valid"
);
let got = tokio::fs::read(&final_path).await.expect("read final file");
assert_eq!(got, original_bytes(), "resumed content mismatch");
}
#[tokio::test]
async fn test_rename_failed_when_final_path_is_directory() {
let dir = temp_dir("rename_dir_dest");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let final_path = dir.join("out.bin");
std::fs::create_dir(&final_path).expect("final path must be a directory");
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
download(Url::parse(&url).expect("valid url"), cfg, tx, cancel);
let events = timeout(Duration::from_secs(60), drain(rx))
.await
.expect("drain timed out");
assert!(
events.iter().any(|e| matches!(e, Event::RenameFailed(_))),
"renaming onto a directory must emit Event::RenameFailed, got: {events:?}"
);
assert!(
!events.iter().any(|e| matches!(e, Event::Renamed(_))),
"must NOT emit Renamed when the rename failed"
);
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
assert!(
part.exists() && fd.exists(),
".part/.fd must be kept after a failed rename"
);
let _ = std::fs::remove_dir_all(&final_path);
}
#[tokio::test]
async fn test_overwrite_true_replaces_existing_final_file() {
let dir = temp_dir("overwrite_replace");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let final_path = dir.join("out.bin");
tokio::fs::write(&final_path, b"stale content")
.await
.expect("create pre-existing final file");
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
download(Url::parse(&url).expect("valid url"), cfg, tx, cancel);
let events = timeout(Duration::from_secs(60), drain(rx))
.await
.expect("drain timed out");
let renamed = events
.iter()
.find_map(|e| match e {
Event::Renamed(p) => Some(p.clone()),
_ => None,
})
.expect("expected Event::Renamed");
assert_eq!(
renamed.file_name(),
final_path.file_name(),
"overwrite=true must rename onto the original file name, not a unique variant"
);
let got = tokio::fs::read(&final_path).await.expect("read final file");
assert_eq!(
got,
original_bytes(),
"the pre-existing file must be replaced by the fresh download"
);
}
#[tokio::test]
async fn test_invalid_proxy_reports_build_client_error() {
let dir = temp_dir("bad_proxy");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let mut cfg = make_config(&dir);
cfg.proxy = Some(fast_down_api::fast_down::Proxy::Custom(
"not a valid url".to_string(),
));
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
download(Url::parse(&url).expect("valid url"), cfg, tx, cancel);
let events = timeout(Duration::from_secs(30), drain(rx))
.await
.expect("drain timed out");
assert!(
events
.iter()
.any(|e| matches!(e, Event::BuildClientError(_))),
"an invalid proxy must emit Event::BuildClientError, got: {events:?}"
);
assert!(
!events.iter().any(|e| matches!(e, Event::Renamed(_))),
"download must not complete when the client cannot be built"
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_non_overwrite_create_new_fatal_error_reports_not_hangs() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("fa01_readonly_create_new");
let mut perms = std::fs::metadata(&dir)
.expect("stat save dir")
.permissions();
perms.set_mode(0o555);
std::fs::set_permissions(&dir, perms).expect("chmod save dir read-only");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cfg = PartialConfig {
save_dir: Some(dir.clone()),
filename: Some("out.bin".to_string()),
parse_filename: Some(false),
overwrite: Some(false),
resume: Some(false),
write_method: Some(WriteMethod::Mmap),
min_chunk_size: Some(1024 * 1024),
threads: Some(32),
..Default::default()
};
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
download(Url::parse(&url).expect("valid url"), cfg, tx, cancel);
let events = timeout(Duration::from_secs(15), drain(rx))
.await
.expect("download task hung on a fatal create_new error (F-A01 regression)");
assert!(
events
.iter()
.any(|e| matches!(e, Event::BuildPusherError(_))),
"a fatal .part creation error must emit Event::BuildPusherError, got: {events:?}"
);
assert!(
!events.iter().any(|e| matches!(e, Event::Renamed(_))),
"download must not complete when the .part cannot be created"
);
let mut restore = std::fs::metadata(&dir)
.expect("stat save dir")
.permissions();
restore.set_mode(0o755);
let _ = std::fs::set_permissions(&dir, restore);
}
#[tokio::test]
async fn test_resume_without_url_uses_fd_url() {
let dir = temp_dir("resume_no_url_fd");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cancel = create_cancellation_token();
partial_download_via_cancel(&url, &dir, cancel).await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
assert!(
part.exists() && fd.exists(),
"precondition: partial .part/.fd must exist"
);
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let resume_cancel = create_cancellation_token();
resume(part.clone(), None, cfg, tx, resume_cancel);
let events = drain(rx).await;
assert!(
events.iter().any(|e| matches!(e, Event::Resumed { .. })),
"resume() with no url must still resume (using the .fd's initial URL)"
);
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"resume() with no url must complete (Renamed)"
);
assert!(
!events.iter().any(|e| matches!(e, Event::ResumeError(_))),
"resume() with no url must NOT report a ResumeError when the .fd has a URL"
);
let got = tokio::fs::read(&final_path)
.await
.expect("final file should exist after resume");
assert_eq!(got, original_bytes(), "resumed content mismatch");
}
#[tokio::test]
async fn test_resume_without_url_missing_tmp_path_errors() {
let dir = temp_dir("resume_no_url_no_tmp");
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
assert!(!part.exists(), "precondition: tmp_path must not exist");
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
resume(part.clone(), None, cfg, tx, cancel);
let events = drain(rx).await;
let err = events
.iter()
.find_map(|e| match e {
Event::ResumeError(r) => Some(r),
_ => None,
})
.expect("expected Event::ResumeError when no url and no .part");
assert!(
matches!(err, StateError::NoUrl(_)),
"expected StateError::NoUrl, got {err:?}"
);
assert!(
!events.iter().any(|e| matches!(e, Event::Renamed(_))),
"resume() must NOT rename when it cannot resolve a URL"
);
}
#[tokio::test]
async fn test_resume_without_url_and_fd_has_no_url_errors() {
let dir = temp_dir("resume_no_url_fd_blank");
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
tokio::fs::write(&fd, "size = 1024\n")
.await
.expect("write .fd without a url");
tokio::fs::write(&part, b"partial")
.await
.expect("create .part");
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
resume(part.clone(), None, cfg, tx, cancel);
let events = drain(rx).await;
let err = events
.iter()
.find_map(|e| match e {
Event::ResumeError(r) => Some(r),
_ => None,
})
.expect("expected Event::ResumeError when .fd has no url and none supplied");
assert!(
matches!(err, StateError::NoUrl(_)),
"expected StateError::NoUrl for a url-less .fd with no url arg, got {err:?}"
);
assert!(
part.exists() && fd.exists(),
"partial files must be kept on NoUrl"
);
}
#[tokio::test]
async fn test_resume_truncated_part_falls_back_to_fresh_download() {
let dir = temp_dir("resume_truncated_part");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cancel = create_cancellation_token();
partial_download_via_cancel(&url, &dir, cancel).await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
assert!(
part.exists() && fd.exists(),
"precondition: .part and .fd must exist"
);
let fd_content = tokio::fs::read_to_string(&fd).await.expect("read .fd");
assert!(
fd_content.contains("downloaded_chunk"),
"precondition: .fd must contain progress"
);
{
let file = tokio::fs::OpenOptions::new()
.write(true)
.open(&part)
.await
.expect("open .part for truncation");
file.set_len(100).await.expect("truncate .part file"); }
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let resume_cancel = create_cancellation_token();
resume(
part.clone(),
Some(Url::parse(&url).expect("valid url")),
cfg,
tx,
resume_cancel,
);
let events = drain(rx).await;
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"resume with truncated .part should complete with fresh download"
);
assert!(
!events.iter().any(|e| matches!(e, Event::Resumed { .. })),
"should not emit Resumed event when .part is truncated"
);
let got = tokio::fs::read(&final_path).await.expect("read final file");
assert_eq!(
got,
original_bytes(),
"content should match original after fresh download"
);
}
#[tokio::test]
async fn test_resume_rejects_non_part_extension() {
let dir = temp_dir("resume_wrong_ext");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let final_path = dir.join("out.bin");
let wrong_ext_path = final_path.with_extension("tmp");
tokio::fs::write(&wrong_ext_path, b"dummy")
.await
.expect("create dummy file");
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
resume(
wrong_ext_path.clone(),
Some(Url::parse(&url).expect("valid url")),
cfg,
tx,
cancel,
);
let events = drain(rx).await;
let err = events
.iter()
.find_map(|e| match e {
Event::ResumeError(r) => Some(r),
_ => None,
})
.expect("expected Event::ResumeError for non-.part extension");
if let StateError::Open(e) = err {
assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput);
assert!(
e.to_string()
.contains("tmp_path must end with .part extension")
);
} else {
panic!("expected StateError::Open, got {err:?}");
}
assert!(
!events.iter().any(|e| matches!(e, Event::Renamed(_))),
"should not rename when tmp_path has wrong extension"
);
}
#[tokio::test]
async fn download_creates_template_subdir() {
let dir = temp_dir("template_subdir");
let (_server, base) = start_server(original_bytes(), "orig", "LM-A", true).await;
let url = format!("{base}/a/b/data.bin");
let cfg = PartialConfig {
save_dir: Some(dir.clone()),
filename: Some("{parent_path}/{file_name}".to_string()),
parse_filename: Some(true),
overwrite: Some(true),
write_method: Some(WriteMethod::Mmap),
min_chunk_size: Some(1024 * 1024),
threads: Some(32),
cache_high_watermark: Some(1),
cache_low_watermark: Some(0),
write_buffer_size: Some(1),
..Default::default()
};
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
download(Url::parse(&url).expect("valid url"), cfg, tx, cancel);
let events = drain(rx).await;
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"a template-subdir download must complete with Renamed"
);
let final_path = dir.join("a").join("b").join("data.bin");
assert!(
final_path.exists(),
"the template subdir must be created and the file must land inside it"
);
let got = tokio::fs::read(&final_path).await.expect("read final file");
assert_eq!(
got,
original_bytes(),
"downloaded content must match source"
);
}
#[tokio::test]
async fn test_download_from_fd_resumes_when_part_present() {
let dir = temp_dir("from_fd_resume");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cancel = create_cancellation_token();
partial_download_via_cancel(&url, &dir, cancel).await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
assert!(
part.exists() && fd.exists(),
"precondition: .part and .fd must exist"
);
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel2 = create_cancellation_token();
download_from_fd(
fd.clone(),
Some(Url::parse(&url).expect("valid url")),
cfg,
tx,
cancel2,
);
let events = timeout(Duration::from_secs(30), drain(rx))
.await
.expect("drain timed out");
assert!(
events.iter().any(|e| matches!(e, Event::Resumed { .. })),
"download_from_fd must resume (emit Event::Resumed) when .part exists"
);
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"download_from_fd must complete with Renamed"
);
assert!(
!events.iter().any(|e| matches!(e, Event::ResumeError(_))),
"download_from_fd must NOT emit a ResumeError on a valid state"
);
let got = tokio::fs::read(&final_path).await.expect("read final file");
assert_eq!(got, original_bytes(), "resumed content mismatch");
}
#[tokio::test]
async fn test_download_from_fd_reuses_fd_when_part_missing() {
let dir = temp_dir("from_fd_missing_part");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cancel = create_cancellation_token();
partial_download_via_cancel(&url, &dir, cancel).await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
assert!(fd.exists(), "precondition: .fd must exist");
tokio::fs::remove_file(&part).await.expect("delete .part");
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel2 = create_cancellation_token();
download_from_fd(
fd.clone(),
Some(Url::parse(&url).expect("valid url")),
cfg,
tx,
cancel2,
);
let events = timeout(Duration::from_secs(30), drain(rx))
.await
.expect("drain timed out");
assert!(
!events.iter().any(|e| matches!(e, Event::ResumeError(_))),
"download_from_fd must NOT emit a ResumeError when .part is missing"
);
assert!(
!events.iter().any(|e| matches!(e, Event::Resumed { .. })),
"a manifest-only fresh download must not be reported as resumed"
);
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"download_from_fd must complete with Renamed even without a .part"
);
let got = tokio::fs::read(&final_path).await.expect("read final file");
assert_eq!(
got,
original_bytes(),
"fresh download from the manifest must produce correct content"
);
}
#[tokio::test]
async fn test_download_from_fd_reuses_fd_when_part_missing_non_range() {
let dir = temp_dir("from_fd_missing_part_non_range");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", false).await;
let cancel = create_cancellation_token();
partial_download_via_cancel(&url, &dir, cancel).await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
assert!(fd.exists(), "precondition: .fd must exist");
tokio::fs::remove_file(&part).await.expect("delete .part");
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel2 = create_cancellation_token();
download_from_fd(
fd.clone(),
Some(Url::parse(&url).expect("valid url")),
cfg,
tx,
cancel2,
);
let events = timeout(Duration::from_secs(30), drain(rx))
.await
.expect("drain timed out");
assert!(
!events.iter().any(|e| matches!(e, Event::ResumeError(_))),
"download_from_fd must NOT emit a ResumeError when .part is missing"
);
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"download_from_fd must complete with Renamed even without a .part on a non-range server"
);
let got = tokio::fs::read(&final_path).await.expect("read final file");
assert_eq!(
got,
original_bytes(),
"fresh single-stream download from the manifest must produce correct content"
);
}
#[tokio::test]
async fn test_download_from_fd_missing_fd_reports_error() {
let dir = temp_dir("from_fd_missing");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let final_path = dir.join("out.bin");
let fd = final_path.with_added_extension("fd");
assert!(!fd.exists(), "precondition: .fd must not exist");
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel = create_cancellation_token();
download_from_fd(
fd.clone(),
Some(Url::parse(&url).expect("valid url")),
cfg,
tx,
cancel,
);
let events = timeout(Duration::from_secs(30), drain(rx))
.await
.expect("drain timed out");
let err = events
.iter()
.find_map(|e| match e {
Event::ResumeError(r) => Some(r),
_ => None,
})
.expect("expected Event::ResumeError for a missing .fd");
assert!(
matches!(err, StateError::Open(_)),
"expected Event::ResumeError(StateError::Open) for a missing .fd, got {err:?}"
);
assert!(
!events.iter().any(|e| matches!(e, Event::Renamed(_))),
"download_from_fd must NOT rename when there is no .fd"
);
}
#[tokio::test]
async fn test_download_from_fd_uses_fd_url_when_none_given() {
let dir = temp_dir("from_fd_url_none");
let (_server, url) = start_server(original_bytes(), "orig", "LM-A", true).await;
let cancel = create_cancellation_token();
partial_download_via_cancel(&url, &dir, cancel).await;
let final_path = dir.join("out.bin");
let part = final_path.with_added_extension("part");
let fd = final_path.with_added_extension("fd");
assert!(part.exists() && fd.exists());
let cfg = make_config(&dir);
let (tx, rx) = create_channel();
let cancel2 = create_cancellation_token();
download_from_fd(fd.clone(), None, cfg, tx, cancel2);
let events = timeout(Duration::from_secs(30), drain(rx))
.await
.expect("drain timed out");
assert!(
events.iter().any(|e| matches!(e, Event::Resumed { .. })),
"download_from_fd(None) must resume using the .fd's durable url"
);
assert!(
events.iter().any(|e| matches!(e, Event::Renamed(_))),
"download_from_fd(None) must complete with Renamed"
);
let got = tokio::fs::read(&final_path).await.expect("read final file");
assert_eq!(got, original_bytes(), "resumed content mismatch");
}