diff --git a/src/main/java/com/datadobi/s3test/ChecksumTests.java b/src/main/java/com/datadobi/s3test/ChecksumTests.java index 07f7ebb..f5ddbd6 100644 --- a/src/main/java/com/datadobi/s3test/ChecksumTests.java +++ b/src/main/java/com/datadobi/s3test/ChecksumTests.java @@ -37,30 +37,50 @@ public class ChecksumTests extends S3TestBase { public ChecksumTests() throws IOException { } + /** + * Puts an object with CRC32 checksum and verifies the server stores and returns it. + * Expected: Put succeeds; HEAD with ChecksumMode.ENABLED returns same content-length, ETag, and CRC32 checksum. + */ @Test @SkipForQuirks({Quirk.CHECKSUMS_NOT_SUPPORTED}) public void testCRC32() { putWithChecksum(ChecksumAlgorithm.CRC32); } + /** + * Puts an object with CRC32C checksum and verifies the server stores and returns it. + * Expected: Put succeeds; HEAD with ChecksumMode.ENABLED returns same content-length, ETag, and CRC32C checksum. + */ @Test @SkipForQuirks({Quirk.CHECKSUMS_NOT_SUPPORTED}) public void testCRC32_C() { putWithChecksum(ChecksumAlgorithm.CRC32_C); } + /** + * Puts an object with SHA1 checksum and verifies the server stores and returns it. + * Expected: Put succeeds; HEAD with ChecksumMode.ENABLED returns same content-length, ETag, and SHA1 checksum. + */ @Test @SkipForQuirks({Quirk.CHECKSUMS_NOT_SUPPORTED}) public void testSHA1() { putWithChecksum(ChecksumAlgorithm.SHA1); } + /** + * Puts an object with SHA256 checksum and verifies the server stores and returns it. + * Expected: Put succeeds; HEAD with ChecksumMode.ENABLED returns same content-length, ETag, and SHA256 checksum. + */ @Test @SkipForQuirks({Quirk.CHECKSUMS_NOT_SUPPORTED}) public void testSHA256() { putWithChecksum(ChecksumAlgorithm.SHA256); } + /** + * Puts an object with CRC64_NVME checksum and verifies the server stores and returns it. + * Expected: Put succeeds; HEAD returns same CRC64_NVME checksum. Skipped by default (requires C runtime). + */ @Test @SkipForQuirks({Quirk.CHECKSUMS_NOT_SUPPORTED}) @Ignore("Requires C runtime") @@ -99,6 +119,10 @@ private void putWithChecksum(ChecksumAlgorithm checksumAlgorithm) { assertEquals(String.format("Checksum mismatch (expected: %s, received: %s)", putChecksum, headChecksum), putChecksum, headChecksum); } + /** + * Puts an object using indefinite-length (chunked) request body with CRC32 checksum. + * Expected: Put completes successfully without requiring a predeclared Content-Length. + */ @Test @SkipForQuirks({Quirk.CHECKSUMS_NOT_SUPPORTED}) public void indefiniteLengthWithChecksum() { diff --git a/src/main/java/com/datadobi/s3test/ConditionalRequestTests.java b/src/main/java/com/datadobi/s3test/ConditionalRequestTests.java index 0d4a034..0d60c74 100644 --- a/src/main/java/com/datadobi/s3test/ConditionalRequestTests.java +++ b/src/main/java/com/datadobi/s3test/ConditionalRequestTests.java @@ -35,6 +35,11 @@ public class ConditionalRequestTests extends S3TestBase { public ConditionalRequestTests() throws IOException { } + /** + * Puts an object, then attempts overwrite with If-None-Match: * (create-only semantics). + * Expected: First put succeeds; second put either fails with 412 Precondition Failed (object exists) + * or 501 if unsupported; if it wrongly succeeds, GET returns new content and test fails. + */ @Test @SkipForQuirks({PUT_OBJECT_IF_NONE_MATCH_STAR_NOT_SUPPORTED}) public void thatConditionalPutIfNoneMatchStarWorks() throws InterruptedException { @@ -59,6 +64,10 @@ public void thatConditionalPutIfNoneMatchStarWorks() throws InterruptedException } } + /** + * Puts an object, then attempts overwrite with If-None-Match: "" (create-only if etag differs). + * Expected: Overwrite fails with 412 (or 501 if unsupported); object content remains unchanged. + */ @Test @SkipForQuirks({PUT_OBJECT_IF_NONE_MATCH_ETAG_NOT_SUPPORTED}) public void thatConditionalPutIfNoneMatchEtagWorks() throws IOException, InterruptedException { @@ -110,6 +119,10 @@ public void thatConditionalPutIfNoneMatchEtagWorks() throws IOException, Interru } } + /** + * Puts an object, then overwrites with If-Match: "" (update-only if etag matches). + * Expected: Overwrite succeeds; GET returns new content and new ETag; or 412/501 if precondition fails. + */ @Test @SkipForQuirks({PUT_OBJECT_IF_MATCH_ETAG_NOT_SUPPORTED}) public void thatConditionalPutIfMatchEtagWorks() throws IOException, InterruptedException { @@ -153,6 +166,16 @@ public void thatConditionalPutIfMatchEtagWorks() throws IOException, Interrupted "baz" ); + GetObjectResponse getResponse; + String content; + try (var response = bucket.getObject("object")) { + getResponse = response.response(); + content = new String(response.readAllBytes(), StandardCharsets.UTF_8); + } + if (!target.hasQuirk(PUT_OBJECT_IF_MATCH_ETAG_NOT_SUPPORTED)) { + assertEquals("bar", content); + assertEquals(overwritePutResponse.eTag(), getResponse.eTag()); + } fail("PutObject using 'If-Match: \"\"' should fail if object etag does not match current etag"); } catch (S3Exception e) { // Should have gotten 412 Precondition Failed diff --git a/src/main/java/com/datadobi/s3test/DeleteObjectTests.java b/src/main/java/com/datadobi/s3test/DeleteObjectTests.java index 2fbf9f3..98ebc46 100644 --- a/src/main/java/com/datadobi/s3test/DeleteObjectTests.java +++ b/src/main/java/com/datadobi/s3test/DeleteObjectTests.java @@ -27,12 +27,20 @@ public class DeleteObjectTests extends S3TestBase { public DeleteObjectTests() throws IOException { } + /** + * Puts an object then deletes it with DeleteObject. + * Expected: Delete succeeds; object is no longer present (no exception). + */ @Test public void testDeleteObject() { bucket.putObject("foo", "Hello, World!"); bucket.deleteObject("foo"); } + /** + * Puts and then deletes an object whose key contains ".." (e.g. "f..o"). + * Expected: HEAD confirms object exists; DeleteObject succeeds without error. + */ @Test public void testDeleteObjectContainingDotDot() { var fullContent = "Hello, World!"; diff --git a/src/main/java/com/datadobi/s3test/DeleteObjectsTests.java b/src/main/java/com/datadobi/s3test/DeleteObjectsTests.java index 4dc4dfe..632f198 100644 --- a/src/main/java/com/datadobi/s3test/DeleteObjectsTests.java +++ b/src/main/java/com/datadobi/s3test/DeleteObjectsTests.java @@ -31,6 +31,10 @@ public class DeleteObjectsTests extends S3TestBase { public DeleteObjectsTests() throws IOException { } + /** + * Deletes multiple objects by key in a single DeleteObjects request. + * Expected: Both objects are removed; response hasDeleted() is true and deleted list contains "a" and "b". + */ @Test public void testDeleteObjectsByKey() { bucket.putObject("a", "Hello"); @@ -45,6 +49,10 @@ public void testDeleteObjectsByKey() { ); } + /** + * Deletes one object using DeleteObjects with a matching ETag condition. + * Expected: Object is deleted; response hasDeleted() is true and deleted list contains "a". + */ @Test public void testDeleteObjectsWithMatchingETag() { bucket.putObject("a", "Hello"); @@ -59,6 +67,10 @@ public void testDeleteObjectsWithMatchingETag() { ); } + /** + * Attempts to delete an object with a non-matching ETag (conditional delete). + * Expected: Object is not deleted; response hasDeleted() is false and deleted list is empty. + */ @Test public void testDeleteObjectsWithDifferentETag() { bucket.putObject("a", "Hello"); @@ -75,6 +87,10 @@ public void testDeleteObjectsWithDifferentETag() { ); } + /** + * Deletes objects whose keys contain ".." (e.g. "a..b", "c..d") via DeleteObjects. + * Expected: Both objects are deleted; response lists "a..b" and "c..d" in deleted. + */ @Test public void testDeleteObjectsContainingDotDot() { bucket.putObject("a..b", "Hello"); diff --git a/src/main/java/com/datadobi/s3test/GetObjectTests.java b/src/main/java/com/datadobi/s3test/GetObjectTests.java index af0423a..a9c0a8d 100644 --- a/src/main/java/com/datadobi/s3test/GetObjectTests.java +++ b/src/main/java/com/datadobi/s3test/GetObjectTests.java @@ -36,6 +36,10 @@ public class GetObjectTests extends S3TestBase { public GetObjectTests() throws IOException { } + /** + * Puts an object and retrieves it with GET. + * Expected: GetObject returns the full body; content matches original string "Hello, World!". + */ @Test public void testGetObject() throws IOException { var fullContent = "Hello, World!"; @@ -47,6 +51,10 @@ public void testGetObject() throws IOException { } } + /** + * Puts an empty object and retrieves it with GET. + * Expected: GetObject returns 0 bytes; response contentLength is 0. + */ @Test public void testGetEmptyObject() throws IOException { var emptyContent = ""; @@ -59,6 +67,10 @@ public void testGetEmptyObject() throws IOException { } } + /** + * Calls GetObject for a key that does not exist. + * Expected: S3Exception with HTTP status 404 (Not Found). + */ @Test public void testGetKeyThatDoesNotExist() throws IOException { try (var objectInputStream = bucket.getObject("foo")) { @@ -68,6 +80,10 @@ public void testGetKeyThatDoesNotExist() throws IOException { } } + /** + * Gets a byte range (bytes 7 to end) of an object using Range header. + * Expected: Response body is "World!"; Content-Range and Content-Length match the range. + */ @Test public void testGetPartial() throws IOException { var fullContent = "Hello, World!"; @@ -89,6 +105,10 @@ public void testGetPartial() throws IOException { } } + /** + * Requests a byte range that starts beyond the object size (e.g. bytes 200-). + * Expected: S3Exception with HTTP status 416 (Range Not Satisfiable). + */ @Test public void testGetPartialUnsatisfiable() { var fullContent = "Hello, World!"; @@ -102,6 +122,10 @@ public void testGetPartialUnsatisfiable() { } } + /** + * Gets a byte range with both start and end (bytes 0-4). + * Expected: Response body is "Hello"; Content-Range and Content-Length are correct. + */ @Test public void testGetPartialWithEndPosition() throws IOException { var fullContent = "Hello, World!"; @@ -123,6 +147,10 @@ public void testGetPartialWithEndPosition() throws IOException { } } + /** + * Gets a middle byte range (bytes 7-11) of "Hello, World!". + * Expected: Response body is "World"; Content-Range and Content-Length match. + */ @Test public void testGetPartialMiddleRange() throws IOException { var fullContent = "Hello, World!"; @@ -144,6 +172,10 @@ public void testGetPartialMiddleRange() throws IOException { } } + /** + * Gets the last N bytes using suffix-length range (e.g. bytes=-6 for last 6 bytes). + * Expected: Response body is "World!" (last 6 bytes of "Hello, World!"). + */ @Test public void testGetPartialLastBytes() throws IOException { var fullContent = "Hello, World!"; @@ -158,6 +190,10 @@ public void testGetPartialLastBytes() throws IOException { } } + /** + * Requests a range with end position beyond object size (e.g. bytes 10-100). + * Expected: Server returns from start to end of object only; body is "ld!" (bytes 10-12). + */ @Test public void testGetPartialBeyondEnd() throws IOException { var fullContent = "Hello, World!"; diff --git a/src/main/java/com/datadobi/s3test/ListBucketsTests.java b/src/main/java/com/datadobi/s3test/ListBucketsTests.java index e815957..5799c41 100644 --- a/src/main/java/com/datadobi/s3test/ListBucketsTests.java +++ b/src/main/java/com/datadobi/s3test/ListBucketsTests.java @@ -32,6 +32,10 @@ public class ListBucketsTests extends S3TestBase { public ListBucketsTests() throws IOException { } + /** + * Calls ListBuckets and checks the response Date header. + * Expected: Response includes a "Date" header; parsed value is within ~30 seconds of request time (valid RFC 1123). + */ @Test public void listBucketsResponsesShouldReturnValidDateHeader() { var timeOfRequest = Instant.now(); diff --git a/src/main/java/com/datadobi/s3test/ListObjectsTests.java b/src/main/java/com/datadobi/s3test/ListObjectsTests.java index 80ed57a..db231a3 100644 --- a/src/main/java/com/datadobi/s3test/ListObjectsTests.java +++ b/src/main/java/com/datadobi/s3test/ListObjectsTests.java @@ -44,6 +44,10 @@ public class ListObjectsTests extends S3TestBase { public ListObjectsTests() throws IOException { } + /** + * Uploads 143 objects, then lists all keys (implementation uses V2) with page size 7. + * Expected: All 143 keys are returned (paginated); set of keys matches uploaded keys. + */ @Test public void serialListObjectsV1GetsAllKeys() { Set generatedKeys = new HashSet<>(); @@ -62,6 +66,10 @@ public void serialListObjectsV1GetsAllKeys() { ); } + /** + * Uploads 143 objects, then lists all keys using ListObjects V2 with page size 7. + * Expected: All 143 keys are returned (paginated); set of keys matches uploaded keys. + */ @Test public void serialListObjectsV2GetsAllKeys() { Set generatedKeys = new HashSet<>(); @@ -80,21 +88,37 @@ public void serialListObjectsV2GetsAllKeys() { ); } + /** + * Lists with V2, maxKeys=1, startAfter="180" when keys "180","190","200" exist. + * Expected: One key returned ("190"); isTruncated is true (more keys exist). + */ @Test public void listUntilPenultimateV2ShouldIndicateTruncatedWithExistingStartAfterKey() { listUntilPenultimateShouldIndicateTruncated(V2, "180"); } + /** + * Same as above but startAfter="185" (non-existing key); first key after "185" is "190". + * Expected: One key "190" returned; isTruncated is true. + */ @Test public void listUntilPenultimateV2ShouldIndicateTruncatedWithNonExistingStartAfterKey() { listUntilPenultimateShouldIndicateTruncated(V2, "185"); } + /** + * Lists with V1 (marker), maxKeys=1, startAfter="180"; keys "180","190","200" exist. + * Expected: One key "190"; isTruncated true. + */ @Test public void listUntilPenultimateV1ShouldIndicateTruncatedWithExistingStartAfterKey() { listUntilPenultimateShouldIndicateTruncated(V1, "180"); } + /** + * Same with V1 and startAfter="185" (non-existing). + * Expected: One key "190"; isTruncated true. + */ @Test public void listUntilPenultimateV1ShouldIndicateTruncatedWithNonExistingStartAfterKey() { listUntilPenultimateShouldIndicateTruncated(V1, "185"); @@ -137,6 +161,10 @@ private void listUntilPenultimateShouldIndicateTruncated(S3.ListObjectsVersion v public static final String AFTER_SURROGATES = "\uFB80"; public static final String CP_MAX = "\uDBFF\uDFFF"; + /** + * Puts objects with keys containing BMP and non-BMP (surrogate) codepoints; lists and checks sort order. + * Expected: Keys are returned in UTF-8 binary order (or UTF-16 order if quirk); startAfter filtering works accordingly. + */ @Test @SkipForQuirks({KEYS_WITH_CODEPOINTS_OUTSIDE_BMP_REJECTED}) public void thatServerSortsInUtf8Binary() { @@ -172,6 +200,10 @@ public void thatServerSortsInUtf8Binary() { } } + /** + * Puts an object, copies it in-place with metadata replace; checks LIST and HEAD ETags. + * Expected: Copy preserves content so ETag unchanged; LIST V1/V2 return same ETag (or "" if quirk ETAG_EMPTY_AFTER_COPY_OBJECT). + */ @Test public void listReturnsSameEtagAsCopyObject() { var putResponse = bucket.putObject("key", "body"); @@ -206,12 +238,20 @@ public void listReturnsSameEtagAsCopyObject() { } } + /** + * Lists objects on an empty bucket (V1 and V2). + * Expected: Both return empty list of keys. + */ @Test public void testListEmpty() { assertEquals("V1 should return empty list for empty bucket", List.of(), bucket.listObjectKeys(V1)); assertEquals("V2 should return empty list for empty bucket", List.of(), bucket.listObjectKeys(V2)); } + /** + * Puts three objects ("a","l","z") and lists with V1 and V2. + * Expected: Both return keys in order ["a","l","z"]. + */ @Test public void testList() { var keys = Arrays.asList("a", "l", "z"); @@ -239,6 +279,10 @@ private void validateListObjects(S3.ListObjectsVersion listObjectsVersion, Map s / 100 == 2, "HTTP 2xx status"); } + /** + * Puts with key containing a null byte (invalid in UTF-8). + * Expected: HTTP 4xx/5xx (rejection); unless KEYS_WITH_NULL_NOT_REJECTED. + */ @Test @SkipForQuirks({KEYS_WITH_NULL_NOT_REJECTED}) public void testNullIsRejected() throws IOException { @@ -176,6 +200,10 @@ public void testNullIsRejected() throws IOException { assertThat(status).as("Put object with invalid UTF-8 bytes should be rejected").matches(s -> s / 100 > 3, "HTTP status of 300 or above"); } + /** + * Puts with key containing overlong null encoding (0xC0 0x80). + * Expected: HTTP 4xx/5xx (invalid UTF-8 rejected). + */ @Test @SkipForQuirks({KEYS_WITH_INVALID_UTF8_NOT_REJECTED}) public void testOverlongNullIsRejected() throws IOException { @@ -191,6 +219,10 @@ public void testOverlongNullIsRejected() throws IOException { assertThat(status).as("Put object with invalid UTF-8 bytes should be rejected").matches(s -> s / 100 > 3, "HTTP status of 300 or above"); } + /** + * Puts with key containing overlong encoding of 'a' (4-byte form). + * Expected: HTTP 4xx/5xx (overlong UTF-8 rejected). + */ @Test @SkipForQuirks({KEYS_WITH_INVALID_UTF8_NOT_REJECTED}) public void testOverlongEncodingsAreRejected() throws IOException { @@ -214,6 +246,10 @@ public void testOverlongEncodingsAreRejected() throws IOException { assertThat(status).as("Put object with invalid UTF-8 bytes should be rejected").matches(s -> s / 100 > 3, "HTTP status of 300 or above"); } + /** + * Puts objects with keys that are Unicode-equivalent under normalization (e.g. Å vs A+combining ring). + * Expected: Only the exact key used on put is retrievable; equivalent normalized forms do not match (no server-side normalization). + */ @Test public void testThatServerDoesNotNormalizeCodePoints() throws IOException { // Unicode normalization : https://www.unicode.org/reports/tr15/#Norm_Forms @@ -259,6 +295,10 @@ public void testThatServerDoesNotNormalizeCodePoints() throws IOException { } } + /** + * When null bytes in keys are accepted: puts keys with null and "A"; lists and checks order. + * Expected: Keys sorted in UTF-8 order: "with-null-byte-\0.key" before "with-null-byte-A.key". + */ @Test @SkipForQuirks({KEYS_WITH_NULL_ARE_TRUNCATED}) public void thatServerSortsNullInUtf8Order() throws IOException { diff --git a/src/main/java/com/datadobi/s3test/PrefixDelimiterTests.java b/src/main/java/com/datadobi/s3test/PrefixDelimiterTests.java index 8158085..96b6860 100644 --- a/src/main/java/com/datadobi/s3test/PrefixDelimiterTests.java +++ b/src/main/java/com/datadobi/s3test/PrefixDelimiterTests.java @@ -37,6 +37,10 @@ public class PrefixDelimiterTests extends S3TestBase { public PrefixDelimiterTests() throws IOException { } + /** + * Lists with prefix="a/", delimiter="/", maxKeys=10. Bucket has a, a/b/0..9, a/c/0..9, a/d. + * Expected: contents = ["a/d"]; commonPrefixes = ["a/b/", "a/c/"]; not truncated. + */ @Test public void testSimple() { bucket.putObject("a", "a"); @@ -64,6 +68,10 @@ public void testSimple() { assertFalse("Result should not be truncated", response.isTruncated()); } + /** + * Lists with prefix="a/b/", delimiter="/". All keys under a/b/ (0..9). + * Expected: contents = all a/b/0..9; commonPrefixes empty; not truncated. + */ @Test public void testTruncatedPrefix() { bucket.putObject("a", "a"); @@ -90,6 +98,10 @@ public void testTruncatedPrefix() { assertFalse("Result should not be truncated", result.isTruncated()); } + /** + * Lists with prefix="a/" and no delimiter; uses continuation tokens across pages. + * Expected: First page a/b/0..9, second a/c/0..9, third a/d; pagination via nextContinuationToken. + */ @Test public void testPrefixOnly() { bucket.putObject("a", "a"); @@ -129,6 +141,10 @@ public void testPrefixOnly() { ); } + /** + * Lists with prefix="a/", delimiter="/", maxKeys=10; many objects and common prefixes. + * Expected: Multiple pages; contents and commonPrefixes match expected batches; truncation/continuation correct. + */ @Test public void testMorePrefixesThanMaxKeys() { for (var i = 0; i < 15; i++) { @@ -178,6 +194,10 @@ public void testMorePrefixesThanMaxKeys() { ); } + /** + * Lists with prefix=null, delimiter="/"; bucket has a, d, b/0..499, c/0..499. + * Expected: First page contents ["a","d"], commonPrefixes ["b/","c/"] (top-level listing). + */ @Test public void testNullPrefix() { bucket.putObject("a", "a"); @@ -205,6 +225,10 @@ public void testNullPrefix() { ); } + /** + * Lists with prefix="/", delimiter="/". No keys start with "/". + * Expected: Empty contents and empty commonPrefixes. + */ @Test public void testSlashPrefix() { bucket.putObject("a", "a"); @@ -230,6 +254,10 @@ public void testSlashPrefix() { ); } + /** + * Lists with prefix="b/", delimiter="/"; bucket has object "b/" and b/0..199, c/0..9, d. + * Expected: First page includes "b/" and b/0, b/1, b/10, etc. (lexicographic order); commonPrefixes empty. + */ @Test public void testPrefixMatchingObjectKey() { bucket.putObject("a", "a"); @@ -258,6 +286,10 @@ public void testPrefixMatchingObjectKey() { ); } + /** + * Single object with key "b/" (trailing slash); list with prefix="", delimiter="/". + * Expected: No contents; commonPrefixes = ["b/"] (key "b/" reported as common prefix). + */ @Test public void testSingleObjectIsReportedAsCommonPrefix() { // Intentionally create an object with a key matching the prefix to see if it's returned or not diff --git a/src/main/java/com/datadobi/s3test/PutObjectTests.java b/src/main/java/com/datadobi/s3test/PutObjectTests.java index b4d97ae..b9002d1 100644 --- a/src/main/java/com/datadobi/s3test/PutObjectTests.java +++ b/src/main/java/com/datadobi/s3test/PutObjectTests.java @@ -37,6 +37,10 @@ public class PutObjectTests extends S3TestBase { public PutObjectTests() throws IOException { } + /** + * Puts an object "foo" with body "bar" and checks HEAD. + * Expected: contentLength 3; HEAD ETag matches put response ETag. + */ @Test public void testPutObject() { var putResponse = bucket.putObject("foo", "bar"); @@ -45,6 +49,10 @@ public void testPutObject() { assertEquals("ETag should match PutObject response", putResponse.eTag(), headResponse.eTag()); } + /** + * Puts an empty object "foo" (zero-length body). + * Expected: HEAD contentLength 0; ETag matches put response. + */ @Test public void testPutEmptyObject() { var putResponse = bucket.putObject("foo", ""); @@ -53,6 +61,10 @@ public void testPutEmptyObject() { assertEquals("ETag should match PutObject response", putResponse.eTag(), headResponse.eTag()); } + /** + * Puts an object, then overwrites it with different content. + * Expected: Second put succeeds; GET returns new content and new ETag; ETags differ. + */ @Test public void thatPutObjectCanUpdate() throws Exception { var key = "key"; @@ -83,6 +95,10 @@ public void thatPutObjectCanUpdate() throws Exception { } + /** + * Puts an object with Content-Encoding: gzip and binary gzip body. + * Expected: Object stored as-is; GET returns same bytes and Content-Encoding: gzip. + */ @Test public void thatServerAcceptsContentEncodingGzip() throws Exception { var xml = ""; @@ -107,6 +123,10 @@ public void thatServerAcceptsContentEncodingGzip() throws Exception { } } + /** + * Puts an object with custom Content-Encoding "dd-plain-no-encoding". + * Expected: Object stored; GET returns same content and contentEncoding header. + */ @Test public void thatServerAcceptsArbitraryContentEncoding() throws Exception { var xml = ""; @@ -127,6 +147,10 @@ public void thatServerAcceptsArbitraryContentEncoding() throws Exception { } } + /** + * Puts an empty object with key "content-type/" and Content-Type "text/empty". + * Expected: GET returns Content-Type "text/empty" (unless CONTENT_TYPE_NOT_SET_FOR_KEYS_WITH_TRAILING_SLASH). + */ @Test @SkipForQuirks({CONTENT_TYPE_NOT_SET_FOR_KEYS_WITH_TRAILING_SLASH}) public void canSetContentTypeOnEmptyObjectWithKeyContainingTrailingSlash() throws IOException { @@ -145,6 +169,10 @@ public void canSetContentTypeOnEmptyObjectWithKeyContainingTrailingSlash() throw } } + /** + * Puts an object, then overwrites with same content but different user metadata. + * Expected: LastModified from HEAD increases after second put. + */ @Test public void thatUpdateMetadataUsingPutObjectAffectsLastModifiedTime() throws Exception { var key = "key"; @@ -169,6 +197,10 @@ public void thatUpdateMetadataUsingPutObjectAffectsLastModifiedTime() throws Exc assertNotEquals("PutObject with different user metadata should change LastModified", info1.lastModified(), info2.lastModified()); } + /** + * Puts an object, then overwrites with different content after a short delay. + * Expected: ETag and LastModified both change after second put. + */ @Test public void thatUpdateDataUsingPutObjectAffectsLastModifiedTime() throws Exception { var key = "key"; diff --git a/src/main/java/com/datadobi/s3test/RunTests.java b/src/main/java/com/datadobi/s3test/RunTests.java index 7dda140..e595da6 100644 --- a/src/main/java/com/datadobi/s3test/RunTests.java +++ b/src/main/java/com/datadobi/s3test/RunTests.java @@ -195,15 +195,16 @@ public void testFinished(Description description) throws Exception { } stdOut.println(); } else if (failure != null) { - stdOut.println(" ❌: " + failure.getException().getMessage()); - + stdOut.println(" ❌"); + String shortMessage = getShortFailureMessage(failure); + stdOut.println(" " + shortMessage); if (logPath != null) { - Path logPath = Files.createDirectories( + Path logDir = Files.createDirectories( this.logPath.resolve(description.getTestClass().getSimpleName()) .resolve(description.getMethodName()) ); Files.writeString( - logPath.resolve("error.log"), + logDir.resolve("error.log"), failure.getTrace(), StandardCharsets.UTF_8 ); @@ -212,5 +213,21 @@ public void testFinished(Description description) throws Exception { stdOut.println(" ✅"); } } + + private static String getShortFailureMessage(Failure failure) { + Throwable ex = failure.getException(); + String name = ex.getClass().getSimpleName(); + String message = ex.getMessage(); + if (message != null && !message.isBlank()) { + // keep first line only, truncate if very long + String firstLine = message.lines().findFirst().orElse("").trim(); + int maxLen = 200; + if (firstLine.length() > maxLen) { + firstLine = firstLine.substring(0, maxLen) + "..."; + } + return name + ": " + firstLine; + } + return name; + } } } diff --git a/src/main/java/com/datadobi/s3test/s3/S3TestBase.java b/src/main/java/com/datadobi/s3test/s3/S3TestBase.java index 84091a7..fa1588f 100644 --- a/src/main/java/com/datadobi/s3test/s3/S3TestBase.java +++ b/src/main/java/com/datadobi/s3test/s3/S3TestBase.java @@ -28,6 +28,7 @@ import java.io.IOException; import java.nio.file.Path; import java.util.Objects; +import java.util.UUID; public class S3TestBase { public static final Config DEFAULT_CONFIG; @@ -61,6 +62,9 @@ public class S3TestBase { private Description currentTest; + /** When cleanup fails, next test uses this bucket instead of target.bucket(); cleared after use. */ + private static volatile String cleanupFailedNextBucket = null; + @Rule(order = 0) public TestWatcher testName = new TestWatcher() { @Override @@ -92,10 +96,17 @@ public final void setUp() throws IOException { s3 = S3.createClient(target); - this.bucket = new S3Bucket(s3, target.bucket()); + String bucketName = cleanupFailedNextBucket != null ? cleanupFailedNextBucket : target.bucket(); + this.bucket = new S3Bucket(s3, bucketName); if (target.createBucket()) { bucket.create(); } + // If we used a fallback bucket, keep using new buckets for subsequent tests (original may still exist). + if (cleanupFailedNextBucket != null) { + cleanupFailedNextBucket = "s3test-" + UUID.randomUUID(); + } else { + cleanupFailedNextBucket = null; + } if (!CAPTURE_SETUP) { WIRE_LOGGER.start(currentTest); @@ -108,9 +119,15 @@ public final void tearDown() { WIRE_LOGGER.stop(); } - S3.clearBucket(s3, target.bucket()); - if (target.createBucket()) { - bucket.delete(); + try { + S3.clearBucket(s3, bucket.name()); + if (target.createBucket()) { + bucket.delete(); + } + } catch (Throwable t) { + // Cleanup failed (e.g. bucket not empty): use a new bucket for next test and do not + // fail this test — only the test method's result counts. + cleanupFailedNextBucket = "s3test-" + UUID.randomUUID(); } s3.close();