diff --git a/crates/s3/src/client.rs b/crates/s3/src/client.rs index 8425710..26fbbef 100644 --- a/crates/s3/src/client.rs +++ b/crates/s3/src/client.rs @@ -761,7 +761,7 @@ impl S3Client { } let response = builder.send().await.map_err(|e| { - let err_str = e.to_string(); + let err_str = Self::format_sdk_error(&e); if err_str.contains("NotFound") || err_str.contains("NoSuchBucket") { Error::NotFound(format!("Bucket not found: {}", path.bucket)) } else { @@ -3545,6 +3545,117 @@ mod tests { } } + #[tokio::test] + async fn list_object_versions_page_preserves_markers_and_delete_markers() { + let response = http::Response::builder() + .status(200) + .body(SdkBody::from( + r#" + + bucket + logs/ + + + logs/c.txt + v3 + 25 + true + + logs/a.txt + v1 + true + 2026-04-29T11:22:33.000Z + "etag-a" + 12 + STANDARD + + + logs/b.txt + v2 + false + 2026-04-28T10:20:30.000Z + + owner + + +"#, + )) + .expect("build list object versions response"); + let (client, request_receiver) = test_s3_client(Some(response)); + let path = RemotePath::new("test", "bucket", "logs/"); + + let result = client + .list_object_versions_page(&path, Some(25)) + .await + .expect("list object versions page"); + + let request = request_receiver.expect_request(); + let uri = request.uri().to_string(); + assert!( + uri.starts_with("https://example.com/bucket/?"), + "unexpected URI: {uri}" + ); + assert!( + uri.contains("versions"), + "expected versions subresource: {uri}" + ); + assert!( + uri.contains("prefix=logs%2F"), + "expected prefix query: {uri}" + ); + assert!( + uri.contains("max-keys=25"), + "expected max-keys query: {uri}" + ); + + assert!(result.truncated); + assert_eq!(result.continuation_token.as_deref(), Some("logs/c.txt")); + assert_eq!(result.version_id_marker.as_deref(), Some("v3")); + assert_eq!(result.items.len(), 2); + + let version = &result.items[0]; + assert_eq!(version.key, "logs/a.txt"); + assert_eq!(version.version_id, "v1"); + assert!(version.is_latest); + assert!(!version.is_delete_marker); + assert_eq!(version.size_bytes, Some(12)); + assert_eq!(version.etag.as_deref(), Some("etag-a")); + + let delete_marker = &result.items[1]; + assert_eq!(delete_marker.key, "logs/b.txt"); + assert_eq!(delete_marker.version_id, "v2"); + assert!(!delete_marker.is_latest); + assert!(delete_marker.is_delete_marker); + assert_eq!(delete_marker.size_bytes, None); + assert_eq!(delete_marker.etag, None); + } + + #[tokio::test] + async fn list_object_versions_page_maps_missing_bucket_to_not_found() { + let response = http::Response::builder() + .status(404) + .header("x-amz-error-code", "NoSuchBucket") + .body(SdkBody::from( + r#" + + NoSuchBucket + The specified bucket does not exist. +"#, + )) + .expect("build missing bucket response"); + let (client, _request_receiver) = test_s3_client(Some(response)); + let path = RemotePath::new("test", "missing-bucket", ""); + + let result = client.list_object_versions_page(&path, Some(1000)).await; + + match result { + Err(Error::NotFound(message)) => { + assert_eq!(message, "Bucket not found: missing-bucket") + } + other => panic!("Expected NotFound for missing bucket, got: {other:?}"), + } + } + #[tokio::test] async fn delete_bucket_maps_bucket_not_empty_to_conflict() { let response = http::Response::builder()