Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ java {

repositories {
mavenCentral()
mavenLocal()
}

jacoco {
Expand All @@ -40,13 +41,15 @@ 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 {
useJUnitPlatform()
finalizedBy jacocoTestReport // report is always generated after tests run
}


jacocoTestReport {
dependsOn test
reports {
Expand Down Expand Up @@ -96,3 +99,4 @@ tasks.register('specsValidation', Test) {

include '**/ConformanceTest.class'
}

Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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;
}
}
108 changes: 108 additions & 0 deletions src/test/java/dev/toonformat/jtoon/JToonConcurrencyTest.java
Original file line number Diff line number Diff line change
@@ -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<Throwable> errors = Collections.synchronizedList(new ArrayList<>());

Runnable task = () -> {
try {
// Given
final Map<String, Object> 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<Throwable> 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);
}

}
60 changes: 60 additions & 0 deletions src/test/java/dev/toonformat/jtoon/JToonFuzzTest.java
Original file line number Diff line number Diff line change
@@ -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
}
}
);
}
}


}
93 changes: 93 additions & 0 deletions src/test/java/dev/toonformat/jtoon/JToonRaceConditionTest.java
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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<String, Object> metadata = new LinkedHashMap<>();
metadata.put("author", "dev");
metadata.put("stars", 100);
metadata.put("created", java.time.LocalDateTime.now());
input.put("metadata", metadata);

List<Future<Void>> 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<String, Object> decodedMap = (Map<String, Object>) decoded;
assertEquals(input.get("name"), decodedMap.get("name"));
assertEquals(input.get("version"), decodedMap.get("version"));
assertEquals(input.get("active"), decodedMap.get("active"));

Map<String, Object> decodedMetadata = (Map<String, Object>) decodedMap.get("metadata");
assertEquals("dev", decodedMetadata.get("author"));
assertEquals(100L, ((Number) decodedMetadata.get("stars")).longValue());

return null;
}));
}

for (Future<Void> 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<Future<Void>> futures = new ArrayList<>();

for (int i = 0; i < iterations; i++) {
final int index = i;
futures.add(executor.submit(() -> {
Map<String, Object> obj = Map.of("key", "value" + index);
String encoded = JToon.encode(obj);
Map<String, Object> decoded = (Map<String, Object>) JToon.decode(encoded);
assertEquals("value" + index, decoded.get("key"));
return null;
}));
}

for (Future<Void> future : futures) {
future.get();
}

executor.shutdown();
}
}
10 changes: 8 additions & 2 deletions src/test/java/dev/toonformat/jtoon/TestPojos.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> properties;

public FullEmployee(AnnotatedEmployee employee, Map<String, String> properties) {
Expand All @@ -155,6 +155,10 @@ public FullEmployee(AnnotatedEmployee employee, Map<String, String> properties)
public Map<String, String> getProperties() {
return properties;
}

public AnnotatedEmployee employee() {
return employee;
}
}

/**
Expand All @@ -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
Expand Down
Loading
Loading