diff --git a/candybox-common/src/test/java/me/predatorray/candybox/common/DomainTypesTest.java b/candybox-common/src/test/java/me/predatorray/candybox/common/DomainTypesTest.java index 69e708b..9236a6a 100644 --- a/candybox-common/src/test/java/me/predatorray/candybox/common/DomainTypesTest.java +++ b/candybox-common/src/test/java/me/predatorray/candybox/common/DomainTypesTest.java @@ -54,4 +54,36 @@ void candyKeyUtf8RoundTrips() { assertThat(restored).isEqualTo(original); assertThat(restored.value()).isEqualTo("片/路径/✓"); } + + @Test + void candyKeyOfUtf8RejectsEmptyAndExposesHashAndString() { + assertThatThrownBy(() -> CandyKey.ofUtf8(new byte[0])).isInstanceOf(ValidationException.class); + CandyKey key = CandyKey.of("k"); + assertThat(key.toString()).isEqualTo("k"); + assertThat(key.hashCode()).isEqualTo(CandyKey.of("k").hashCode()); + } + + @Test + void tombstoneLocatorCarriesNoBytesAndZeroAccessors() { + CandyLocator tombstone = CandyLocator.tombstone(new Hlc(1, 0, 1), 1000L); + assertThat(tombstone.isTombstone()).isTrue(); + assertThat(tombstone.contentLength()).isZero(); + assertThat(tombstone.chunkSize()).isZero(); + assertThat(tombstone.crc32c()).isZero(); + assertThat(tombstone.parts()).isEmpty(); + } + + @Test + void candyLocatorRejectsNullRequiredFieldsAndPartsOnDelete() { + assertThatThrownBy(() -> new CandyLocator(null, LocatorType.PUT, null, java.util.Map.of(), + 0L, List.of(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("hlc and type are required"); + // A DELETE tombstone must not carry any parts. + Part part = new Part(1, 16, 0, List.of(new SegmentRef(1, 0, 0))); + assertThatThrownBy(() -> new CandyLocator(new Hlc(1, 0, 1), LocatorType.DELETE, null, + java.util.Map.of(), 0L, List.of(part), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("DELETE tombstone must carry no parts"); + } } diff --git a/candybox-lsm/src/test/java/me/predatorray/candybox/lsm/engine/BoxEngineEdgeCasesTest.java b/candybox-lsm/src/test/java/me/predatorray/candybox/lsm/engine/BoxEngineEdgeCasesTest.java new file mode 100644 index 0000000..de7fb56 --- /dev/null +++ b/candybox-lsm/src/test/java/me/predatorray/candybox/lsm/engine/BoxEngineEdgeCasesTest.java @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package me.predatorray.candybox.lsm.engine; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import me.predatorray.candybox.bookkeeper.fake.InMemoryLedgerStore; +import me.predatorray.candybox.common.BoxName; +import me.predatorray.candybox.common.CandyKey; +import me.predatorray.candybox.common.ManualClock; +import me.predatorray.candybox.common.auth.Grant; +import me.predatorray.candybox.common.auth.ObjectAcl; +import me.predatorray.candybox.common.auth.Operation; +import me.predatorray.candybox.common.auth.Principal; +import me.predatorray.candybox.common.config.CandyboxConfig; +import me.predatorray.candybox.common.exception.CandyNotFoundException; +import me.predatorray.candybox.common.exception.ValidationException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * Edge-case and validation coverage for {@link BoxEngine} that complements the happy-path + * {@link BoxEngineTest}: argument validation across the multipart API, object-ACL reads/writes, + * range-GET boundary resolution, prefix/range scan corners, and recovery that re-opens SSTables. + */ +class BoxEngineEdgeCasesTest { + + private final InMemoryLedgerStore store = new InMemoryLedgerStore(); + private final BoxName box = BoxName.of("my-box"); + private BoxEngine engine; + + @AfterEach + void tearDown() { + if (engine != null) { + engine.close(); + } + store.close(); + } + + private BoxEngine newEngine() { + return newEngine(CandyboxConfig.defaults()); + } + + private BoxEngine newEngine(CandyboxConfig cfg) { + return BoxEngine.createNew(box, cfg, store, 1, new ManualClock(1000), 1L); + } + + private static byte[] bytes(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + // ---- put / metadata --------------------------------------------------------------------- + + @Test + void putAcceptsNullUserMetadataAndExposesBoxName() { + engine = newEngine(); + engine.putCandy(CandyKey.of("k"), bytes("v"), "text/plain", null, null); + assertThat(engine.headCandy(CandyKey.of("k")).userMetadata()).isEmpty(); + assertThat(engine.box()).isEqualTo(box); + } + + // ---- multipart: createMultipartUpload ---------------------------------------------------- + + @Test + void createMultipartUploadAcceptsNullMetadataAndCapsConcurrency() { + CandyboxConfig cfg = CandyboxConfig.builder() + .multipartMaxConcurrentUploadsPerBox(1) + .build(); + engine = newEngine(cfg); + String id = engine.createMultipartUpload(CandyKey.of("a"), null, null); + assertThat(engine.multipartUpload(id)).isNotNull(); + assertThat(engine.listMultipartUploads()).hasSize(1); + // The second concurrent upload exceeds the per-Box cap. + assertThatThrownBy(() -> engine.createMultipartUpload(CandyKey.of("b"), null, Map.of())) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("Too many in-flight"); + } + + // ---- multipart: uploadPart --------------------------------------------------------------- + + @Test + void uploadPartValidatesUploadIdAndPartNumber() { + engine = newEngine(); + assertThatThrownBy(() -> engine.uploadPart("", 1, bytes("x"))) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("uploadId is required"); + String id = engine.createMultipartUpload(CandyKey.of("k"), null, Map.of()); + assertThatThrownBy(() -> engine.uploadPart(id, 0, bytes("x"))) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("partNumber"); + assertThatThrownBy(() -> engine.uploadPart("no-such-upload", 1, bytes("x"))) + .isInstanceOf(CandyNotFoundException.class); + } + + @Test + void reUploadingSamePartNumberSupersedesThePriorPart() { + CandyboxConfig cfg = CandyboxConfig.builder().multipartMinPartBytes(1).build(); + engine = newEngine(cfg); + String id = engine.createMultipartUpload(CandyKey.of("k"), null, Map.of()); + engine.uploadPart(id, 1, bytes("first-version")); + BoxEngine.PartUploadResult second = engine.uploadPart(id, 1, bytes("second")); + // The recorded part reflects the latest upload. + assertThat(engine.multipartUpload(id).parts().get(1).partLength()).isEqualTo(6); + engine.completeMultipartUpload(id, List.of(new BoxEngine.PartCompletion(1, second.crc32c())), + null); + assertThat(engine.getCandy(CandyKey.of("k"))).isEqualTo(bytes("second")); + } + + // ---- multipart: uploadPartCopy ----------------------------------------------------------- + + @Test + void uploadPartCopyValidatesArgumentsAndRange() { + CandyboxConfig cfg = CandyboxConfig.builder().multipartMinPartBytes(1).build(); + engine = newEngine(cfg); + engine.putCandy(CandyKey.of("src"), bytes("hello candybox"), null, Map.of(), null); + String id = engine.createMultipartUpload(CandyKey.of("dst"), null, Map.of()); + + assertThatThrownBy(() -> engine.uploadPartCopy("", 1, CandyKey.of("src"), 0, 1)) + .isInstanceOf(ValidationException.class); + assertThatThrownBy(() -> engine.uploadPartCopy(id, 0, CandyKey.of("src"), 0, 1)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("partNumber"); + assertThatThrownBy(() -> engine.uploadPartCopy("missing", 1, CandyKey.of("src"), 0, 1)) + .isInstanceOf(CandyNotFoundException.class); + assertThatThrownBy(() -> engine.uploadPartCopy(id, 1, CandyKey.of("absent"), 0, 1)) + .isInstanceOf(CandyNotFoundException.class); + // first byte beyond the object end is not satisfiable. + assertThatThrownBy(() -> engine.uploadPartCopy(id, 1, CandyKey.of("src"), 100, 200)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("InvalidRange"); + } + + @Test + void uploadPartCopyWithOpenEndedBoundsCopiesToObjectEnd() { + CandyboxConfig cfg = CandyboxConfig.builder().multipartMinPartBytes(1).build(); + engine = newEngine(cfg); + engine.putCandy(CandyKey.of("src"), bytes("hello candybox"), null, Map.of(), null); + String id = engine.createMultipartUpload(CandyKey.of("dst"), null, Map.of()); + // firstByte < 0 resolves to 0; lastByte < 0 resolves to the object's last byte. + BoxEngine.PartUploadResult copied = engine.uploadPartCopy(id, 1, CandyKey.of("src"), -1, -1); + engine.completeMultipartUpload(id, List.of(new BoxEngine.PartCompletion(1, copied.crc32c())), + null); + assertThat(engine.getCandy(CandyKey.of("dst"))).isEqualTo(bytes("hello candybox")); + } + + // ---- multipart: completeMultipartUpload / abort ------------------------------------------ + + @Test + void completeMultipartUploadValidatesItsArguments() { + engine = newEngine(); + assertThatThrownBy(() -> engine.completeMultipartUpload("", List.of(), null)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("uploadId is required"); + String id = engine.createMultipartUpload(CandyKey.of("k"), null, Map.of()); + assertThatThrownBy(() -> engine.completeMultipartUpload(id, List.of(), null)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("at least one part"); + assertThatThrownBy(() -> engine.completeMultipartUpload("missing", + List.of(new BoxEngine.PartCompletion(1, 0)), null)) + .isInstanceOf(CandyNotFoundException.class); + } + + @Test + void completeMultipartUploadRejectsNonAscendingAndUnknownParts() { + CandyboxConfig cfg = CandyboxConfig.builder().multipartMinPartBytes(1).build(); + engine = newEngine(cfg); + String id = engine.createMultipartUpload(CandyKey.of("k"), null, Map.of()); + BoxEngine.PartUploadResult p1 = engine.uploadPart(id, 1, bytes("aaaa")); + BoxEngine.PartUploadResult p2 = engine.uploadPart(id, 2, bytes("bbbb")); + // Non-ascending order. + assertThatThrownBy(() -> engine.completeMultipartUpload(id, List.of( + new BoxEngine.PartCompletion(2, p2.crc32c()), + new BoxEngine.PartCompletion(1, p1.crc32c())), null)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("ascending"); + // Reference to a part that was never uploaded. + assertThatThrownBy(() -> engine.completeMultipartUpload(id, List.of( + new BoxEngine.PartCompletion(9, 0)), null)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("Unknown part"); + } + + @Test + void completeMultipartUploadIsIdempotentUnderToken() { + CandyboxConfig cfg = CandyboxConfig.builder().multipartMinPartBytes(1).build(); + engine = newEngine(cfg); + String id = engine.createMultipartUpload(CandyKey.of("k"), null, Map.of()); + BoxEngine.PartUploadResult p1 = engine.uploadPart(id, 1, bytes("payload")); + CandyMetadata first = engine.completeMultipartUpload(id, + List.of(new BoxEngine.PartCompletion(1, p1.crc32c())), "tok-1"); + // The retry replays the cached result rather than failing on the now-removed upload. + CandyMetadata retry = engine.completeMultipartUpload(id, + List.of(new BoxEngine.PartCompletion(1, p1.crc32c())), "tok-1"); + assertThat(retry.hlc()).isEqualTo(first.hlc()); + } + + @Test + void abortMultipartUploadRejectsBlankUploadId() { + engine = newEngine(); + assertThatThrownBy(() -> engine.abortMultipartUpload("")) + .isInstanceOf(ValidationException.class); + } + + // ---- object ACLs ------------------------------------------------------------------------- + + @Test + void objectAclIsStoredOnPutAndReplacedBySetAcl() { + engine = newEngine(); + Principal alice = Principal.user("alice"); + ObjectAcl owned = new ObjectAcl(alice.toString(), + List.of(Grant.of(Grant.ALL_USERS, Operation.READ))); + engine.putCandy(CandyKey.of("k"), new java.io.ByteArrayInputStream(bytes("v")), null, + Map.of(), null, owned); + assertThat(engine.getCandyAcl(CandyKey.of("k")).owner()).isEqualTo(alice.toString()); + + // setCandyAcl rewrites the document in place (zero-copy locator rewrite) and survives a flush. + ObjectAcl replaced = ObjectAcl.ownedBy(Principal.user("bob")); + engine.setCandyAcl(CandyKey.of("k"), replaced); + engine.flush(); + assertThat(engine.getCandyAcl(CandyKey.of("k")).owner()) + .isEqualTo(Principal.user("bob").toString()); + assertThat(engine.getCandy(CandyKey.of("k"))).isEqualTo(bytes("v")); // bytes intact + } + + @Test + void aclOperationsOnMissingKeyThrowNotFound() { + engine = newEngine(); + assertThatThrownBy(() -> engine.getCandyAcl(CandyKey.of("ghost"))) + .isInstanceOf(CandyNotFoundException.class); + assertThatThrownBy(() -> engine.setCandyAcl(CandyKey.of("ghost"), ObjectAcl.NONE)) + .isInstanceOf(CandyNotFoundException.class); + } + + // ---- range GET boundary resolution ------------------------------------------------------- + + @Test + void rangeGetBoundaryResolution() { + engine = newEngine(); + engine.putCandy(CandyKey.of("k"), bytes("hello candybox"), null, Map.of(), null); // 14 bytes + + // Missing key. + assertThatThrownBy(() -> engine.getCandyRange(CandyKey.of("ghost"), 0, 1, + new ByteArrayOutputStream())).isInstanceOf(CandyNotFoundException.class); + // Neither bound supplied. + assertThatThrownBy(() -> engine.getCandyRange(CandyKey.of("k"), -1, -1, + new ByteArrayOutputStream())).isInstanceOf(IllegalArgumentException.class); + // Non-positive suffix. + assertThatThrownBy(() -> engine.getCandyRange(CandyKey.of("k"), -1, 0, + new ByteArrayOutputStream())).isInstanceOf(IllegalArgumentException.class); + // firstByte at/after end is unsatisfiable. + assertThatThrownBy(() -> engine.getCandyRange(CandyKey.of("k"), 14, -1, + new ByteArrayOutputStream())).isInstanceOf(IllegalArgumentException.class); + + // Suffix larger than the object clamps to the whole object. + ByteArrayOutputStream all = new ByteArrayOutputStream(); + BoxEngine.RangeReadResult whole = engine.getCandyRange(CandyKey.of("k"), -1, 999, all); + assertThat(all.toByteArray()).isEqualTo(bytes("hello candybox")); + assertThat(whole.firstByte()).isZero(); + assertThat(whole.lastByte()).isEqualTo(13); + } + + @Test + void rangeGetOnEmptyObjectIsUnsatisfiable() { + engine = newEngine(); + engine.putCandy(CandyKey.of("empty"), new byte[0], null, Map.of(), null); + assertThatThrownBy(() -> engine.getCandyRange(CandyKey.of("empty"), 0, 0, + new ByteArrayOutputStream())).isInstanceOf(IllegalArgumentException.class); + } + + // ---- scans ------------------------------------------------------------------------------- + + @Test + void deleteRangeByPrefixWithEmptyPrefixClearsTheBox() { + engine = newEngine(); + engine.putCandy(CandyKey.of("a"), bytes("x"), null, Map.of(), null); + engine.putCandy(CandyKey.of("b"), bytes("x"), null, Map.of(), null); + engine.deleteRangeByPrefix(""); // empty prefix => whole-keyspace tombstone + assertThat(engine.listCandies(null, null, 100).entries()).isEmpty(); + } + + @Test + void deleteRangeByPrefixWithHighBytePrefixHasUnboundedSuccessor() { + engine = newEngine(); + // A prefix of 0xFF bytes has no lexicographic successor, so the upper bound is "unbounded". + String highPrefix = new String(new byte[]{(byte) 0xFF}, StandardCharsets.UTF_8); + engine.putCandy(CandyKey.of(highPrefix + "z"), bytes("x"), null, Map.of(), null); + engine.putCandy(CandyKey.of("a"), bytes("x"), null, Map.of(), null); + engine.deleteRangeByPrefix(highPrefix); + assertThat(engine.listCandies(null, null, 100).entries()) + .extracting(e -> e.key().value()).containsExactly("a"); + } + + @Test + void forwardScanHonoursPrefixIntersectedWithExplicitWindow() { + engine = newEngine(); + for (String k : new String[] {"p/a", "p/b", "p/c", "q/a"}) { + engine.putCandy(CandyKey.of(k), bytes("x"), null, Map.of(), null); + } + engine.flush(); + // Prefix "p/" intersected with [p/b, p/c) yields only "p/b". + ListResult res = engine.scanCandies(new ScanQuery("p/", CandyKey.of("p/b"), + CandyKey.of("p/c"), null, ScanDirection.FORWARD, 100)); + assertThat(res.entries()).extracting(e -> e.key().value()).containsExactly("p/b"); + } + + @Test + void reverseScanRespectsLowerAndUpperBounds() { + engine = newEngine(); + for (String k : new String[] {"a", "b", "c", "d", "e"}) { + engine.putCandy(CandyKey.of(k), bytes("x"), null, Map.of(), null); + } + engine.flush(); + // Reverse over [b, e): emits d, c, b (descending), excluding a (< b) and e (== end). + ListResult res = engine.scanCandies(ScanQuery.reverse(null, CandyKey.of("b"), + CandyKey.of("e"), null, 100)); + assertThat(res.entries()).extracting(e -> e.key().value()).containsExactly("d", "c", "b"); + } + + // ---- recovery re-opens SSTables ---------------------------------------------------------- + + @Test + void recoverReopensFlushedSSTablesAndKeepsReadingThem() { + ManualClock clock = new ManualClock(5000); + BoxEngine ownerA = BoxEngine.createNew(box, CandyboxConfig.defaults(), store, 1, clock, 1L); + ownerA.putCandy(CandyKey.of("persisted"), bytes("durable"), null, Map.of(), null); + ownerA.flush(); // now lives in an L0 SSTable referenced by the manifest + long manifestLedgerId = ownerA.manifestLedgerId(); + ownerA.close(); + + engine = BoxEngine.recover(box, CandyboxConfig.defaults(), store, 2, clock, manifestLedgerId, 2L); + // The recovered owner must re-open the SSTable readers and serve the flushed key. + assertThat(engine.getCandy(CandyKey.of("persisted"))).isEqualTo(bytes("durable")); + } + + // ---- GC bookkeeping ---------------------------------------------------------------------- + + @Test + void dropSyrupsIgnoresAnEmptyCollection() { + engine = newEngine(); + engine.dropSyrups(List.of()); // no-op, must not touch the manifest + engine.putCandy(CandyKey.of("k"), bytes("v"), null, Map.of(), null); + assertThat(engine.getCandy(CandyKey.of("k"))).isEqualTo(bytes("v")); + } +} diff --git a/candybox-lsm/src/test/java/me/predatorray/candybox/lsm/manifest/ManifestEditTest.java b/candybox-lsm/src/test/java/me/predatorray/candybox/lsm/manifest/ManifestEditTest.java new file mode 100644 index 0000000..5f7365e --- /dev/null +++ b/candybox-lsm/src/test/java/me/predatorray/candybox/lsm/manifest/ManifestEditTest.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package me.predatorray.candybox.lsm.manifest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import me.predatorray.candybox.common.Part; +import me.predatorray.candybox.common.SegmentRef; +import org.junit.jupiter.api.Test; + +/** + * Validation and defensive-copy coverage for the manifest edit value types: {@link ManifestEdit}, + * its {@link ManifestEdit.PartUpsert}, the {@link ManifestEdit.Builder} setters, and + * {@link MultipartUploadState}. + */ +class ManifestEditTest { + + private static Part part() { + return new Part(3, 16, 0, List.of(new SegmentRef(1, 0, 0))); + } + + @Test + void constructorNormalizesNullCollectionsToEmpty() { + ManifestEdit edit = new ManifestEdit(List.of(), Set.of(), Set.of(), Set.of(), null, + null, null, null, 0L); + assertThat(edit.addedUploads()).isEmpty(); + assertThat(edit.upsertParts()).isEmpty(); + assertThat(edit.removedUploads()).isEmpty(); + } + + @Test + void constructorRejectsNegativeFencingToken() { + assertThatThrownBy(() -> new ManifestEdit(List.of(), Set.of(), Set.of(), Set.of(), null, + List.of(), List.of(), Set.of(), -1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ownerFencingToken"); + } + + @Test + void partUpsertValidatesItsFields() { + assertThatThrownBy(() -> new ManifestEdit.PartUpsert("", 1, part())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("uploadId"); + assertThatThrownBy(() -> new ManifestEdit.PartUpsert("u", 0, part())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("partNumber"); + assertThatThrownBy(() -> new ManifestEdit.PartUpsert("u", 1, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("part is required"); + } + + @Test + void builderSettersPopulateUploadAndPartLists() { + MultipartUploadState upload = new MultipartUploadState("up", "key", null, Map.of(), 1L, + Map.of()); + ManifestEdit edit = ManifestEdit.builder() + .addedUploads(List.of(upload)) + .upsertParts(List.of(new ManifestEdit.PartUpsert("up", 1, part()))) + .removedUploads(Set.of("gone")) + .ownerFencingToken(7L) + .build(); + assertThat(edit.addedUploads()).extracting(MultipartUploadState::uploadId).containsExactly("up"); + assertThat(edit.upsertParts()).hasSize(1); + assertThat(edit.removedUploads()).containsExactly("gone"); + assertThat(edit.ownerFencingToken()).isEqualTo(7L); + } + + @Test + void multipartUploadStateValidatesIdAndKey() { + assertThatThrownBy(() -> new MultipartUploadState("", "key", null, Map.of(), 0L, Map.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("uploadId"); + assertThatThrownBy(() -> new MultipartUploadState("up", "", null, Map.of(), 0L, Map.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("key"); + } + + @Test + void multipartUploadStateNormalizesNullMetadataAndSortsParts() { + MultipartUploadState state = new MultipartUploadState("up", "key", null, null, 0L, + Map.of(2, part(), 1, part())); + assertThat(state.userMetadata()).isEmpty(); + assertThat(state.parts().keySet()).containsExactly(1, 2); // sorted ascending + + // withPart installs a new part number and keeps ordering. + MultipartUploadState withThree = state.withPart(3, part()); + assertThat(withThree.parts().keySet()).containsExactly(1, 2, 3); + assertThatThrownBy(() -> state.withPart(0, part())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("partNumber"); + } +} diff --git a/candybox-lsm/src/test/java/me/predatorray/candybox/lsm/sstable/SSTableTest.java b/candybox-lsm/src/test/java/me/predatorray/candybox/lsm/sstable/SSTableTest.java index 0068708..5cf41d7 100644 --- a/candybox-lsm/src/test/java/me/predatorray/candybox/lsm/sstable/SSTableTest.java +++ b/candybox-lsm/src/test/java/me/predatorray/candybox/lsm/sstable/SSTableTest.java @@ -149,6 +149,41 @@ void writesRangeOnlyTableWithNoPointData() { } } + @Test + void openingAnEmptyLedgerIsRejected() { + // A ledger with no entries has no footer to parse — the reader must fail loudly. + long emptyLedgerId = store.createLedger(config).ledgerId(); + assertThat(emptyLedgerId).isGreaterThanOrEqualTo(0); + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> new SSTableReader(store, emptyLedgerId)) + .isInstanceOf(me.predatorray.candybox.common.exception.SerializationException.class) + .hasMessageContaining("empty"); + } + + @Test + void forwardScanFromKeyBeyondMaxIsEmptyAndExhaustsCleanly() { + SSTableMeta meta = writeKeys(10); + try (SSTableReader reader = new SSTableReader(store, meta.ledgerId())) { + var it = reader.scan(CandyKey.of("zzz")); // beyond the table's max key + assertThat(it.hasNext()).isFalse(); + org.assertj.core.api.Assertions.assertThatThrownBy(it::next) + .isInstanceOf(java.util.NoSuchElementException.class); + } + } + + @Test + void reverseScanNextPastEndThrows() { + SSTableMeta meta = writeKeys(3); + try (SSTableReader reader = new SSTableReader(store, meta.ledgerId())) { + var it = reader.scanReverse(null); + while (it.hasNext()) { + it.next(); + } + org.assertj.core.api.Assertions.assertThatThrownBy(it::next) + .isInstanceOf(java.util.NoSuchElementException.class); + } + } + @Test void fullScanReturnsEverythingInOrder() { SSTableMeta meta = writeKeys(50); diff --git a/candybox-lsm/src/test/java/me/predatorray/candybox/lsm/syrup/SyrupReaderEdgeTest.java b/candybox-lsm/src/test/java/me/predatorray/candybox/lsm/syrup/SyrupReaderEdgeTest.java new file mode 100644 index 0000000..8309c37 --- /dev/null +++ b/candybox-lsm/src/test/java/me/predatorray/candybox/lsm/syrup/SyrupReaderEdgeTest.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package me.predatorray.candybox.lsm.syrup; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.List; +import me.predatorray.candybox.bookkeeper.LedgerConfig; +import me.predatorray.candybox.bookkeeper.WritableLedger; +import me.predatorray.candybox.bookkeeper.fake.InMemoryLedgerStore; +import me.predatorray.candybox.common.Part; +import me.predatorray.candybox.common.SegmentRef; +import me.predatorray.candybox.common.checksum.Crc32c; +import me.predatorray.candybox.common.config.LedgerRole; +import me.predatorray.candybox.common.exception.StorageException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * Failure-mode coverage for {@link SyrupReader}: the integrity checks (chunk too short, per-part length + * and CRC mismatch), invalid range bounds, and the downstream-write {@link IOException} paths. The + * happy paths live in {@link SyrupTest}; this class deliberately hand-builds malformed inputs and a + * failing {@link OutputStream} to drive the error branches. + */ +class SyrupReaderEdgeTest { + + private final InMemoryLedgerStore store = new InMemoryLedgerStore(); + private final SyrupReader reader = new SyrupReader(store); + + @AfterEach + void tearDown() { + store.close(); + } + + /** Appends one well-formed chunk ({@code [4-byte CRC32C][payload]}) and returns its segment. */ + private SegmentRef writeChunk(byte[] payload) { + WritableLedger syrup = store.createLedger(LedgerConfig.forRole(LedgerRole.SYRUP)); + int crc = Crc32c.of(payload); + byte[] entry = new byte[SyrupManager.CHUNK_HEADER_BYTES + payload.length]; + entry[0] = (byte) (crc >>> 24); + entry[1] = (byte) (crc >>> 16); + entry[2] = (byte) (crc >>> 8); + entry[3] = (byte) crc; + System.arraycopy(payload, 0, entry, SyrupManager.CHUNK_HEADER_BYTES, payload.length); + long id = syrup.append(entry); + syrup.close(); + return new SegmentRef(syrup.ledgerId(), id, id); + } + + /** An OutputStream that throws on the first write — models a broken client connection. */ + private static OutputStream failingStream() { + return new OutputStream() { + @Override + public void write(int b) throws IOException { + throw new IOException("broken pipe"); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + throw new IOException("broken pipe"); + } + }; + } + + @Test + void readRangeRejectsInvalidBounds() { + List parts = List.of(new Part(3, 16, Crc32c.of("abc".getBytes()), + List.of(writeChunk("abc".getBytes())))); + assertThatThrownBy(() -> reader.readRange(parts, -1, 2, new ByteArrayOutputStream())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> reader.readRange(parts, 5, 3, new ByteArrayOutputStream())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void readToWrapsDownstreamIoErrorAsStorageException() { + List segments = List.of(writeChunk("abc".getBytes())); + assertThatThrownBy(() -> reader.readTo(segments, failingStream())) + .isInstanceOf(StorageException.class) + .hasMessageContaining("Failed writing"); + } + + @Test + void readPartsWrapsDownstreamIoErrorAsStorageException() { + byte[] payload = "abc".getBytes(); + List parts = List.of(new Part(payload.length, 16, Crc32c.of(payload), + List.of(writeChunk(payload)))); + assertThatThrownBy(() -> reader.readParts(parts, failingStream())) + .isInstanceOf(StorageException.class) + .hasMessageContaining("Failed writing"); + } + + @Test + void readRangeWrapsDownstreamIoErrorAsStorageException() { + byte[] payload = "abcdef".getBytes(); + List parts = List.of(new Part(payload.length, 16, Crc32c.of(payload), + List.of(writeChunk(payload)))); + assertThatThrownBy(() -> reader.readRange(parts, 0, 5, failingStream())) + .isInstanceOf(StorageException.class) + .hasMessageContaining("Failed writing"); + } + + @Test + void readPartsDetectsPartLengthMismatch() { + byte[] payload = "abc".getBytes(); + // Declared length (10) overstates the bytes actually present (3). + List parts = List.of(new Part(10, 16, Crc32c.of(payload), + List.of(writeChunk(payload)))); + assertThatThrownBy(() -> reader.readParts(parts, new ByteArrayOutputStream())) + .isInstanceOf(StorageException.class) + .hasMessageContaining("Part length mismatch"); + } + + @Test + void readPartsDetectsPartCrcMismatch() { + byte[] payload = "abc".getBytes(); + // Length matches, but the declared part CRC does not. + List parts = List.of(new Part(payload.length, 16, Crc32c.of(payload) ^ 0x1, + List.of(writeChunk(payload)))); + assertThatThrownBy(() -> reader.readParts(parts, new ByteArrayOutputStream())) + .isInstanceOf(StorageException.class) + .hasMessageContaining("Part CRC mismatch"); + } + + @Test + void readRangeDetectsUnderProducingPart() { + byte[] payload = "abc".getBytes(); + // Part claims 10 bytes but only 3 exist, so the slice under-produces vs. the requested window. + List parts = List.of(new Part(10, 16, Crc32c.of(payload), + List.of(writeChunk(payload)))); + assertThatThrownBy(() -> reader.readRange(parts, 0, 9, new ByteArrayOutputStream())) + .isInstanceOf(StorageException.class) + .hasMessageContaining("Range read produced"); + } + + @Test + void detectsChunkShorterThanHeader() { + // A chunk whose bytes are fewer than the 4-byte CRC header is structurally corrupt. + WritableLedger syrup = store.createLedger(LedgerConfig.forRole(LedgerRole.SYRUP)); + long id = syrup.append(new byte[]{1, 2}); // 2 < CHUNK_HEADER_BYTES + syrup.close(); + List segments = List.of(new SegmentRef(syrup.ledgerId(), id, id)); + assertThatThrownBy(() -> reader.readTo(segments, new ByteArrayOutputStream())) + .isInstanceOf(StorageException.class) + .hasMessageContaining("too short"); + } + + @Test + void readAllSizesBufferForUnknownLength() { + // expectedLength <= 0 exercises the default-capacity branch of readAll. + SegmentRef seg = writeChunk("hello".getBytes()); + assertThat(reader.readAll(List.of(seg), 0)).isEqualTo("hello".getBytes()); + } +} diff --git a/candybox-server/src/test/java/me/predatorray/candybox/server/NodeRequestHandlerTest.java b/candybox-server/src/test/java/me/predatorray/candybox/server/NodeRequestHandlerTest.java index 3bba95a..803a7ab 100644 --- a/candybox-server/src/test/java/me/predatorray/candybox/server/NodeRequestHandlerTest.java +++ b/candybox-server/src/test/java/me/predatorray/candybox/server/NodeRequestHandlerTest.java @@ -304,4 +304,125 @@ void multipartDispatchIncludingListings() { .isInstanceOf(Message.OkResponse.class); } } + + @Test + void listMultipartUploadsHonoursPrefixMarkersAndPaging() { + try (CandyboxNode node = new CandyboxNode(1, config(), new InMemoryLedgerStore(), + new InMemoryCoordinationService(), new ManualClock(1000))) { + RequestHandler handler = node.requestHandler(); + roundTrip(handler, new Message.CreateBoxRequest("mpu-box", 1)); + // Uploads on three keys, plus two on the same key to exercise the uploadId tie-break. + roundTrip(handler, new Message.CreateMultipartUploadRequest("mpu-box", "p/a", null, Map.of())); + roundTrip(handler, new Message.CreateMultipartUploadRequest("mpu-box", "p/b", null, Map.of())); + roundTrip(handler, new Message.CreateMultipartUploadRequest("mpu-box", "q/a", null, Map.of())); + + // Prefix narrows to the "p/" keys only. + Message prefixed = roundTrip(handler, new Message.ListMultipartUploadsRequest( + "mpu-box", 0, "p/", null, null, 100)); + assertThat(((Message.ListMultipartUploadsResponse) prefixed).uploads()) + .extracting(Message.InProgressUpload::key).containsExactlyInAnyOrder("p/a", "p/b"); + + // keyMarker drops uploads whose key sorts before it. + Message afterMarker = roundTrip(handler, new Message.ListMultipartUploadsRequest( + "mpu-box", 0, null, "p/b", null, 100)); + assertThat(((Message.ListMultipartUploadsResponse) afterMarker).uploads()) + .extracting(Message.InProgressUpload::key).doesNotContain("p/a"); + + // maxUploads forces truncation and a continuation marker. + Message page = roundTrip(handler, new Message.ListMultipartUploadsRequest( + "mpu-box", 0, null, null, null, 1)); + Message.ListMultipartUploadsResponse paged = (Message.ListMultipartUploadsResponse) page; + assertThat(paged.uploads()).hasSize(1); + assertThat(paged.nextKeyMarker()).isNotNull(); + assertThat(paged.nextUploadIdMarker()).isNotNull(); + } + } + + @Test + void listPartsHonoursMarkerAndPaging() { + try (CandyboxNode node = new CandyboxNode(1, config(), new InMemoryLedgerStore(), + new InMemoryCoordinationService(), new ManualClock(1000))) { + RequestHandler handler = node.requestHandler(); + roundTrip(handler, new Message.CreateBoxRequest("lp-box", 1)); + Message created = roundTrip(handler, new Message.CreateMultipartUploadRequest( + "lp-box", "obj", null, Map.of())); + String uploadId = ((Message.CreateMultipartUploadResponse) created).uploadId(); + for (int pn = 1; pn <= 3; pn++) { + roundTrip(handler, new Message.UploadPartRequest("lp-box", "obj", uploadId, pn, + bytes("part" + pn))); + } + // partNumberMarker skips part 1; maxParts=1 truncates and yields a next cursor. + Message page = roundTrip(handler, + new Message.ListPartsRequest("lp-box", "obj", uploadId, 1, 1)); + Message.ListPartsResponse parts = (Message.ListPartsResponse) page; + assertThat(parts.parts()).extracting(Message.UploadedPart::partNumber).containsExactly(2); + assertThat(parts.nextPartNumberMarker()).isEqualTo(2); + + // ListParts on an unknown upload is NotFound. + assertThat(roundTrip(handler, new Message.ListPartsRequest("lp-box", "obj", "no-upload", 0, 10))) + .isInstanceOf(Message.NotFoundResponse.class); + } + } + + @Test + void objectAclGetSetRoundTripsAndRejectsMalformedGrants() { + try (CandyboxNode node = new CandyboxNode(1, config(), new InMemoryLedgerStore(), + new InMemoryCoordinationService(), new ManualClock(1000))) { + RequestHandler handler = node.requestHandler(); + roundTrip(handler, new Message.CreateBoxRequest("acl-box", 1)); + roundTrip(handler, new Message.PutCandyRequest("acl-box", "k", null, Map.of(), null, + bytes("v"))); + + // Set a valid owner + grant, then read it back. + assertThat(roundTrip(handler, new Message.SetCandyAclRequest("acl-box", "k", "User:alice", + List.of("AllUsers:READ")))).isInstanceOf(Message.OkResponse.class); + Message acl = roundTrip(handler, new Message.GetCandyAclRequest("acl-box", "k")); + Message.CandyAclResponse aclResp = (Message.CandyAclResponse) acl; + assertThat(aclResp.owner()).isEqualTo("User:alice"); + assertThat(aclResp.grants()).isNotEmpty(); + + // A malformed grant string is a validation error, not a crash. + assertThat(roundTrip(handler, new Message.SetCandyAclRequest("acl-box", "k", "User:alice", + List.of("this-is-not-a-grant")))).isInstanceOf(Message.ErrorResponse.class); + } + } + + @Test + void putWithMalformedGrantIsAValidationError() { + try (CandyboxNode node = new CandyboxNode(1, config(), new InMemoryLedgerStore(), + new InMemoryCoordinationService(), new ManualClock(1000))) { + RequestHandler handler = node.requestHandler(); + roundTrip(handler, new Message.CreateBoxRequest("put-acl-box", 1)); + // The 8-arg PutCandyRequest carries request grants; a malformed one trips effectiveAcl. + Message response = roundTrip(handler, new Message.PutCandyRequest("put-acl-box", "k", null, + Map.of(), null, bytes("v"), null, List.of("bogus-grant"))); + assertThat(response).isInstanceOf(Message.ErrorResponse.class); + } + } + + @Test + void uploadPartCopySamePartitionCopiesAByteRange() { + try (CandyboxNode node = new CandyboxNode(1, config(), new InMemoryLedgerStore(), + new InMemoryCoordinationService(), new ManualClock(1000))) { + RequestHandler handler = node.requestHandler(); + roundTrip(handler, new Message.CreateBoxRequest("upc-box", 1)); // single partition + roundTrip(handler, new Message.PutCandyRequest("upc-box", "src", null, Map.of(), null, + bytes("hello candybox"))); + Message created = roundTrip(handler, new Message.CreateMultipartUploadRequest( + "upc-box", "dst", null, Map.of())); + String uploadId = ((Message.CreateMultipartUploadResponse) created).uploadId(); + + Message copied = roundTrip(handler, new Message.UploadPartCopyRequest( + "upc-box", "dst", uploadId, 1, "src", 6, 13)); // "candybox" + Message.UploadPartResponse part = (Message.UploadPartResponse) copied; + assertThat(part.partLength()).isEqualTo(8); + + Message done = roundTrip(handler, new Message.CompleteMultipartUploadRequest("upc-box", "dst", + uploadId, List.of(new Message.CompletedPart(1, part.crc32c())), null)); + assertThat(done).isInstanceOf(Message.HeadCandyResponse.class); + Message get = roundTrip(handler, new Message.GetCandyRequest("upc-box", "dst")); + assertThat(new String(((Message.CandyDataResponse) get).data(), StandardCharsets.UTF_8)) + .isEqualTo("candybox"); + } + } }