feat: support parallel multi-file uploads
The browser upload flow was built around one selected file and one global upload state. That made the existing chunk pool useful for a single file, but users could not start several selected files at the same time. Refactor the browser state into per-file upload items. Each selected file now has its own upload record, completed-chunk set, abort controller, retry state, progress row, and saved IndexedDB resume record. The picker accepts multiple files, `Start all` and `Resume all` use a bounded file-level pool, and each file keeps the existing bounded chunk pool. This keeps parallel uploads useful without letting one large selection create unbounded request fan-out. Keep the server API unchanged. Each file still receives a separate server upload id, and server-side progress remains authoritative before any missing chunks are scheduled. Terminal conflicts still stop the affected file without overwriting completed data. Update the user-facing markup, styles, project docs, and test checklist for the multi-file scheduler. Add a server regression test that interleaves two uploads and verifies the completed files contain exactly their own bytes. Test Plan: - just check - git diff --check
This commit is contained in:
@@ -66,6 +66,80 @@ async fn assembles_completed_upload() -> Result<(), Box<dyn std::error::Error>>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parallel_uploads_keep_bytes_separate() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let app = test_app(temp_dir.path());
|
||||
let chunk_size = usize::try_from(CHUNK_SIZE)?;
|
||||
let left_upload = create_upload(&app, "left.bin", CHUNK_SIZE + 4).await?;
|
||||
let right_upload = create_upload(&app, "right.bin", CHUNK_SIZE + 5).await?;
|
||||
|
||||
let mut expected_left = vec![b'l'; chunk_size];
|
||||
expected_left.extend_from_slice(b"eft!");
|
||||
let mut expected_right = vec![b'r'; chunk_size];
|
||||
expected_right.extend_from_slice(b"ight!");
|
||||
|
||||
let left_first = chunk_request(
|
||||
&left_upload.upload_id,
|
||||
0,
|
||||
expected_left[..chunk_size].to_vec(),
|
||||
)?;
|
||||
let left_final = chunk_request(
|
||||
&left_upload.upload_id,
|
||||
1,
|
||||
expected_left[chunk_size..].to_vec(),
|
||||
)?;
|
||||
let right_first = chunk_request(
|
||||
&right_upload.upload_id,
|
||||
0,
|
||||
expected_right[..chunk_size].to_vec(),
|
||||
)?;
|
||||
let right_final = chunk_request(
|
||||
&right_upload.upload_id,
|
||||
1,
|
||||
expected_right[chunk_size..].to_vec(),
|
||||
)?;
|
||||
|
||||
let (left_first, right_first, left_final, right_final) = tokio::join!(
|
||||
app.clone().oneshot(left_first),
|
||||
app.clone().oneshot(right_first),
|
||||
app.clone().oneshot(left_final),
|
||||
app.clone().oneshot(right_final),
|
||||
);
|
||||
|
||||
assert_eq!(left_first?.status(), StatusCode::NO_CONTENT);
|
||||
assert_eq!(right_first?.status(), StatusCode::NO_CONTENT);
|
||||
assert_eq!(left_final?.status(), StatusCode::NO_CONTENT);
|
||||
assert_eq!(right_final?.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let left_complete = empty_request(
|
||||
Method::POST,
|
||||
&format!("/api/uploads/{}/complete", left_upload.upload_id),
|
||||
)?;
|
||||
let right_complete = empty_request(
|
||||
Method::POST,
|
||||
&format!("/api/uploads/{}/complete", right_upload.upload_id),
|
||||
)?;
|
||||
|
||||
let (left_complete, right_complete) = tokio::join!(
|
||||
app.clone().oneshot(left_complete),
|
||||
app.clone().oneshot(right_complete),
|
||||
);
|
||||
|
||||
assert_eq!(left_complete?.status(), StatusCode::OK);
|
||||
assert_eq!(right_complete?.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
tokio::fs::read(temp_dir.path().join("complete").join("left.bin")).await?,
|
||||
expected_left
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(temp_dir.path().join("complete").join("right.bin")).await?,
|
||||
expected_right
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_incomplete_upload() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
|
||||
@@ -22,7 +22,8 @@ async fn serves_index_page() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let body = String::from_utf8(body.to_vec())?;
|
||||
|
||||
assert!(body.contains("<title>upl</title>"));
|
||||
assert!(body.contains("Choose file"));
|
||||
assert!(body.contains("Choose files"));
|
||||
assert!(body.contains("Selected uploads"));
|
||||
assert!(body.contains("Saved upload progress"));
|
||||
assert!(!body.contains("Server online"));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user