diff --git a/build.gradle b/build.gradle index b74082a..3b25204 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,7 @@ java { repositories { mavenCentral() + mavenLocal() } jacoco { @@ -40,6 +41,7 @@ dependencies { testImplementation platform('org.junit:junit-bom:6.0.2') testImplementation 'org.junit.jupiter:junit-jupiter' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.awaitility:awaitility:4.2.1' } test { @@ -47,6 +49,7 @@ test { finalizedBy jacocoTestReport // report is always generated after tests run } + jacocoTestReport { dependsOn test reports { @@ -96,3 +99,4 @@ tasks.register('specsValidation', Test) { include '**/ConformanceTest.class' } + diff --git a/src/main/java/dev/toonformat/jtoon/util/ObjectMapperSingleton.java b/src/main/java/dev/toonformat/jtoon/util/ObjectMapperSingleton.java index a5326e8..8d24086 100644 --- a/src/main/java/dev/toonformat/jtoon/util/ObjectMapperSingleton.java +++ b/src/main/java/dev/toonformat/jtoon/util/ObjectMapperSingleton.java @@ -15,7 +15,7 @@ public final class ObjectMapperSingleton { /** * Holds the singleton ObjectMapper. */ - private static ObjectMapper INSTANCE; + private static volatile ObjectMapper INSTANCE; private ObjectMapperSingleton() { throw new UnsupportedOperationException("Utility class cannot be instantiated"); @@ -27,14 +27,20 @@ private ObjectMapperSingleton() { * @return ObjectMapper */ public static ObjectMapper getInstance() { - if (INSTANCE == null) { - INSTANCE = JsonMapper.builder() - .changeDefaultPropertyInclusion(incl -> incl.withValueInclusion(JsonInclude.Include.ALWAYS)) - .addModule(new AfterburnerModule()) // Speeds up Jackson by 20–40% in most real-world cases - .defaultTimeZone(TimeZone.getTimeZone("UTC")) // set a default timezone for dates - .disable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY) - .build(); + ObjectMapper result = INSTANCE; + if (result == null) { + synchronized (ObjectMapperSingleton.class) { + result = INSTANCE; + if (result == null) { + INSTANCE = result = JsonMapper.builder() + .changeDefaultPropertyInclusion(incl -> incl.withValueInclusion(JsonInclude.Include.ALWAYS)) + .addModule(new AfterburnerModule()) // Speeds up Jackson by 20–40% in most real-world cases + .defaultTimeZone(TimeZone.getTimeZone("UTC")) // set a default timezone for dates + .disable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY) + .build(); + } + } } - return INSTANCE; + return result; } } diff --git a/src/test/java/dev/toonformat/jtoon/JToonConcurrencyTest.java b/src/test/java/dev/toonformat/jtoon/JToonConcurrencyTest.java new file mode 100644 index 0000000..f25c1b1 --- /dev/null +++ b/src/test/java/dev/toonformat/jtoon/JToonConcurrencyTest.java @@ -0,0 +1,108 @@ +package dev.toonformat.jtoon; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.*; + +class JToonConcurrencyTest { + + @Test + void encodeDecodeStressTest() { + int threads = 8; + int tasksPerThread = 5_000; + + final ExecutorService executor = Executors.newFixedThreadPool(threads); + final CountDownLatch latch = new CountDownLatch(threads * tasksPerThread); + final List errors = Collections.synchronizedList(new ArrayList<>()); + + Runnable task = () -> { + try { + // Given + final Map data = new LinkedHashMap<>(); + data.put("id", ThreadLocalRandom.current().nextInt()); + data.put("name", "Alice"); + data.put("tags", List.of("x", "y", "z")); + + // When + final String toon = JToon.encode(data); + + // Then + assertNotNull(toon); + final Object decoded = JToon.decode(toon); + assertNotNull(decoded); + + } catch (Throwable ex) { + errors.add(ex); + } finally { + latch.countDown(); + } + }; + + for (int i = 0; i < threads * tasksPerThread; i++) { + executor.submit(task); + } + + await() + .atMost(10, SECONDS) + .until(() -> latch.getCount() == 0); + + executor.shutdown(); + + assertTrue(errors.isEmpty(), "Errors occurred in threads: " + errors); + } + + void encodeDecodeJSONStressTest() { + int threads = 8; + int tasksPerThread = 5_000; + + final ExecutorService executor = Executors.newFixedThreadPool(threads); + final CountDownLatch latch = new CountDownLatch(threads * tasksPerThread); + final List errors = Collections.synchronizedList(new ArrayList<>()); + + Runnable task = () -> { + try { + // Given + String json = "{\"foo\":123, \"bar\":[\"a\",\"b\"]}"; + + // When + String toon = JToon.encodeJson(json); + + // Then + assertNotNull(toon); + String roundTrip = JToon.decodeToJson(toon); + assertNotNull(roundTrip); + assertTrue(roundTrip.contains("\"foo\":123")); + + } catch (Throwable ex) { + errors.add(ex); + } finally { + latch.countDown(); + } + }; + + for (int i = 0; i < threads * tasksPerThread; i++) { + executor.submit(task); + } + + await() + .atMost(10, SECONDS) + .until(() -> latch.getCount() == 0); + + executor.shutdown(); + + assertTrue(errors.isEmpty(), "Errors occurred in threads: " + errors); + } + +} diff --git a/src/test/java/dev/toonformat/jtoon/JToonFuzzTest.java b/src/test/java/dev/toonformat/jtoon/JToonFuzzTest.java new file mode 100644 index 0000000..ac5ddd9 --- /dev/null +++ b/src/test/java/dev/toonformat/jtoon/JToonFuzzTest.java @@ -0,0 +1,60 @@ +package dev.toonformat.jtoon; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.Arrays; +import java.util.SplittableRandom; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; + +class JToonFuzzTest { + + private static final SplittableRandom RANDOM = new SplittableRandom(); + + @Test + @Tag("fuzz") + void fuzzUnicodeInput() { + final String[] evil = { + "\u0000", // null char + "\uD800", // broken surrogate + "\uFFFF", + "\u2028", // line separator + "💣", // emoji + "漢字" + }; + assertDoesNotThrow(() -> Arrays.stream(evil).forEach(s -> { + try { + JToon.decode("{\"x\":\"" + s + "\"}"); + } catch (RuntimeException e) { + // acceptable + } + })); + } + + + @Test + @Tag("fuzz") + void fuzzDoesNotHang() { + for (int i = 0; i < 1_000; i++) { + byte[] bytes = new byte[RANDOM.nextInt(500)]; + RANDOM.nextBytes(bytes); + String input = new String(bytes); + + assertTimeoutPreemptively( + Duration.ofMillis(100), + () -> { + try { + JToon.decode(input); + } catch (RuntimeException e) { + // expected + } + } + ); + } + } + + +} diff --git a/src/test/java/dev/toonformat/jtoon/JToonRaceConditionTest.java b/src/test/java/dev/toonformat/jtoon/JToonRaceConditionTest.java new file mode 100644 index 0000000..926bd25 --- /dev/null +++ b/src/test/java/dev/toonformat/jtoon/JToonRaceConditionTest.java @@ -0,0 +1,93 @@ +package dev.toonformat.jtoon; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.*; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class JToonRaceConditionTest { + + @Test + @DisplayName("Should be thread-safe when encoding and decoding concurrently") + void concurrentEncodeDecode() throws InterruptedException, ExecutionException { + int threadCount = 20; + int iterationsPerThread = 100; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + + Map input = new LinkedHashMap<>(); + input.put("name", "JToon"); + input.put("version", "1.0.0"); + input.put("tags", List.of("java", "json", "toon")); + input.put("active", true); + + Map metadata = new LinkedHashMap<>(); + metadata.put("author", "dev"); + metadata.put("stars", 100); + metadata.put("created", java.time.LocalDateTime.now()); + input.put("metadata", metadata); + + List> futures = new ArrayList<>(); + + for (int i = 0; i < threadCount * iterationsPerThread; i++) { + futures.add(executor.submit(() -> { + String encoded = JToon.encode(input); + Object decoded = JToon.decode(encoded); + + // When decoding, LocalDateTime becomes a String + // We use toString check for other fields and manual check for metadata + Map decodedMap = (Map) decoded; + assertEquals(input.get("name"), decodedMap.get("name")); + assertEquals(input.get("version"), decodedMap.get("version")); + assertEquals(input.get("active"), decodedMap.get("active")); + + Map decodedMetadata = (Map) decodedMap.get("metadata"); + assertEquals("dev", decodedMetadata.get("author")); + assertEquals(100L, ((Number) decodedMetadata.get("stars")).longValue()); + + return null; + })); + } + + for (Future future : futures) { + future.get(); + } + + executor.shutdown(); + if (!executor.awaitTermination(10, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } + + @Test + @DisplayName("Should handle different objects concurrently without interference") + void concurrentDifferentObjects() throws InterruptedException, ExecutionException { + int threadCount = 10; + int iterations = 1000; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + + List> futures = new ArrayList<>(); + + for (int i = 0; i < iterations; i++) { + final int index = i; + futures.add(executor.submit(() -> { + Map obj = Map.of("key", "value" + index); + String encoded = JToon.encode(obj); + Map decoded = (Map) JToon.decode(encoded); + assertEquals("value" + index, decoded.get("key")); + return null; + })); + } + + for (Future future : futures) { + future.get(); + } + + executor.shutdown(); + } +} diff --git a/src/test/java/dev/toonformat/jtoon/TestPojos.java b/src/test/java/dev/toonformat/jtoon/TestPojos.java index 814112a..ac76d32 100644 --- a/src/test/java/dev/toonformat/jtoon/TestPojos.java +++ b/src/test/java/dev/toonformat/jtoon/TestPojos.java @@ -143,7 +143,7 @@ public record OrderEmployee(String name, int id, Address address) { * Class with Jackson Annotations */ public static class FullEmployee { - public AnnotatedEmployee employee; + public final AnnotatedEmployee employee; private final Map properties; public FullEmployee(AnnotatedEmployee employee, Map properties) { @@ -155,6 +155,10 @@ public FullEmployee(AnnotatedEmployee employee, Map properties) public Map getProperties() { return properties; } + + public AnnotatedEmployee employee() { + return employee; + } } /** @@ -169,7 +173,9 @@ public record HotelInfoLlmRerankDTO(String no, String hotelAddressDistance) { } - public record UserDTO(Integer id, String firstName, String lastName, java.sql.Date lastLogin) {} + public record UserDTO(Integer id, String firstName, String lastName, java.sql.Date lastLogin) { + + } /** * Custom Serializer for HotelInfoLlmRerankDTO diff --git a/src/test/java/dev/toonformat/jtoon/decoder/DecodeContextRaceConditionTest.java b/src/test/java/dev/toonformat/jtoon/decoder/DecodeContextRaceConditionTest.java new file mode 100644 index 0000000..c86aa32 --- /dev/null +++ b/src/test/java/dev/toonformat/jtoon/decoder/DecodeContextRaceConditionTest.java @@ -0,0 +1,87 @@ +package dev.toonformat.jtoon.decoder; + +import dev.toonformat.jtoon.JToon; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class DecodeContextRaceConditionTest { + + @Test + @DisplayName("Should be thread-safe when decoding multiple inputs concurrently") + void concurrentDecoding() throws InterruptedException, ExecutionException { + int threadCount = 20; + int iterationsPerThread = 100; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + + String toonInput = new StringBuilder() + .append("name: JToon\n") + .append("version: 1.0.0\n") + .append("tags[3]:\n") + .append(" - java\n") + .append(" - json\n") + .append(" - toon\n") + .append("metadata:\n") + .append(" author: dev\n") + .append(" active: true") + .toString(); + + final List> futures = new ArrayList<>(); + + for (int i = 0; i < threadCount * iterationsPerThread; i++) { + futures.add(executor.submit(() -> JToon.decode(toonInput))); + } + + for (Future future : futures) { + Object result = future.get(); + if (!(result instanceof Map)) { + fail("Result should be a Map"); + } + Map map = (Map) result; + assertEquals("JToon", map.get("name")); + assertEquals("1.0.0", map.get("version")); + assertEquals(true, ((Map) map.get("metadata")).get("active")); + } + + executor.shutdown(); + if (!executor.awaitTermination(10, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } + + @Test + @DisplayName("Should handle different inputs concurrently without interference") + void concurrentDifferentInputs() throws InterruptedException, ExecutionException { + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + + final List> futures = new ArrayList<>(); + + for (int i = 0; i < 1000; i++) { + final int index = i; + futures.add(executor.submit(() -> { + String input = "key: value" + index; + Map result = (Map) JToon.decode(input); + assertEquals("value" + index, result.get("key")); + return null; + })); + } + + for (Future future : futures) { + future.get(); + } + + executor.shutdown(); + } +} diff --git a/src/test/java/dev/toonformat/jtoon/decoder/KeyDecoderTest.java b/src/test/java/dev/toonformat/jtoon/decoder/KeyDecoderTest.java index 5c231ed..09c03df 100644 --- a/src/test/java/dev/toonformat/jtoon/decoder/KeyDecoderTest.java +++ b/src/test/java/dev/toonformat/jtoon/decoder/KeyDecoderTest.java @@ -156,6 +156,66 @@ void testCallsExpandPathIntoMapWhenShouldExpandKeyTrue() { // Then Map expectedNestedMap = new LinkedHashMap<>(); expectedNestedMap.put("bar", expectedArray); + assertNull(result.get("bar")); + assertEquals(result.size(), expectedNestedMap.size()); + } + + @Test + @DisplayName("Given basic keyed array line When processed Then value is placed in map") + void processKeyedArrayLine_givenBasicKeyedArray_whenProcessed_thenValueInMap() { + // Given + Map result = new LinkedHashMap<>(); + String content = "tags[3]: a, b, c"; + String originalKey = "tags"; + DecodeContext context = new DecodeContext(); + context.options = new DecodeOptions(2, Delimiter.COMMA, true, PathExpansion.OFF); + context.delimiter = Delimiter.COMMA; + + // When + KeyDecoder.processKeyedArrayLine(result, content, originalKey, 0, context); + + // Then + List expected = Arrays.asList("a", "b", "c"); + assertEquals(expected, result.get("tags")); + } + + @Test + @DisplayName("Given dotted keyed array line and SAFE expansion When processed Then value is placed in nested map") + void processKeyedArrayLine_givenDottedKeyedArray_whenProcessed_thenNestedMapContainsValue() { + // Given + Map result = new LinkedHashMap<>(); + String content = "user.tags[2]: dev, test"; + String originalKey = "user.tags"; + DecodeContext context = new DecodeContext(); + context.options = new DecodeOptions(2, Delimiter.COMMA, true, PathExpansion.SAFE); + context.delimiter = Delimiter.COMMA; + + // When + KeyDecoder.processKeyedArrayLine(result, content, originalKey, 0, context); + + // Then + assertTrue(result.containsKey("user")); + @SuppressWarnings("unchecked") Map user = (Map) result.get("user"); + List expected = Arrays.asList("dev", "test"); + assertEquals(expected, user.get("tags")); + } + + @Test + @DisplayName("Given dotted keyed array line and expansion conflict in strict mode When processed Then throws exception") + void processKeyedArrayLine_givenExpansionConflictStrict_whenProcessed_thenThrowsException() { + // Given + Map result = new LinkedHashMap<>(); + result.put("user", "not-a-map"); + String content = "user.tags[1]: dev"; + String originalKey = "user.tags"; + DecodeContext context = new DecodeContext(); + context.options = new DecodeOptions(2, Delimiter.COMMA, true, PathExpansion.SAFE); + context.delimiter = Delimiter.COMMA; + + // When / Then + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> KeyDecoder.processKeyedArrayLine(result, content, originalKey, 0, context)); + assertTrue(ex.getMessage().contains("Path expansion conflict")); } @Test diff --git a/src/test/java/dev/toonformat/jtoon/decoder/ValueDecoderThreadSafetyTest.java b/src/test/java/dev/toonformat/jtoon/decoder/ValueDecoderThreadSafetyTest.java new file mode 100644 index 0000000..ef4c902 --- /dev/null +++ b/src/test/java/dev/toonformat/jtoon/decoder/ValueDecoderThreadSafetyTest.java @@ -0,0 +1,43 @@ +package dev.toonformat.jtoon.decoder; + +import dev.toonformat.jtoon.DecodeOptions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +@Execution(ExecutionMode.CONCURRENT) +class ValueDecoderThreadSafetyTest { + + @RepeatedTest(100) + @DisplayName("ValueDecoder should be thread-safe when decoding TOON strings") + void decodeThreadSafety() { + // Given + String id = UUID.randomUUID().toString(); + String toon = "id: " + id + "\n" + + "tags[3]: a, b, c\n" + + "meta:\n" + + " active: true\n" + + " score: 42"; + + // When + Object decoded = ValueDecoder.decode(toon, DecodeOptions.DEFAULT); + + // Then + assertInstanceOf(Map.class, decoded); + Map map = (Map) decoded; + assertEquals(id, map.get("id")); + assertEquals(List.of("a", "b", "c"), map.get("tags")); + + Map meta = (Map) map.get("meta"); + assertEquals(true, meta.get("active")); + assertEquals(42L, meta.get("score")); + } +} diff --git a/src/test/java/dev/toonformat/jtoon/encoder/ListItemEncoderTest.java b/src/test/java/dev/toonformat/jtoon/encoder/ListItemEncoderTest.java index 8a962d2..00f7586 100644 --- a/src/test/java/dev/toonformat/jtoon/encoder/ListItemEncoderTest.java +++ b/src/test/java/dev/toonformat/jtoon/encoder/ListItemEncoderTest.java @@ -128,7 +128,6 @@ void usesTabularFormatForNestedUniformObjectArrays() { EncodeOptions options = EncodeOptions.DEFAULT; LineWriter writer = new LineWriter(options.indent()); - Set rootKeys = new HashSet<>(); // When ArrayEncoder.encodeArray("items",node, writer, 0, options); @@ -153,7 +152,6 @@ void usesListFormatForNestedObjectArraysWithMismatchedKeys() { EncodeOptions options = EncodeOptions.DEFAULT; LineWriter writer = new LineWriter(options.indent()); - Set rootKeys = new HashSet<>(); // When ArrayEncoder.encodeArray("items", node, writer, 0, options); diff --git a/src/test/java/dev/toonformat/jtoon/normalizer/JsonNormalizerTest.java b/src/test/java/dev/toonformat/jtoon/normalizer/JsonNormalizerTest.java index ffb64c3..376cd25 100644 --- a/src/test/java/dev/toonformat/jtoon/normalizer/JsonNormalizerTest.java +++ b/src/test/java/dev/toonformat/jtoon/normalizer/JsonNormalizerTest.java @@ -1075,14 +1075,7 @@ void testStreamWithNulls() { @DisplayName("POJOs") class POJOs { - static class SimplePojo { - public String name; - public int age; - - SimplePojo(String name, int age) { - this.name = name; - this.age = age; - } + record SimplePojo(String name, int age) { } record PojoWithGetters(String value) { diff --git a/src/test/java/dev/toonformat/jtoon/normalizer/JsonNormalizerThreadSafetyTest.java b/src/test/java/dev/toonformat/jtoon/normalizer/JsonNormalizerThreadSafetyTest.java new file mode 100644 index 0000000..db9fa9c --- /dev/null +++ b/src/test/java/dev/toonformat/jtoon/normalizer/JsonNormalizerThreadSafetyTest.java @@ -0,0 +1,56 @@ +package dev.toonformat.jtoon.normalizer; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import tools.jackson.databind.JsonNode; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@Execution(ExecutionMode.CONCURRENT) +class JsonNormalizerThreadSafetyTest { + + @RepeatedTest(100) + @DisplayName("JsonNormalizer should be thread-safe when normalizing complex objects") + void normalizeThreadSafety() { + // Given + String id = UUID.randomUUID().toString(); + Map input = Map.of( + "id", id, + "timestamp", LocalDateTime.now(), + "tags", List.of("a", "b", "c"), + "nested", Map.of("key", "value") + ); + + // When + JsonNode normalized = JsonNormalizer.normalize(input); + + // Then + assertNotNull(normalized); + assertTrue(normalized.isObject()); + assertTrue(normalized.has("id")); + assertEquals(normalized.get("id").asString(), id); + } + + @RepeatedTest(100) + @DisplayName("JsonNormalizer should be thread-safe when parsing JSON strings") + void parseThreadSafety() { + // Given + String id = UUID.randomUUID().toString(); + String json = "{\"id\":\"" + id + "\",\"active\":true}"; + + // When + JsonNode parsed = JsonNormalizer.parse(json); + + // Then + assertNotNull(parsed); + assertTrue(parsed.isObject()); + assertEquals(parsed.get("id").asString(), id); + } +}