diff --git a/src/jvm/build.gradle.kts b/src/jvm/build.gradle.kts index e8203b5..e70d4b1 100644 --- a/src/jvm/build.gradle.kts +++ b/src/jvm/build.gradle.kts @@ -3,9 +3,11 @@ import com.vanniktech.maven.publish.JavadocJar import com.vanniktech.maven.publish.SourcesJar import me.champeau.gradle.japicmp.JapicmpTask import net.ltgt.gradle.errorprone.errorprone +import org.gradle.testing.jacoco.tasks.JacocoReport plugins { `java-library` + jacoco alias(libs.plugins.maven.publish) alias(libs.plugins.japicmp) alias(libs.plugins.spotless) @@ -39,10 +41,27 @@ val testJdk = providers.gradleProperty("testJdk").map(String::toInt).getOrElse(2 tasks.withType().configureEach { useJUnitPlatform() javaLauncher.set(javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(testJdk) }) + finalizedBy(tasks.named("jacocoTestReport")) } +tasks.named("jacocoTestReport") { + dependsOn(tasks.named("test")) + reports { + xml.required = true + html.required = true + } +} + +tasks.named("check") { dependsOn("jacocoTestReport") } + dependencies { api(libs.jspecify) + implementation(libs.classgraph) + api(libs.jackson.databind) + implementation(libs.jackson.datatype.jsr310) + implementation(libs.approvaltests) + implementation(platform(libs.junit.bom)) + implementation(libs.junit.jupiter) errorprone(libs.errorprone.core) errorprone(libs.errorprone.contrib) errorprone(libs.errorprone.refaster) diff --git a/src/jvm/gradle/libs.versions.toml b/src/jvm/gradle/libs.versions.toml index 062ae3c..054c966 100644 --- a/src/jvm/gradle/libs.versions.toml +++ b/src/jvm/gradle/libs.versions.toml @@ -2,6 +2,9 @@ kotlin = "2.3.21" scala = "3.8.3" jspecify = "1.0.0" +jackson = "2.17.0" +classgraph = "4.8.179" +approvaltests = "24.0.0" junit = "5.12.2" spotless = "8.6.0" palantir-java-format = "2.91.0" @@ -14,6 +17,10 @@ nullaway-plugin = "3.0.0" [libraries] jspecify = { module = "org.jspecify:jspecify", version.ref = "jspecify" } +classgraph = { module = "io.github.classgraph:classgraph", version.ref = "classgraph" } +jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } +jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } +approvaltests = { module = "com.approvaltests:approvaltests", version.ref = "approvaltests" } scala-library = { module = "org.scala-lang:scala3-library_3", version.ref = "scala" } scalatest = { module = "org.scalatest:scalatest_3", version = "3.2.19" } junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" } diff --git a/src/jvm/src/main/java/io/eventdriven/strictland/Contract.java b/src/jvm/src/main/java/io/eventdriven/strictland/Contract.java new file mode 100644 index 0000000..4f714c7 --- /dev/null +++ b/src/jvm/src/main/java/io/eventdriven/strictland/Contract.java @@ -0,0 +1,47 @@ +package io.eventdriven.strictland; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +/** + * Entry point for the contract DSL used to verify event schema versioning and serialization compatibility. + */ +public class Contract { + private static final ObjectMapper DEFAULT_MAPPER = new JsonMapper() + .registerModule(new JavaTimeModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + + private final ObjectMapper mapper; + + private Contract(ObjectMapper mapper) { + this.mapper = mapper; + } + + public static Contract specification() { + return new Contract(DEFAULT_MAPPER); + } + + public static Contract specification(ObjectMapper mapper) { + return new Contract(mapper); + } + + public GivenStep given(Snapshot.ByClass snapshot) { + return new GivenStep<>(snapshot, null, mapper); + } + + public GivenStep given(Snapshot.ByMessageType snapshot) { + return new GivenStep<>(snapshot, null, mapper); + } + + public GivenStep given(Snapshot.ByPath snapshot) { + return new GivenStep<>(snapshot, null, mapper); + } + + public GivenStep given(S instance) { + return new GivenStep<>(null, instance, mapper); + } +} diff --git a/src/jvm/src/main/java/io/eventdriven/strictland/GivenStep.java b/src/jvm/src/main/java/io/eventdriven/strictland/GivenStep.java new file mode 100644 index 0000000..bda2f9a --- /dev/null +++ b/src/jvm/src/main/java/io/eventdriven/strictland/GivenStep.java @@ -0,0 +1,41 @@ +package io.eventdriven.strictland; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jspecify.annotations.Nullable; + +/** + * The "given" step of the contract DSL, holding either a snapshot or an instance to work with. + */ +public class GivenStep { + final @Nullable Snapshot snapshot; + final @Nullable S instance; + final ObjectMapper mapper; + + GivenStep(@Nullable Snapshot snapshot, @Nullable S instance, ObjectMapper mapper) { + this.snapshot = snapshot; + this.instance = instance; + this.mapper = mapper; + } + + public ThenContractStep whenSerialized() { + var instance = requireInstance(); + return new ThenContractStep<>(instance, null, mapper); + } + + public ThenContractStep whenSerialized(Snapshot destination) { + var instance = requireInstance(); + return new ThenContractStep<>(instance, destination, mapper); + } + + private S requireInstance() { + if (instance == null) { + throw new IllegalStateException( + "whenSerialized() requires an instance — use Contract.given(instance), not Contract.given(Snapshot)"); + } + return instance; + } + + public ThenCompatibilityStep whenDeserializedAs(Class targetType) { + return new ThenCompatibilityStep<>(snapshot, instance, targetType, mapper); + } +} diff --git a/src/jvm/src/main/java/io/eventdriven/strictland/PublicApiScanner.java b/src/jvm/src/main/java/io/eventdriven/strictland/PublicApiScanner.java new file mode 100644 index 0000000..61f1682 --- /dev/null +++ b/src/jvm/src/main/java/io/eventdriven/strictland/PublicApiScanner.java @@ -0,0 +1,123 @@ +package io.eventdriven.strictland; + +import io.github.classgraph.ClassGraph; +import io.github.classgraph.ClassInfo; +import java.lang.reflect.*; +import java.util.Arrays; +import java.util.Comparator; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +public class PublicApiScanner { + private final String packageName; + private Predicate methodFilter = m -> true; + private boolean excludeConstructors; + + private PublicApiScanner(String packageName) { + this.packageName = packageName; + } + + public static PublicApiScanner forPackage(String packageName) { + return new PublicApiScanner(packageName); + } + + public PublicApiScanner excludingMethods(Predicate filter) { + this.methodFilter = this.methodFilter.and(filter.negate()); + return this; + } + + public PublicApiScanner excludeConstructors() { + this.excludeConstructors = true; + return this; + } + + public PublicApiScanner onlyGettersAndFields() { + return excludeConstructors() + .excludeStandardObjectMethods() + .excludingMethods(m -> m.getParameterCount() > 0 || m.getReturnType() == void.class); + } + + public PublicApiScanner excludeStandardObjectMethods() { + return excludingMethods(m -> (m.getName().equals("equals") && m.getParameterCount() == 1) + || (m.getName().equals("hashCode") && m.getParameterCount() == 0) + || (m.getName().equals("toString") && m.getParameterCount() == 0)); + } + + public String generate() { + try (var scanResult = + new ClassGraph().enableAllInfo().acceptPackages(packageName).scan()) { + + return scanResult.getAllClasses().stream() + .map(ClassInfo::loadClass) + .filter(c -> Modifier.isPublic(c.getModifiers())) + .sorted(Comparator.comparing(Class::getName)) + .map(this::describeType) + .collect(Collectors.joining("\n\n")); + } + } + + private String describeType(Class clazz) { + var sb = new StringBuilder(); + sb.append(typeDeclaration(clazz)).append(" {\n"); + + Arrays.stream(clazz.getDeclaredFields()) + .filter(f -> Modifier.isPublic(f.getModifiers()) && !f.isSynthetic()) + .sorted(Comparator.comparing(Field::getName)) + .forEach(f -> sb.append(" ").append(f.toGenericString()).append(";\n")); + + if (!excludeConstructors) { + Arrays.stream(clazz.getDeclaredConstructors()) + .filter(c -> Modifier.isPublic(c.getModifiers())) + .sorted(Comparator.comparing(Constructor::toGenericString)) + .forEach(c -> sb.append(" ").append(formatConstructor(c)).append(";\n")); + } + + Arrays.stream(clazz.getDeclaredMethods()) + .filter(m -> Modifier.isPublic(m.getModifiers()) && !m.isSynthetic() && !m.isBridge()) + .filter(methodFilter) + .sorted(Comparator.comparing(Method::getName).thenComparing(Method::toGenericString)) + .forEach(m -> sb.append(" ").append(formatMethod(m)).append(";\n")); + + sb.append("}"); + return sb.toString(); + } + + private static String formatConstructor(Constructor c) { + var params = Arrays.stream(c.getGenericParameterTypes()) + .map(Type::getTypeName) + .collect(Collectors.joining(", ")); + return Modifier.toString(c.getModifiers()) + " " + c.getDeclaringClass().getSimpleName() + "(" + params + ")"; + } + + private static String formatMethod(Method m) { + var params = Arrays.stream(m.getGenericParameterTypes()) + .map(Type::getTypeName) + .collect(Collectors.joining(", ")); + return Modifier.toString(m.getModifiers()) + " " + + m.getGenericReturnType().getTypeName() + " " + m.getName() + "(" + params + ")"; + } + + private static String typeDeclaration(Class clazz) { + var parts = new StringBuilder(); + + if (Modifier.isPublic(clazz.getModifiers())) parts.append("public "); + if (Modifier.isStatic(clazz.getModifiers())) parts.append("static "); + if (Modifier.isFinal(clazz.getModifiers()) && !clazz.isEnum()) parts.append("final "); + + if (clazz.isRecord()) parts.append("record"); + else if (clazz.isInterface()) parts.append("interface"); + else if (clazz.isEnum()) parts.append("enum"); + else parts.append("class"); + + parts.append(" ").append(clazz.getName()); + + var interfaces = clazz.getInterfaces(); + if (interfaces.length > 0) { + var keyword = clazz.isInterface() ? " extends " : " implements "; + parts.append(keyword) + .append(Arrays.stream(interfaces).map(Class::getName).collect(Collectors.joining(", "))); + } + + return parts.toString(); + } +} diff --git a/src/jvm/src/main/java/io/eventdriven/strictland/Snapshot.java b/src/jvm/src/main/java/io/eventdriven/strictland/Snapshot.java new file mode 100644 index 0000000..2274c84 --- /dev/null +++ b/src/jvm/src/main/java/io/eventdriven/strictland/Snapshot.java @@ -0,0 +1,36 @@ +package io.eventdriven.strictland; + +import java.nio.file.Path; +import org.jspecify.annotations.Nullable; + +/** + * Identifies the source of an approved snapshot used to verify schema contracts and compatibility. + */ +public sealed interface Snapshot permits Snapshot.ByClass, Snapshot.ByMessageType, Snapshot.ByPath { + + record ByClass(Class sourceType) implements Snapshot {} + + record ByMessageType(String messageType, @Nullable Class sourceType) implements Snapshot {} + + record ByPath(Path path, @Nullable Class sourceType) implements Snapshot {} + + static ByClass of(Class sourceType) { + return new ByClass<>(sourceType); + } + + static ByPath at(Path path) { + return new ByPath(path, null); + } + + static ByPath at(Path path, Class sourceType) { + return new ByPath(path, sourceType); + } + + static ByMessageType forMessageType(String messageType) { + return new ByMessageType(messageType, null); + } + + static ByMessageType forMessageType(String messageType, Class sourceType) { + return new ByMessageType(messageType, sourceType); + } +} diff --git a/src/jvm/src/main/java/io/eventdriven/strictland/SnapshotPathResolver.java b/src/jvm/src/main/java/io/eventdriven/strictland/SnapshotPathResolver.java new file mode 100644 index 0000000..265d204 --- /dev/null +++ b/src/jvm/src/main/java/io/eventdriven/strictland/SnapshotPathResolver.java @@ -0,0 +1,29 @@ +package io.eventdriven.strictland; + +import java.nio.file.Path; +import java.util.Set; + +class SnapshotPathResolver { + private static final Set DSL_CLASSES = Set.of( + Contract.class.getName(), + GivenStep.class.getName(), + ThenContractStep.class.getName(), + ThenCompatibilityStep.class.getName(), + SnapshotPathResolver.class.getName()); + private static final String SOURCE_ROOT = "src/test/java"; + + static Path resolve(String snapshotName) { + var callerClass = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE) + .walk(frames -> frames.filter(f -> !DSL_CLASSES.contains(f.getClassName())) + .filter(f -> !f.getClassName().startsWith("java.")) + .filter(f -> !f.getClassName().startsWith("jdk.")) + .filter(f -> !f.getClassName().startsWith("sun.")) + .filter(f -> !f.getClassName().startsWith("org.junit.")) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Cannot determine calling test class from stack"))) + .getDeclaringClass(); + + var packagePath = callerClass.getPackageName().replace('.', '/'); + return Path.of(SOURCE_ROOT, packagePath, snapshotName + ".approved.txt"); + } +} diff --git a/src/jvm/src/main/java/io/eventdriven/strictland/ThenCompatibilityStep.java b/src/jvm/src/main/java/io/eventdriven/strictland/ThenCompatibilityStep.java new file mode 100644 index 0000000..344ac8e --- /dev/null +++ b/src/jvm/src/main/java/io/eventdriven/strictland/ThenCompatibilityStep.java @@ -0,0 +1,109 @@ +package io.eventdriven.strictland; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.util.HashSet; +import java.util.Map; +import java.util.function.Consumer; +import org.jspecify.annotations.Nullable; + +/** + * The "then" step of the contract DSL that verifies forward and backward compatibility of deserialization. + */ +public class ThenCompatibilityStep { + private final @Nullable Snapshot snapshot; + private final @Nullable S instance; + private final Class targetType; + private final ObjectMapper mapper; + + ThenCompatibilityStep(@Nullable Snapshot snapshot, @Nullable S instance, Class targetType, ObjectMapper mapper) { + this.snapshot = snapshot; + this.instance = instance; + this.targetType = targetType; + this.mapper = mapper; + } + + public void thenForwardCompatible() { + verifySharedFields(t -> {}); + } + + public void thenForwardCompatible(Consumer extra) { + verifySharedFields(extra); + } + + public void thenBackwardCompatible() { + verifySharedFields(t -> {}); + } + + public void thenBackwardCompatible(Consumer extra) { + verifySharedFields(extra); + } + + private void verifySharedFields(Consumer extra) { + var sourceBytes = resolveSourceBytes(); + T deserialized; + try { + deserialized = mapper.readValue(sourceBytes, targetType); + } catch (IOException e) { + throw new RuntimeException("Deserialization as " + targetType.getSimpleName() + " failed", e); + } + if (deserialized == null) { + fail("Deserialization as " + targetType.getSimpleName() + " returned empty"); + return; + } + assertSharedFieldsMatch(sourceBytes, deserialized); + extra.accept(deserialized); + } + + private void assertSharedFieldsMatch(byte[] sourceBytes, T target) { + try { + var sourceMap = toMap(sourceBytes); + var targetMap = toMap(mapper.writeValueAsBytes(target)); + + var sharedKeys = new HashSet<>(sourceMap.keySet()); + sharedKeys.retainAll(targetMap.keySet()); + + for (var key : sharedKeys) { + assertEquals( + sourceMap.get(key), targetMap.get(key), "Field '" + key + "' value differs between versions"); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private Map toMap(byte[] bytes) throws IOException { + return mapper.readValue(bytes, new TypeReference<>() {}); + } + + private byte[] resolveSourceBytes() { + if (instance != null) { + try { + return mapper.writeValueAsBytes(instance); + } catch (IOException e) { + throw new RuntimeException( + "Serialization of " + instance.getClass().getSimpleName() + " failed", e); + } + } + if (snapshot == null) { + throw new IllegalStateException("Either a snapshot or an instance is required"); + } + var path = + switch (snapshot) { + case Snapshot.ByClass s -> + SnapshotPathResolver.resolve(s.sourceType().getSimpleName()); + case Snapshot.ByMessageType s -> SnapshotPathResolver.resolve(s.messageType()); + case Snapshot.ByPath s -> s.path(); + }; + try { + return Files.readAllBytes(path); + } catch (IOException e) { + throw new RuntimeException("Cannot read snapshot file: " + path, e); + } + } +} diff --git a/src/jvm/src/main/java/io/eventdriven/strictland/ThenContractStep.java b/src/jvm/src/main/java/io/eventdriven/strictland/ThenContractStep.java new file mode 100644 index 0000000..e5eafb5 --- /dev/null +++ b/src/jvm/src/main/java/io/eventdriven/strictland/ThenContractStep.java @@ -0,0 +1,87 @@ +package io.eventdriven.strictland; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.File; +import java.nio.file.Path; +import org.approvaltests.Approvals; +import org.approvaltests.core.Options; +import org.approvaltests.namer.ApprovalNamer; +import org.approvaltests.reporters.AutoApproveWhenEmptyReporter; +import org.jspecify.annotations.Nullable; + +/** + * The "then" step of the contract DSL that verifies the serialized shape of an instance is unchanged. + */ +public class ThenContractStep { + private final S instance; + private final @Nullable Snapshot destination; + private final ObjectMapper mapper; + + ThenContractStep(S instance, @Nullable Snapshot destination, ObjectMapper mapper) { + this.instance = instance; + this.destination = destination; + this.mapper = mapper; + } + + public void thenContractIsUnchanged() { + verify(destination != null ? optionsFor(destination) : defaultOptions()); + } + + private Options defaultOptions() { + return new Options().forFile().withBaseName(instance.getClass().getSimpleName()); + } + + private Options optionsFor(Snapshot s) { + return switch (s) { + case Snapshot.ByClass b -> + new Options().forFile().withBaseName(b.sourceType().getSimpleName()); + case Snapshot.ByMessageType b -> new Options().forFile().withBaseName(b.messageType()); + case Snapshot.ByPath b -> new Options().forFile().withNamer(namedAt(b.path())); + }; + } + + private void verify(Options options) { + try { + Approvals.verify( + mapper.writeValueAsString(instance), options.withReporter(new AutoApproveWhenEmptyReporter())); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + private static ApprovalNamer namedAt(Path path) { + var parent = path.getParent(); + if (parent == null) { + throw new IllegalArgumentException("Snapshot path must have a parent directory: " + path); + } + var directory = parent.toAbsolutePath() + File.separator; + var name = path.getFileName().toString().replaceAll("\\.approved\\.txt$", ""); + return new ApprovalNamer() { + @Override + public String getApprovalName() { + return name; + } + + @Override + public String getSourceFilePath() { + return directory; + } + + @Override + public File getApprovedFile(String ext) { + return new File(directory + name + ".approved" + ext); + } + + @Override + public File getReceivedFile(String ext) { + return new File(directory + name + ".received" + ext); + } + + @Override + public ApprovalNamer addAdditionalInformation(String info) { + return this; + } + }; + } +} diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/ContractTests.java b/src/jvm/src/test/java/io/eventdriven/strictland/ContractTests.java new file mode 100644 index 0000000..296fee1 --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/ContractTests.java @@ -0,0 +1,259 @@ +package io.eventdriven.strictland; + +import static java.time.ZoneOffset.UTC; +import static java.util.Objects.requireNonNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.io.IOException; +import java.nio.file.Path; +import java.time.OffsetDateTime; +import java.util.UUID; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +final class ContractTests { + private static final UUID FIXED_CART_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final OffsetDateTime FIXED_DATE = OffsetDateTime.of(2024, 1, 1, 12, 0, 0, 0, UTC); + private static final Contract CONTRACT = Contract.specification(); + private static final ObjectMapper CUSTOM_MAPPER = new JsonMapper() + .registerModule(new JavaTimeModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + + record ShoppingCartConfirmedV1( + UUID shoppingCartId, @Nullable String clientId, OffsetDateTime confirmedAt) {} + + record ShoppingCartConfirmedV2( + UUID shoppingCartId, + @Nullable String clientId, + OffsetDateTime confirmedAt, + @Nullable String initializedBy) {} + + record ShoppingCartConfirmed( + UUID shoppingCartId, @Nullable String clientId, OffsetDateTime confirmedAt) {} + + record EmptyEvent() {} + + record Unserializable(UUID id) { + @Override + @SuppressWarnings("DoNotCallSuggester") + public UUID id() { + throw new RuntimeException("boom"); + } + } + + @Test + void shoppingCartConfirmedV1_withCompleteData_contractIsUnchanged() { + CONTRACT.given(new ShoppingCartConfirmedV1(FIXED_CART_ID, "anonymised", FIXED_DATE)) + .whenSerialized() + .thenContractIsUnchanged(); + } + + @Test + void shoppingCartConfirmedV1_withNullClientId_contractIsUnchanged() { + CONTRACT.given(new ShoppingCartConfirmedV1(FIXED_CART_ID, null, FIXED_DATE)) + .whenSerialized(Snapshot.forMessageType("ShoppingCartConfirmedV1_NullClientId")) + .thenContractIsUnchanged(); + } + + @Test + void shoppingCartConfirmedV2_withRequiredData_contractIsUnchanged() { + CONTRACT.given(new ShoppingCartConfirmedV2(FIXED_CART_ID, "anonymised", FIXED_DATE, null)) + .whenSerialized(Snapshot.forMessageType("ShoppingCartConfirmedV2_WithRequiredData")) + .thenContractIsUnchanged(); + } + + @Test + void shoppingCartConfirmedV2_withCompleteData_contractIsUnchanged() { + CONTRACT.given(new ShoppingCartConfirmedV2(FIXED_CART_ID, "anonymised", FIXED_DATE, "Oskar")) + .whenSerialized() + .thenContractIsUnchanged(); + } + + @Test + void givenV1Event_whenReadAsV2_thenForwardCompatible() { + CONTRACT.given(Snapshot.of(ShoppingCartConfirmedV1.class)) + .whenDeserializedAs(ShoppingCartConfirmedV2.class) + .thenForwardCompatible(v2 -> assertNull(v2.initializedBy())); + } + + @Test + void givenEvent_whenRoundTrippedAsSameType_thenForwardCompatibleWithNoExtraAssertions() { + CONTRACT.given(new ShoppingCartConfirmed(FIXED_CART_ID, "anonymised", FIXED_DATE)) + .whenDeserializedAs(ShoppingCartConfirmed.class) + .thenForwardCompatible(); + } + + @Test + void givenV2Event_whenReadAsV1_thenBackwardCompatible() { + CONTRACT.given(new ShoppingCartConfirmedV2(FIXED_CART_ID, "anonymised", FIXED_DATE, "admin")) + .whenDeserializedAs(ShoppingCartConfirmedV1.class) + .thenBackwardCompatible(); + } + + @Test + void specification_withCustomMapper_isUsedForSerialization() { + Contract.specification(CUSTOM_MAPPER) + .given(new ShoppingCartConfirmed(FIXED_CART_ID, "anonymised", FIXED_DATE)) + .whenSerialized() + .thenContractIsUnchanged(); + } + + @Test + void given_byClass_whenSerialized_verifiesAgainstSnapshotNamedAfterTheContractualType() { + Contract.specification() + .given(new ShoppingCartConfirmed(FIXED_CART_ID, "anonymised", FIXED_DATE)) + .whenSerialized(Snapshot.of(ShoppingCartConfirmed.class)) + .thenContractIsUnchanged(); + } + + @Test + void given_byMessageType_whenSerialized_verifiesAgainstNamedSnapshot() { + Contract.specification() + .given(new ShoppingCartConfirmed(FIXED_CART_ID, "anonymised", FIXED_DATE)) + .whenSerialized( + Snapshot.forMessageType("ShoppingCartConfirmed_ByMessageType", ShoppingCartConfirmed.class)) + .thenContractIsUnchanged(); + } + + @Test + void given_byPath_whenSerialized_verifiesAgainstSnapshotAtPath() { + var path = Path.of("src/test/java/io/eventdriven/strictland/ShoppingCartConfirmed_ByPath.approved.txt"); + + Contract.specification() + .given(new ShoppingCartConfirmed(FIXED_CART_ID, "anonymised", FIXED_DATE)) + .whenSerialized(Snapshot.at(path, ShoppingCartConfirmed.class)) + .thenContractIsUnchanged(); + } + + @Test + void given_byPath_withRelativeSinglePartPath_throwsBecausePathHasNoParent() { + var path = Path.of("Orphan.approved.txt"); + + var exception = assertThrows( + IllegalArgumentException.class, + () -> Contract.specification() + .given(new ShoppingCartConfirmed(FIXED_CART_ID, "anonymised", FIXED_DATE)) + .whenSerialized(Snapshot.at(path)) + .thenContractIsUnchanged()); + + assertTrue(requireNonNull(exception.getMessage()).contains("must have a parent directory")); + } + + @Test + void given_snapshotByMessageType_whenSerialized_throwsBecauseInstanceIsRequired() { + var exception = assertThrows( + IllegalStateException.class, + () -> Contract.specification() + .given(Snapshot.forMessageType("ShoppingCartConfirmed")) + .whenSerialized()); + + assertTrue(requireNonNull(exception.getMessage()).contains("Contract.given(instance)")); + } + + @Test + void given_snapshotByPath_whenSerialized_throwsBecauseInstanceIsRequired() { + var path = Path.of("src/test/java/io/eventdriven/strictland/ShoppingCartConfirmed.approved.txt"); + + var exception = assertThrows( + IllegalStateException.class, + () -> Contract.specification().given(Snapshot.at(path)).whenSerialized()); + + assertTrue(requireNonNull(exception.getMessage()).contains("Contract.given(instance)")); + } + + @Test + void given_snapshotByMessageTypeWithSourceType_whenDeserializedAs_isForwardCompatible() { + Contract.specification() + .given(Snapshot.forMessageType("ShoppingCartConfirmed", ShoppingCartConfirmed.class)) + .whenDeserializedAs(ShoppingCartConfirmed.class) + .thenForwardCompatible(event -> assertEquals(FIXED_CART_ID, event.shoppingCartId())); + } + + @Test + void given_snapshotByPath_whenDeserializedAs_isBackwardCompatibleWithExtraAssertion() { + var path = Path.of("src/test/java/io/eventdriven/strictland/ShoppingCartConfirmed.approved.txt"); + + Contract.specification() + .given(Snapshot.at(path, ShoppingCartConfirmed.class)) + .whenDeserializedAs(ShoppingCartConfirmed.class) + .thenBackwardCompatible(event -> assertEquals(FIXED_CART_ID, event.shoppingCartId())); + } + + @Test + void given_snapshotThatDeserializesToNull_thenForwardCompatible_fails() { + var path = Path.of("src/test/java/io/eventdriven/strictland/Null.approved.txt"); + + var error = assertThrows( + AssertionError.class, + () -> Contract.specification() + .given(Snapshot.at(path, ShoppingCartConfirmed.class)) + .whenDeserializedAs(ShoppingCartConfirmed.class) + .thenForwardCompatible()); + + assertTrue(requireNonNull(error.getMessage()).contains("returned empty")); + } + + @Test + void given_malformedSnapshot_whenDeserializedAs_wrapsDeserializationFailureInRuntimeException() { + var path = Path.of("src/test/java/io/eventdriven/strictland/Malformed.approved.txt"); + + var exception = assertThrows( + RuntimeException.class, + () -> Contract.specification() + .given(Snapshot.at(path, ShoppingCartConfirmed.class)) + .whenDeserializedAs(ShoppingCartConfirmed.class) + .thenForwardCompatible()); + + assertTrue(requireNonNull(exception.getMessage()).contains("Deserialization as ShoppingCartConfirmed failed")); + assertTrue(exception.getCause() instanceof IOException); + } + + @Test + void given_missingSnapshotFile_whenDeserializedAs_wrapsFileReadFailureInRuntimeException() { + var path = Path.of("src/test/java/io/eventdriven/strictland/DoesNotExist.approved.txt"); + + var exception = assertThrows( + RuntimeException.class, + () -> Contract.specification() + .given(Snapshot.at(path, ShoppingCartConfirmed.class)) + .whenDeserializedAs(ShoppingCartConfirmed.class) + .thenForwardCompatible()); + + assertTrue(requireNonNull(exception.getMessage()).contains("Cannot read snapshot file")); + assertTrue(exception.getCause() instanceof IOException); + } + + @Test + void given_unserializableInstance_whenDeserializedAs_wrapsSerializationFailureInRuntimeException() { + var exception = assertThrows( + RuntimeException.class, + () -> Contract.specification() + .given(new Unserializable(FIXED_CART_ID)) + .whenDeserializedAs(EmptyEvent.class) + .thenForwardCompatible()); + + assertTrue(requireNonNull(exception.getMessage()).contains("Serialization of Unserializable failed")); + assertTrue(exception.getCause() instanceof IOException); + } + + @Test + void given_instanceThatDeserializesToUnserializableTarget_wrapsTargetSerializationFailure() { + var exception = assertThrows( + RuntimeException.class, + () -> Contract.specification() + .given(new ShoppingCartConfirmed(FIXED_CART_ID, "anonymised", FIXED_DATE)) + .whenDeserializedAs(Unserializable.class) + .thenForwardCompatible()); + + assertTrue(exception.getCause() instanceof IOException); + } +} diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/Malformed.approved.txt b/src/jvm/src/test/java/io/eventdriven/strictland/Malformed.approved.txt new file mode 100644 index 0000000..8dc5858 --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/Malformed.approved.txt @@ -0,0 +1 @@ +{not valid json \ No newline at end of file diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/Null.approved.txt b/src/jvm/src/test/java/io/eventdriven/strictland/Null.approved.txt new file mode 100644 index 0000000..ec747fa --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/Null.approved.txt @@ -0,0 +1 @@ +null \ No newline at end of file diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.contractsV1Package_hasNoPublicApiChanges.approved.txt b/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.contractsV1Package_hasNoPublicApiChanges.approved.txt new file mode 100644 index 0000000..6634c21 --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.contractsV1Package_hasNoPublicApiChanges.approved.txt @@ -0,0 +1,38 @@ +public interface io.eventdriven.strictland.tests.contracts.v1.ShoppingCartEvent { +} + +public static final record io.eventdriven.strictland.tests.contracts.v1.ShoppingCartEvent$ProductItemAddedToShoppingCart implements io.eventdriven.strictland.tests.contracts.v1.ShoppingCartEvent { + public ProductItemAddedToShoppingCart(java.util.UUID, io.eventdriven.strictland.tests.contracts.v1.productitems.ProductItem); + public final boolean equals(java.lang.Object); + public final int hashCode(); + public io.eventdriven.strictland.tests.contracts.v1.productitems.ProductItem productItem(); + public java.util.UUID shoppingCartId(); + public final java.lang.String toString(); +} + +public static final record io.eventdriven.strictland.tests.contracts.v1.ShoppingCartEvent$ShoppingCartConfirmed implements io.eventdriven.strictland.tests.contracts.v1.ShoppingCartEvent { + public ShoppingCartConfirmed(java.util.UUID, java.time.OffsetDateTime); + public java.time.OffsetDateTime confirmedAt(); + public final boolean equals(java.lang.Object); + public final int hashCode(); + public java.util.UUID shoppingCartId(); + public final java.lang.String toString(); +} + +public static final record io.eventdriven.strictland.tests.contracts.v1.ShoppingCartEvent$ShoppingCartOpened implements io.eventdriven.strictland.tests.contracts.v1.ShoppingCartEvent { + public ShoppingCartOpened(java.util.UUID, java.util.UUID); + public java.util.UUID clientId(); + public final boolean equals(java.lang.Object); + public final int hashCode(); + public java.util.UUID shoppingCartId(); + public final java.lang.String toString(); +} + +public final record io.eventdriven.strictland.tests.contracts.v1.productitems.ProductItem { + public ProductItem(java.util.UUID, int); + public final boolean equals(java.lang.Object); + public final int hashCode(); + public java.util.UUID productId(); + public int quantity(); + public final java.lang.String toString(); +} \ No newline at end of file diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.contractsV2Package_excludingComparisonMethod_hidesItFromTheSurface.approved.txt b/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.contractsV2Package_excludingComparisonMethod_hidesItFromTheSurface.approved.txt new file mode 100644 index 0000000..ae00183 --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.contractsV2Package_excludingComparisonMethod_hidesItFromTheSurface.approved.txt @@ -0,0 +1,16 @@ +public enum io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus { + public static final io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus.CANCELLED; + public static final io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus.CONFIRMED; + public static final io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus.PENDING; + public static io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus valueOf(java.lang.String); + public static io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus[] values(); +} + +public class io.eventdriven.strictland.tests.contracts.v2.ShoppingCartSummary implements java.lang.Comparable { + public final java.util.UUID io.eventdriven.strictland.tests.contracts.v2.ShoppingCartSummary.shoppingCartId; + public ShoppingCartSummary(java.util.UUID, io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus); + public boolean equals(java.lang.Object); + public io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus getStatus(); + public int hashCode(); + public java.lang.String toString(); +} \ No newline at end of file diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.contractsV2Package_excludingConstructors_focusesOnFieldsAndMethods.approved.txt b/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.contractsV2Package_excludingConstructors_focusesOnFieldsAndMethods.approved.txt new file mode 100644 index 0000000..d726995 --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.contractsV2Package_excludingConstructors_focusesOnFieldsAndMethods.approved.txt @@ -0,0 +1,16 @@ +public enum io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus { + public static final io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus.CANCELLED; + public static final io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus.CONFIRMED; + public static final io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus.PENDING; + public static io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus valueOf(java.lang.String); + public static io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus[] values(); +} + +public class io.eventdriven.strictland.tests.contracts.v2.ShoppingCartSummary implements java.lang.Comparable { + public final java.util.UUID io.eventdriven.strictland.tests.contracts.v2.ShoppingCartSummary.shoppingCartId; + public int compareTo(io.eventdriven.strictland.tests.contracts.v2.ShoppingCartSummary); + public boolean equals(java.lang.Object); + public io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus getStatus(); + public int hashCode(); + public java.lang.String toString(); +} \ No newline at end of file diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.contractsV2Package_excludingStandardObjectMethods_hidesEqualsHashCodeAndToString.approved.txt b/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.contractsV2Package_excludingStandardObjectMethods_hidesEqualsHashCodeAndToString.approved.txt new file mode 100644 index 0000000..68cbb7c --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.contractsV2Package_excludingStandardObjectMethods_hidesEqualsHashCodeAndToString.approved.txt @@ -0,0 +1,14 @@ +public enum io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus { + public static final io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus.CANCELLED; + public static final io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus.CONFIRMED; + public static final io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus.PENDING; + public static io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus valueOf(java.lang.String); + public static io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus[] values(); +} + +public class io.eventdriven.strictland.tests.contracts.v2.ShoppingCartSummary implements java.lang.Comparable { + public final java.util.UUID io.eventdriven.strictland.tests.contracts.v2.ShoppingCartSummary.shoppingCartId; + public ShoppingCartSummary(java.util.UUID, io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus); + public int compareTo(io.eventdriven.strictland.tests.contracts.v2.ShoppingCartSummary); + public io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus getStatus(); +} \ No newline at end of file diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.contractsV2Package_hasNoPublicApiChanges.approved.txt b/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.contractsV2Package_hasNoPublicApiChanges.approved.txt new file mode 100644 index 0000000..e5ee4c2 --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.contractsV2Package_hasNoPublicApiChanges.approved.txt @@ -0,0 +1,17 @@ +public enum io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus { + public static final io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus.CANCELLED; + public static final io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus.CONFIRMED; + public static final io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus.PENDING; + public static io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus valueOf(java.lang.String); + public static io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus[] values(); +} + +public class io.eventdriven.strictland.tests.contracts.v2.ShoppingCartSummary implements java.lang.Comparable { + public final java.util.UUID io.eventdriven.strictland.tests.contracts.v2.ShoppingCartSummary.shoppingCartId; + public ShoppingCartSummary(java.util.UUID, io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus); + public int compareTo(io.eventdriven.strictland.tests.contracts.v2.ShoppingCartSummary); + public boolean equals(java.lang.Object); + public io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus getStatus(); + public int hashCode(); + public java.lang.String toString(); +} \ No newline at end of file diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.contractsV2Package_onlyGettersAndFields_summarisesObservableState.approved.txt b/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.contractsV2Package_onlyGettersAndFields_summarisesObservableState.approved.txt new file mode 100644 index 0000000..3ddc683 --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.contractsV2Package_onlyGettersAndFields_summarisesObservableState.approved.txt @@ -0,0 +1,11 @@ +public enum io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus { + public static final io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus.CANCELLED; + public static final io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus.CONFIRMED; + public static final io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus.PENDING; + public static io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus[] values(); +} + +public class io.eventdriven.strictland.tests.contracts.v2.ShoppingCartSummary implements java.lang.Comparable { + public final java.util.UUID io.eventdriven.strictland.tests.contracts.v2.ShoppingCartSummary.shoppingCartId; + public io.eventdriven.strictland.tests.contracts.v2.ShoppingCartStatus getStatus(); +} \ No newline at end of file diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.java b/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.java new file mode 100644 index 0000000..138359d --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/PublicApiScannerExampleTests.java @@ -0,0 +1,59 @@ +package io.eventdriven.strictland; + +import org.approvaltests.Approvals; +import org.junit.jupiter.api.Test; + +final class PublicApiScannerExampleTests { + + @Test + void contractsV1Package_hasNoPublicApiChanges() { + var api = PublicApiScanner.forPackage("io.eventdriven.strictland.tests.contracts.v1") + .generate(); + + Approvals.verify(api); + } + + @Test + void contractsV2Package_hasNoPublicApiChanges() { + var api = PublicApiScanner.forPackage("io.eventdriven.strictland.tests.contracts.v2") + .generate(); + + Approvals.verify(api); + } + + @Test + void contractsV2Package_excludingComparisonMethod_hidesItFromTheSurface() { + var api = PublicApiScanner.forPackage("io.eventdriven.strictland.tests.contracts.v2") + .excludingMethods(m -> m.getName().equals("compareTo")) + .generate(); + + Approvals.verify(api); + } + + @Test + void contractsV2Package_excludingConstructors_focusesOnFieldsAndMethods() { + var api = PublicApiScanner.forPackage("io.eventdriven.strictland.tests.contracts.v2") + .excludeConstructors() + .generate(); + + Approvals.verify(api); + } + + @Test + void contractsV2Package_excludingStandardObjectMethods_hidesEqualsHashCodeAndToString() { + var api = PublicApiScanner.forPackage("io.eventdriven.strictland.tests.contracts.v2") + .excludeStandardObjectMethods() + .generate(); + + Approvals.verify(api); + } + + @Test + void contractsV2Package_onlyGettersAndFields_summarisesObservableState() { + var api = PublicApiScanner.forPackage("io.eventdriven.strictland.tests.contracts.v2") + .onlyGettersAndFields() + .generate(); + + Approvals.verify(api); + } +} diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmed.approved.txt b/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmed.approved.txt new file mode 100644 index 0000000..d3cd1f2 --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmed.approved.txt @@ -0,0 +1 @@ +{"shoppingCartId":"00000000-0000-0000-0000-000000000001","clientId":"anonymised","confirmedAt":"2024-01-01T12:00:00Z"} \ No newline at end of file diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmedV1.approved.txt b/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmedV1.approved.txt new file mode 100644 index 0000000..d3cd1f2 --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmedV1.approved.txt @@ -0,0 +1 @@ +{"shoppingCartId":"00000000-0000-0000-0000-000000000001","clientId":"anonymised","confirmedAt":"2024-01-01T12:00:00Z"} \ No newline at end of file diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmedV1_NullClientId.approved.txt b/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmedV1_NullClientId.approved.txt new file mode 100644 index 0000000..3ec47fe --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmedV1_NullClientId.approved.txt @@ -0,0 +1 @@ +{"shoppingCartId":"00000000-0000-0000-0000-000000000001","clientId":null,"confirmedAt":"2024-01-01T12:00:00Z"} \ No newline at end of file diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmedV2.approved.txt b/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmedV2.approved.txt new file mode 100644 index 0000000..1854076 --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmedV2.approved.txt @@ -0,0 +1 @@ +{"shoppingCartId":"00000000-0000-0000-0000-000000000001","clientId":"anonymised","confirmedAt":"2024-01-01T12:00:00Z","initializedBy":"Oskar"} \ No newline at end of file diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmedV2_WithRequiredData.approved.txt b/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmedV2_WithRequiredData.approved.txt new file mode 100644 index 0000000..bcb355f --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmedV2_WithRequiredData.approved.txt @@ -0,0 +1 @@ +{"shoppingCartId":"00000000-0000-0000-0000-000000000001","clientId":"anonymised","confirmedAt":"2024-01-01T12:00:00Z","initializedBy":null} \ No newline at end of file diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmed_ByMessageType.approved.txt b/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmed_ByMessageType.approved.txt new file mode 100644 index 0000000..d3cd1f2 --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmed_ByMessageType.approved.txt @@ -0,0 +1 @@ +{"shoppingCartId":"00000000-0000-0000-0000-000000000001","clientId":"anonymised","confirmedAt":"2024-01-01T12:00:00Z"} \ No newline at end of file diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmed_ByPath.approved.txt b/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmed_ByPath.approved.txt new file mode 100644 index 0000000..d3cd1f2 --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/ShoppingCartConfirmed_ByPath.approved.txt @@ -0,0 +1 @@ +{"shoppingCartId":"00000000-0000-0000-0000-000000000001","clientId":"anonymised","confirmedAt":"2024-01-01T12:00:00Z"} \ No newline at end of file diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v1/ShoppingCartEvent.java b/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v1/ShoppingCartEvent.java new file mode 100644 index 0000000..d242174 --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v1/ShoppingCartEvent.java @@ -0,0 +1,14 @@ +package io.eventdriven.strictland.tests.contracts.v1; + +import io.eventdriven.strictland.tests.contracts.v1.productitems.ProductItem; +import java.time.OffsetDateTime; +import java.util.UUID; + +public sealed interface ShoppingCartEvent { + + record ShoppingCartOpened(UUID shoppingCartId, UUID clientId) implements ShoppingCartEvent {} + + record ProductItemAddedToShoppingCart(UUID shoppingCartId, ProductItem productItem) implements ShoppingCartEvent {} + + record ShoppingCartConfirmed(UUID shoppingCartId, OffsetDateTime confirmedAt) implements ShoppingCartEvent {} +} diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v1/package-info.java b/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v1/package-info.java new file mode 100644 index 0000000..0211da1 --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v1/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package io.eventdriven.strictland.tests.contracts.v1; + +import org.jspecify.annotations.NullMarked; diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v1/productitems/ProductItem.java b/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v1/productitems/ProductItem.java new file mode 100644 index 0000000..db7c8e1 --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v1/productitems/ProductItem.java @@ -0,0 +1,5 @@ +package io.eventdriven.strictland.tests.contracts.v1.productitems; + +import java.util.UUID; + +public record ProductItem(UUID productId, int quantity) {} diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v1/productitems/package-info.java b/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v1/productitems/package-info.java new file mode 100644 index 0000000..e2c0455 --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v1/productitems/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package io.eventdriven.strictland.tests.contracts.v1.productitems; + +import org.jspecify.annotations.NullMarked; diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v2/ShoppingCartStatus.java b/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v2/ShoppingCartStatus.java new file mode 100644 index 0000000..b890ba8 --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v2/ShoppingCartStatus.java @@ -0,0 +1,7 @@ +package io.eventdriven.strictland.tests.contracts.v2; + +public enum ShoppingCartStatus { + PENDING, + CONFIRMED, + CANCELLED +} diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v2/ShoppingCartSummary.java b/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v2/ShoppingCartSummary.java new file mode 100644 index 0000000..6a6a7b8 --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v2/ShoppingCartSummary.java @@ -0,0 +1,40 @@ +package io.eventdriven.strictland.tests.contracts.v2; + +import java.util.Objects; +import java.util.UUID; + +public class ShoppingCartSummary implements Comparable { + public final UUID shoppingCartId; + private final ShoppingCartStatus status; + + public ShoppingCartSummary(UUID shoppingCartId, ShoppingCartStatus status) { + this.shoppingCartId = shoppingCartId; + this.status = status; + } + + public ShoppingCartStatus getStatus() { + return status; + } + + @Override + public int compareTo(ShoppingCartSummary other) { + return shoppingCartId.compareTo(other.shoppingCartId); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ShoppingCartSummary other)) return false; + return shoppingCartId.equals(other.shoppingCartId) && status == other.status; + } + + @Override + public int hashCode() { + return Objects.hash(shoppingCartId, status); + } + + @Override + public String toString() { + return "ShoppingCartSummary{shoppingCartId=" + shoppingCartId + ", status=" + status + "}"; + } +} diff --git a/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v2/package-info.java b/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v2/package-info.java new file mode 100644 index 0000000..8657708 --- /dev/null +++ b/src/jvm/src/test/java/io/eventdriven/strictland/tests/contracts/v2/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package io.eventdriven.strictland.tests.contracts.v2; + +import org.jspecify.annotations.NullMarked;