|
23 | 23 | import static com.github.tomakehurst.wiremock.client.WireMock.put; |
24 | 24 | import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; |
25 | 25 | import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; |
| 26 | +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; |
26 | 27 | import static org.assertj.core.api.Assertions.assertThat; |
27 | 28 | import static org.assertj.core.api.Assertions.assertThatExceptionOfType; |
28 | 29 | import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; |
| 30 | +import static org.mockito.Mockito.atLeastOnce; |
29 | 31 | import static org.mockito.Mockito.mock; |
30 | 32 | import static org.mockito.Mockito.timeout; |
31 | 33 | import static org.mockito.Mockito.times; |
32 | 34 |
|
33 | 35 | import com.github.tomakehurst.wiremock.client.WireMock; |
34 | 36 | import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; |
35 | 37 | import com.github.tomakehurst.wiremock.junit5.WireMockTest; |
| 38 | +import java.io.ByteArrayInputStream; |
36 | 39 | import java.io.IOException; |
37 | 40 | import java.net.URI; |
38 | 41 | import java.time.Duration; |
|
47 | 50 | import org.mockito.Mockito; |
48 | 51 | import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; |
49 | 52 | import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; |
| 53 | +import software.amazon.awssdk.core.async.AsyncRequestBody; |
| 54 | +import software.amazon.awssdk.core.async.BlockingInputStreamAsyncRequestBody; |
50 | 55 | import software.amazon.awssdk.regions.Region; |
51 | 56 | import software.amazon.awssdk.services.s3.S3AsyncClient; |
52 | 57 | import software.amazon.awssdk.services.s3.model.NoSuchBucketException; |
|
57 | 62 | import software.amazon.awssdk.transfer.s3.S3TransferManager; |
58 | 63 | import software.amazon.awssdk.transfer.s3.model.Copy; |
59 | 64 | import software.amazon.awssdk.transfer.s3.model.FileUpload; |
| 65 | +import software.amazon.awssdk.transfer.s3.model.Upload; |
60 | 66 | import software.amazon.awssdk.transfer.s3.progress.LoggingTransferListener; |
61 | 67 | import software.amazon.awssdk.transfer.s3.progress.TransferListener; |
62 | 68 |
|
@@ -358,6 +364,133 @@ void copyWithJavaBasedClient_listeners_reports_ProgressWhenSuccess_copy() { |
358 | 364 | Mockito.verify(transferListenerMock, times(numTimesBytesTransferred)).bytesTransferred(ArgumentMatchers.any()); |
359 | 365 | } |
360 | 366 |
|
| 367 | + /** |
| 368 | + * Verifies that TransferListener callbacks fire for unknown-content-length uploads that fit in a single chunk. |
| 369 | + * This is the scenario where UploadWithUnknownContentLengthHelper routes to uploadInOneChunk. |
| 370 | + */ |
| 371 | + @Test |
| 372 | + void unknownContentLength_singleChunk_transferCompleteFires() { |
| 373 | + S3AsyncClient s3Async = s3AsyncClient(true); |
| 374 | + |
| 375 | + stubFor(put(urlPathEqualTo("/" + EXAMPLE_BUCKET + "/" + TEST_KEY)) |
| 376 | + .willReturn(aResponse().withStatus(200).withBody("<body/>"))); |
| 377 | + |
| 378 | + S3TransferManager tm = new GenericS3TransferManager(s3Async, mock(UploadDirectoryHelper.class), |
| 379 | + mock(TransferManagerConfiguration.class), |
| 380 | + mock(DownloadDirectoryHelper.class)); |
| 381 | + CaptureTransferListener transferListener = new CaptureTransferListener(); |
| 382 | + TransferListener transferListenerMock = mock(TransferListener.class); |
| 383 | + |
| 384 | + BlockingInputStreamAsyncRequestBody body = AsyncRequestBody.forBlockingInputStream(null); |
| 385 | + |
| 386 | + Upload upload = tm.upload(u -> u.putObjectRequest(p -> p.bucket(EXAMPLE_BUCKET).key(TEST_KEY)) |
| 387 | + .requestBody(body) |
| 388 | + .addTransferListener(transferListener) |
| 389 | + .addTransferListener(transferListenerMock) |
| 390 | + .build()); |
| 391 | + |
| 392 | + // Write small data (fits in one chunk) and close the stream |
| 393 | + byte[] data = new byte[1024]; |
| 394 | + body.writeInputStream(new ByteArrayInputStream(data)); |
| 395 | + |
| 396 | + upload.completionFuture().join(); |
| 397 | + |
| 398 | + assertTransferListenerCompletion(transferListener); |
| 399 | + assertThat(transferListener.isTransferInitiated()).isTrue(); |
| 400 | + assertThat(transferListener.isTransferComplete()).isTrue(); |
| 401 | + assertThat(transferListener.getExceptionCaught()).isNull(); |
| 402 | + |
| 403 | + Mockito.verify(transferListenerMock, times(1)).transferInitiated(ArgumentMatchers.any()); |
| 404 | + Mockito.verify(transferListenerMock, timeout(1000).times(1)).transferComplete(ArgumentMatchers.any()); |
| 405 | + Mockito.verify(transferListenerMock, times(0)).transferFailed(ArgumentMatchers.any()); |
| 406 | + } |
| 407 | + |
| 408 | + /** |
| 409 | + * Verifies that TransferListener callbacks fire for unknown-content-length uploads that exceed the part size |
| 410 | + * and go through the multipart upload path. |
| 411 | + */ |
| 412 | + @Test |
| 413 | + void unknownContentLength_multiChunk_allCallbacksFire() { |
| 414 | + S3AsyncClient s3Async = s3AsyncClient(true); |
| 415 | + |
| 416 | + String createMpuUrl = "/" + EXAMPLE_BUCKET + "/" + TEST_KEY + "?uploads"; |
| 417 | + String createMpuResponse = "<CreateMultipartUploadResult><UploadId>1234</UploadId></CreateMultipartUploadResult>"; |
| 418 | + stubFor(post(urlEqualTo(createMpuUrl)).willReturn(aResponse().withStatus(200).withBody(createMpuResponse))); |
| 419 | + stubFor(any(anyUrl()).atPriority(6).willReturn(aResponse().withStatus(200).withBody("<body/>"))); |
| 420 | + |
| 421 | + S3TransferManager tm = new GenericS3TransferManager(s3Async, mock(UploadDirectoryHelper.class), |
| 422 | + mock(TransferManagerConfiguration.class), |
| 423 | + mock(DownloadDirectoryHelper.class)); |
| 424 | + CaptureTransferListener transferListener = new CaptureTransferListener(); |
| 425 | + TransferListener transferListenerMock = mock(TransferListener.class); |
| 426 | + |
| 427 | + BlockingInputStreamAsyncRequestBody body = AsyncRequestBody.forBlockingInputStream(null); |
| 428 | + |
| 429 | + Upload upload = tm.upload(u -> u.putObjectRequest(p -> p.bucket(EXAMPLE_BUCKET).key(TEST_KEY)) |
| 430 | + .requestBody(body) |
| 431 | + .addTransferListener(transferListener) |
| 432 | + .addTransferListener(transferListenerMock) |
| 433 | + .build()); |
| 434 | + |
| 435 | + // Write data larger than the default 8 MiB part size to force multipart |
| 436 | + byte[] data = new byte[OBJ_SIZE]; |
| 437 | + body.writeInputStream(new ByteArrayInputStream(data)); |
| 438 | + |
| 439 | + upload.completionFuture().join(); |
| 440 | + |
| 441 | + assertTransferListenerCompletion(transferListener); |
| 442 | + assertThat(transferListener.isTransferInitiated()).isTrue(); |
| 443 | + assertThat(transferListener.isTransferComplete()).isTrue(); |
| 444 | + assertThat(transferListener.getExceptionCaught()).isNull(); |
| 445 | + |
| 446 | + Mockito.verify(transferListenerMock, times(1)).transferInitiated(ArgumentMatchers.any()); |
| 447 | + Mockito.verify(transferListenerMock, timeout(1000).times(1)).transferComplete(ArgumentMatchers.any()); |
| 448 | + Mockito.verify(transferListenerMock, times(0)).transferFailed(ArgumentMatchers.any()); |
| 449 | + Mockito.verify(transferListenerMock, atLeastOnce()).bytesTransferred(ArgumentMatchers.any()); |
| 450 | + } |
| 451 | + |
| 452 | + /** |
| 453 | + * Verifies that when an unknown-content-length upload fails on the single-chunk path, |
| 454 | + * the completionFuture completes exceptionally and transferFailed fires. |
| 455 | + * This guards against regressions where the failure path in uploadInOneChunk does not |
| 456 | + * propagate the exception to returnFuture, causing the upload to hang indefinitely. |
| 457 | + */ |
| 458 | + @Test |
| 459 | + void unknownContentLength_singleChunk_failurePropagates() { |
| 460 | + S3AsyncClient s3Async = s3AsyncClient(true); |
| 461 | + |
| 462 | + stubFor(put(urlPathEqualTo("/" + EXAMPLE_BUCKET + "/" + TEST_KEY)) |
| 463 | + .willReturn(aResponse().withStatus(500).withBody(ERROR_BODY))); |
| 464 | + |
| 465 | + S3TransferManager tm = new GenericS3TransferManager(s3Async, mock(UploadDirectoryHelper.class), |
| 466 | + mock(TransferManagerConfiguration.class), |
| 467 | + mock(DownloadDirectoryHelper.class)); |
| 468 | + CaptureTransferListener transferListener = new CaptureTransferListener(); |
| 469 | + TransferListener transferListenerMock = mock(TransferListener.class); |
| 470 | + |
| 471 | + BlockingInputStreamAsyncRequestBody body = AsyncRequestBody.forBlockingInputStream(null); |
| 472 | + |
| 473 | + Upload upload = tm.upload(u -> u.putObjectRequest(p -> p.bucket(EXAMPLE_BUCKET).key(TEST_KEY)) |
| 474 | + .requestBody(body) |
| 475 | + .addTransferListener(transferListener) |
| 476 | + .addTransferListener(transferListenerMock) |
| 477 | + .build()); |
| 478 | + |
| 479 | + byte[] data = new byte[1024]; |
| 480 | + body.writeInputStream(new ByteArrayInputStream(data)); |
| 481 | + |
| 482 | + assertThatExceptionOfType(CompletionException.class).isThrownBy(() -> upload.completionFuture().join()); |
| 483 | + |
| 484 | + assertTransferListenerCompletion(transferListener); |
| 485 | + assertThat(transferListener.isTransferInitiated()).isTrue(); |
| 486 | + assertThat(transferListener.isTransferComplete()).isFalse(); |
| 487 | + assertThat(transferListener.getExceptionCaught()).isNotNull(); |
| 488 | + |
| 489 | + Mockito.verify(transferListenerMock, times(1)).transferInitiated(ArgumentMatchers.any()); |
| 490 | + Mockito.verify(transferListenerMock, times(0)).transferComplete(ArgumentMatchers.any()); |
| 491 | + Mockito.verify(transferListenerMock, timeout(1000).times(1)).transferFailed(ArgumentMatchers.any()); |
| 492 | + } |
| 493 | + |
361 | 494 | private static void assertTransferListenerCompletion(CaptureTransferListener transferListener) { |
362 | 495 | Duration waitDuration = Duration.ofSeconds(5); |
363 | 496 | assertTimeoutPreemptively( |
|
0 commit comments