diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeNamingConvention.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeNamingConvention.java new file mode 100644 index 000000000..cd2a114ee --- /dev/null +++ b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeNamingConvention.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.support.change; + +/** + * Shared utility for parsing the Flamingock naming convention that encodes execution order. + * + *

Both code-based changes (class names) and template-based changes (file names) follow + * the same pattern:

+ *
+ *   _ORDER__DescriptiveName
+ * 
+ *

Examples:

+ * + * + *

This class is package-private and intended for use by validators in this package only.

+ */ +final class ChangeNamingConvention { + + private static final String ORDER_PREFIX = "_"; + private static final String ORDER_SEPARATOR = "__"; + + private ChangeNamingConvention() { + } + + /** + * Extracts the order segment from a name (class simple name or file name without extension) + * following the {@code _ORDER__DescriptiveName} convention. + * + * @param name the name to parse (class simple name or file name without extension) + * @return the extracted order string, or {@code null} if the name does not follow the convention + */ + static String extractOrder(String name) { + if (name == null || !name.startsWith(ORDER_PREFIX)) { + return null; + } + int separatorIndex = name.indexOf(ORDER_SEPARATOR); + if (separatorIndex <= 1) { + return null; + } + return name.substring(1, separatorIndex); + } +} diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidator.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidator.java new file mode 100644 index 000000000..a907494c7 --- /dev/null +++ b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidator.java @@ -0,0 +1,261 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.support.change; + +import io.flamingock.api.RecoveryStrategy; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * Base class for Flamingock change validators. + * + *

Provides the soft-assertion engine: assertions are queued via + * {@link #addAssertion(Supplier)} and executed together by {@link #validate()}, which + * collects all failures and throws a single {@link AssertionError} listing every problem.

+ * + *

Shared assertions that apply to both code-based and template-based changes + * ({@code withId}, {@code withAuthor}, {@code withOrder}, {@code withTargetSystem}, + * {@code withRecovery}, {@code isTransactional}, {@code isNotTransactional}) are declared + * here so that concrete subclasses inherit them without duplication.

+ * + *

Subclasses must implement the metadata accessors ({@link #getId()}, + * {@link #getAuthor()}, etc.) to supply the values that each assertion checks.

+ * + *

Subclasses should return {@code this} from their own assertion methods to allow + * fluent chaining.

+ * + * @param the concrete subclass type, used to preserve the fluent return type + * @see CodeBasedChangeValidator + */ +public abstract class ChangeValidator> { + + /** + * Creates a {@code ChangeValidator} for the given change class. + * + *

Validates eagerly that the class is annotated with {@code @Change} and declares + * at least one method annotated with {@code @Apply}.

+ * + * @param changeClass the change class to validate; must not be {@code null} + * @return a new validator ready for assertion chaining + * @throws NullPointerException if {@code changeClass} is {@code null} + * @throws IllegalArgumentException if {@code @Change} or {@code @Apply} is absent + */ + public static CodeBasedChangeValidator of(Class changeClass) { + return new CodeBasedChangeValidator(changeClass); + } + + /** Display name used in error messages (class simple name or file name). */ + protected final String displayName; + + /** + * Order extracted from the name at construction time via {@link ChangeNamingConvention}. + * {@code null} when the name does not follow the {@code _ORDER__Name} convention. + */ + protected final String extractedOrder; + + private final List> assertions = new ArrayList<>(); + + protected ChangeValidator(String displayName, String extractedOrder) { + this.displayName = displayName; + this.extractedOrder = extractedOrder; + } + + protected abstract String getId(); + + protected abstract String getAuthor(); + + protected abstract boolean isTransactionalValue(); + + protected abstract String getTargetSystemId(); + + protected abstract RecoveryStrategy getRecovery(); + + /** + * Asserts that the change id matches the expected value. + * + * @param expected the expected id + * @return this validator for chaining + */ + public SELF withId(String expected) { + addAssertion(() -> { + String actual = getId(); + return actual.equals(expected) + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format("withId: expected \"%s\" but was \"%s\"", expected, actual)); + }); + return self(); + } + + /** + * Asserts that the change author matches the expected value. + * + * @param expected the expected author + * @return this validator for chaining + */ + public SELF withAuthor(String expected) { + addAssertion(() -> { + String actual = getAuthor(); + return actual.equals(expected) + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format("withAuthor: expected \"%s\" but was \"%s\"", expected, actual)); + }); + return self(); + } + + /** + * Asserts that the order extracted from the name matches the expected value. + * + *

Order is derived from the naming convention {@code _ORDER__DescriptiveName}. + * For code-based changes the class simple name is used; for template-based changes + * the file name (without extension) is used.

+ * + * @param expected the exact expected order string (e.g. {@code "0002"}, {@code "20250101_01"}) + * @return this validator for chaining + */ + public SELF withOrder(String expected) { + addAssertion(() -> { + if (extractedOrder == null) { + return ChangeValidatorResult.error(String.format( + "withOrder: could not extract order from \"%s\". " + + "Name must follow the _ORDER__Name convention (e.g. _0001__MyChange).", + displayName)); + } + return extractedOrder.equals(expected) + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format( + "withOrder: expected \"%s\" but extracted order was \"%s\"", + expected, extractedOrder)); + }); + return self(); + } + + /** + * Asserts that a target system is declared with the given id. + * + * @param expectedId the expected target system id + * @return this validator for chaining + */ + public SELF withTargetSystem(String expectedId) { + addAssertion(() -> { + String actual = getTargetSystemId(); + if (actual == null) { + return ChangeValidatorResult.error(String.format( + "withTargetSystem: expected target system \"%s\" but none is declared", + expectedId)); + } + return actual.equals(expectedId) + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format( + "withTargetSystem: expected \"%s\" but was \"%s\"", expectedId, actual)); + }); + return self(); + } + + /** + * Asserts that the recovery strategy matches the expected value. + * + *

When no recovery is explicitly declared, {@link RecoveryStrategy#MANUAL_INTERVENTION} + * is assumed, consistent with the Flamingock runtime default.

+ * + * @param expected the expected {@link RecoveryStrategy} + * @return this validator for chaining + */ + public SELF withRecovery(RecoveryStrategy expected) { + addAssertion(() -> { + RecoveryStrategy actual = getRecovery(); + String actualName = actual != null ? actual.name() : null; + return actual == expected + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format( + "withRecovery: expected %s but was %s", expected.name(), actualName)); + }); + return self(); + } + + /** + * Asserts that the change is transactional. + * + * @return this validator for chaining + */ + public SELF isTransactional() { + addAssertion(() -> isTransactionalValue() + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error("isTransactional: expected transactional=true but was false")); + return self(); + } + + /** + * Asserts that the change is not transactional. + * + * @return this validator for chaining + */ + public SELF isNotTransactional() { + addAssertion(() -> !isTransactionalValue() + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error("isNotTransactional: expected transactional=false but was true")); + return self(); + } + + + /** + * Queues an assertion to be evaluated when {@link #validate()} is called. + * + * @param assertion a supplier that returns an error message if the assertion fails, + * or {@link Optional#empty()} if it passes + */ + protected final void addAssertion(Supplier assertion) { + assertions.add(assertion); + } + + /** + * Runs all queued assertions and throws an {@link AssertionError} if any fail. + * + *

All assertions are always evaluated; failures are collected and reported together + * so every problem is visible in a single test run.

+ * + * @throws AssertionError if one or more assertions failed, listing all failure messages + */ + public final void validate() { + List errors = getErrors().stream() + .map(ChangeValidatorResult.Error::getMessage) + .collect(Collectors.toList()); + + if (!errors.isEmpty()) { + throw new AssertionError( + getClass().getSimpleName() + " failed for " + displayName + ":\n - " + + String.join("\n - ", errors)); + } + } + + @NotNull + private List getErrors() { + return assertions.stream() + .map(Supplier::get) + .filter(ChangeValidatorResult::isError) + .map(ChangeValidatorResult.Error.class::cast) + .collect(Collectors.toList()); + } + + @SuppressWarnings("unchecked") + private SELF self() { + return (SELF) this; + } +} diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidatorResult.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidatorResult.java new file mode 100644 index 000000000..b84452d37 --- /dev/null +++ b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidatorResult.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.support.change; + +public abstract class ChangeValidatorResult { + + public static Ok OK_INSTANCE; + + public static ChangeValidatorResult.Ok OK() { + if (OK_INSTANCE == null) { + OK_INSTANCE = new ChangeValidatorResult.Ok(); + } + return OK_INSTANCE; + } + + public static ChangeValidatorResult.Error error(String message) { + return new ChangeValidatorResult.Error(message); + } + + public static class Ok extends ChangeValidatorResult { + + } + + public static class Error extends ChangeValidatorResult { + private final String message; + + public Error(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + } + + public boolean isError() { + return this instanceof ChangeValidatorResult.Error; + } +} diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/CodeBasedChangeValidator.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/CodeBasedChangeValidator.java new file mode 100644 index 000000000..45c72d746 --- /dev/null +++ b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/CodeBasedChangeValidator.java @@ -0,0 +1,143 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.support.change; + +import io.flamingock.api.RecoveryStrategy; +import io.flamingock.api.annotations.Apply; +import io.flamingock.api.annotations.Change; +import io.flamingock.api.annotations.Recovery; +import io.flamingock.api.annotations.Rollback; +import io.flamingock.api.annotations.TargetSystem; + +import java.util.Arrays; +import java.util.Objects; + +/** + * Fluent assertion utility for validating that a code-based change class is correctly annotated. + * + *

Reads metadata from {@code @Change}, {@code @TargetSystem}, {@code @Recovery}, + * {@code @Apply}, and {@code @Rollback} via reflection and asserts that the values match + * expectations. All assertions are soft: they are queued on each chained call and executed + * together by {@link #validate()}, which collects every failure into a single + * {@link AssertionError}.

+ * + *

Implicit validation at construction

+ *

{@link #of(Class)} checks eagerly that: + *

    + *
  • The class is annotated with {@code @Change}
  • + *
  • The class declares at least one method annotated with {@code @Apply}
  • + *
+ * + *

Usage example

+ *
{@code
+ * ChangeValidator.of(_0002__FeedClients.class)
+ *     .withId("feed-clients")
+ *     .withAuthor("john.doe")
+ *     .withOrder("0002")
+ *     .isTransactional()
+ *     .withTargetSystem("mongodb")
+ *     .hasRollbackMethod()
+ *     .validate();
+ * }
+ * + * @see ChangeValidator + * @see io.flamingock.api.annotations.Change + * @see io.flamingock.api.annotations.Apply + * @see io.flamingock.api.annotations.Rollback + */ +public final class CodeBasedChangeValidator extends ChangeValidator { + + private final Class changeClass; + private final Change changeAnnotation; + + CodeBasedChangeValidator(Class changeClass) { + super( + Objects.requireNonNull(changeClass, "changeClass must not be null").getSimpleName(), + ChangeNamingConvention.extractOrder(changeClass.getSimpleName()) + ); + this.changeClass = changeClass; + + this.changeAnnotation = changeClass.getAnnotation(Change.class); + if (changeAnnotation == null) { + throw new IllegalArgumentException( + String.format("Class [%s] must be annotated with @Change", changeClass.getName())); + } + + boolean hasApplyMethod = Arrays.stream(changeClass.getDeclaredMethods()) + .anyMatch(m -> m.isAnnotationPresent(Apply.class)); + if (!hasApplyMethod) { + throw new IllegalArgumentException( + String.format("Class [%s] must declare a method annotated with @Apply", changeClass.getName())); + } + } + + @Override + protected String getId() { + return changeAnnotation.id(); + } + + @Override + protected String getAuthor() { + return changeAnnotation.author(); + } + + @Override + protected boolean isTransactionalValue() { + return changeAnnotation.transactional(); + } + + @Override + protected String getTargetSystemId() { + TargetSystem ts = changeClass.getAnnotation(TargetSystem.class); + return ts != null ? ts.id() : null; + } + + @Override + protected RecoveryStrategy getRecovery() { + if (changeClass.isAnnotationPresent(Recovery.class)) { + Recovery rec = changeClass.getAnnotation(Recovery.class); + return rec != null ? rec.strategy() : null; + } else { + try { + return (RecoveryStrategy) Recovery.class.getMethod("strategy").getDefaultValue(); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + } + + /** + * Asserts that the class declares a method annotated with {@code @Rollback}. + * + *

Only methods directly declared in the class are considered; inherited methods + * are not scanned.

+ * + * @return this validator for chaining + */ + public CodeBasedChangeValidator hasRollbackMethod() { + addAssertion(() -> { + boolean found = Arrays.stream(changeClass.getDeclaredMethods()) + .anyMatch(m -> m.isAnnotationPresent(Rollback.class)); + return found + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format( + "hasRollbackMethod: no method annotated with @Rollback found in %s", + changeClass.getSimpleName())); + }); + return this; + } +} diff --git a/core/flamingock-test-support/src/test/java/io/flamingock/support/change/ChangeNamingConventionTest.java b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/ChangeNamingConventionTest.java new file mode 100644 index 000000000..4ce29641b --- /dev/null +++ b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/ChangeNamingConventionTest.java @@ -0,0 +1,96 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.support.change; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class ChangeNamingConventionTest { + + @Nested + @DisplayName("Valid names — order extracted correctly") + class ValidNames { + + @Test + @DisplayName("Should extract numeric order from class simple name") + void shouldExtractNumericOrder() { + assertEquals("0002", ChangeNamingConvention.extractOrder("_0002__FeedClients")); + } + + @Test + @DisplayName("Should extract date-based order") + void shouldExtractDateBasedOrder() { + assertEquals("20250101_01", ChangeNamingConvention.extractOrder("_20250101_01__InitSchema")); + } + + @Test + @DisplayName("Should extract version-style order") + void shouldExtractVersionStyleOrder() { + assertEquals("V1_2_3", ChangeNamingConvention.extractOrder("_V1_2_3__LegacyMigration")); + } + + @Test + @DisplayName("Should extract single-digit order") + void shouldExtractSingleDigitOrder() { + assertEquals("1", ChangeNamingConvention.extractOrder("_1__MyChange")); + } + + @Test + @DisplayName("Should work identically for file names without extension") + void shouldWorkForFileNames() { + assertEquals("0003", ChangeNamingConvention.extractOrder("_0003__create_users")); + } + } + + @Nested + @DisplayName("Invalid names — null returned") + class InvalidNames { + + @Test + @DisplayName("Should return null when name is null") + void shouldReturnNullForNull() { + assertNull(ChangeNamingConvention.extractOrder(null)); + } + + @Test + @DisplayName("Should return null when name does not start with underscore") + void shouldReturnNullWhenNoLeadingUnderscore() { + assertNull(ChangeNamingConvention.extractOrder("FeedClients")); + } + + @Test + @DisplayName("Should return null when name starts with underscore but has no double-underscore separator") + void shouldReturnNullWhenNoDoubleSeparator() { + assertNull(ChangeNamingConvention.extractOrder("_FeedClients")); + } + + @Test + @DisplayName("Should return null when double-underscore is at the very start") + void shouldReturnNullWhenSeparatorAtStart() { + assertNull(ChangeNamingConvention.extractOrder("__FeedClients")); + } + + @Test + @DisplayName("Should return null for empty string") + void shouldReturnNullForEmptyString() { + assertNull(ChangeNamingConvention.extractOrder("")); + } + } +} diff --git a/core/flamingock-test-support/src/test/java/io/flamingock/support/change/ChangeValidatorTest.java b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/ChangeValidatorTest.java new file mode 100644 index 000000000..efd88f9b9 --- /dev/null +++ b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/ChangeValidatorTest.java @@ -0,0 +1,414 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.support.change; + +import io.flamingock.api.RecoveryStrategy; +import io.flamingock.support.change.fixtures.NoApplyMethodChange; +import io.flamingock.support.change.fixtures.NoChangeAnnotationClass; +import io.flamingock.support.change.fixtures.NoOrderPrefixChange; +import io.flamingock.support.change.fixtures._0001__FullyAnnotatedChange; +import io.flamingock.support.change.fixtures._0002__NonTransactionalChange; +import io.flamingock.support.change.fixtures._0003__NoTargetSystemChange; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ChangeValidatorTest { + + @Nested + @DisplayName("Construction") + class ConstructionTests { + + @Test + @DisplayName("Should throw IllegalArgumentException when class has no @Change annotation") + void shouldThrowWhenNoChangeAnnotation() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ChangeValidator.of(NoChangeAnnotationClass.class)); + + assertTrue(ex.getMessage().contains(NoChangeAnnotationClass.class.getName())); + } + + @Test + @DisplayName("Should throw IllegalArgumentException when class has no @Apply method") + void shouldThrowWhenNoApplyMethod() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ChangeValidator.of(NoApplyMethodChange.class)); + + assertTrue(ex.getMessage().contains(NoApplyMethodChange.class.getName())); + } + + @Test + @DisplayName("Should throw NullPointerException when changeClass is null") + void shouldThrowWhenChangeClassIsNull() { + assertThrows(NullPointerException.class, () -> ChangeValidator.of(null)); + } + + @Test + @DisplayName("Should construct successfully for a valid change class") + void shouldConstructSuccessfully() { + assertDoesNotThrow(() -> ChangeValidator.of(_0001__FullyAnnotatedChange.class)); + } + + @Test + @DisplayName("Should pass validate() with no assertions added") + void shouldPassValidateWithNoAssertions() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class); + + assertDoesNotThrow(validator::validate); + } + } + + @Nested + @DisplayName("withId") + class WithIdTests { + + @Test + @DisplayName("Should pass when id matches") + void shouldPassWhenIdMatches() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withId("fully-annotated"); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when id does not match") + void shouldFailWhenIdDoesNotMatch() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withId("wrong-id"); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withId")); + assertTrue(error.getMessage().contains("wrong-id")); + assertTrue(error.getMessage().contains("fully-annotated")); + } + } + + @Nested + @DisplayName("withAuthor") + class WithAuthorTests { + + @Test + @DisplayName("Should pass when author matches") + void shouldPassWhenAuthorMatches() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withAuthor("test-author"); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when author does not match") + void shouldFailWhenAuthorDoesNotMatch() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withAuthor("wrong-author"); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withAuthor")); + assertTrue(error.getMessage().contains("wrong-author")); + assertTrue(error.getMessage().contains("test-author")); + } + } + + @Nested + @DisplayName("withOrder") + class WithOrderTests { + + @Test + @DisplayName("Should pass when order matches the class name prefix") + void shouldPassWhenOrderMatches() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withOrder("0001"); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should pass for a different valid order prefix") + void shouldPassForDifferentValidOrderPrefix() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + .withOrder("0002"); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when order does not match") + void shouldFailWhenOrderDoesNotMatch() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withOrder("9999"); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withOrder")); + assertTrue(error.getMessage().contains("9999")); + assertTrue(error.getMessage().contains("0001")); + } + + @Test + @DisplayName("Should fail with descriptive message when class name has no order prefix") + void shouldFailWithDescriptiveMessageWhenNoOrderPrefix() { + CodeBasedChangeValidator validator = ChangeValidator.of(NoOrderPrefixChange.class) + .withOrder("0001"); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withOrder")); + assertTrue(error.getMessage().contains("NoOrderPrefixChange")); + assertTrue(error.getMessage().contains("_ORDER__Name")); + } + } + + @Nested + @DisplayName("isTransactional") + class IsTransactionalTests { + + @Test + @DisplayName("Should pass when change is transactional via explicit annotation value") + void shouldPassWhenTransactional() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .isTransactional(); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should pass when change uses default transactional=true") + void shouldPassForDefaultTransactional() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0003__NoTargetSystemChange.class) + .isTransactional(); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when change is not transactional") + void shouldFailWhenNotTransactional() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + .isTransactional(); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("isTransactional")); + } + } + + @Nested + @DisplayName("isNotTransactional") + class IsNotTransactionalTests { + + @Test + @DisplayName("Should pass when change is not transactional") + void shouldPassWhenNotTransactional() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + .isNotTransactional(); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when change is transactional") + void shouldFailWhenTransactional() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .isNotTransactional(); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("isNotTransactional")); + } + } + + @Nested + @DisplayName("withTargetSystem") + class WithTargetSystemTests { + + @Test + @DisplayName("Should pass when target system id matches") + void shouldPassWhenTargetSystemMatches() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withTargetSystem("mongodb"); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when target system id does not match") + void shouldFailWhenTargetSystemDoesNotMatch() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withTargetSystem("postgresql"); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withTargetSystem")); + assertTrue(error.getMessage().contains("postgresql")); + assertTrue(error.getMessage().contains("mongodb")); + } + + @Test + @DisplayName("Should fail when @TargetSystem annotation is not present") + void shouldFailWhenTargetSystemAnnotationAbsent() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0003__NoTargetSystemChange.class) + .withTargetSystem("mongodb"); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withTargetSystem")); + assertTrue(error.getMessage().contains("none is declared")); + } + } + + @Nested + @DisplayName("withRecovery") + class WithRecoveryTests { + + @Test + @DisplayName("Should pass for ALWAYS_RETRY when @Recovery is present with ALWAYS_RETRY") + void shouldPassForAlwaysRetryWhenAnnotationPresent() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withRecovery(RecoveryStrategy.ALWAYS_RETRY); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should pass for MANUAL_INTERVENTION when @Recovery annotation is absent") + void shouldPassForDefaultWhenAnnotationAbsent() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + .withRecovery(RecoveryStrategy.MANUAL_INTERVENTION); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when recovery strategy does not match") + void shouldFailWhenRecoveryDoesNotMatch() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withRecovery(RecoveryStrategy.MANUAL_INTERVENTION); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withRecovery")); + assertTrue(error.getMessage().contains("MANUAL_INTERVENTION")); + assertTrue(error.getMessage().contains("ALWAYS_RETRY")); + } + + @Test + @DisplayName("Should fail when ALWAYS_RETRY expected but default MANUAL_INTERVENTION is in effect") + void shouldFailWhenAlwaysRetryExpectedButDefaultApplies() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + .withRecovery(RecoveryStrategy.ALWAYS_RETRY); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withRecovery")); + assertTrue(error.getMessage().contains("ALWAYS_RETRY")); + assertTrue(error.getMessage().contains("MANUAL_INTERVENTION")); + } + } + + @Nested + @DisplayName("hasRollbackMethod") + class HasRollbackMethodTests { + + @Test + @DisplayName("Should pass when @Rollback method is present") + void shouldPassWhenRollbackPresent() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .hasRollbackMethod(); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when no @Rollback method is present") + void shouldFailWhenRollbackAbsent() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + .hasRollbackMethod(); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("hasRollbackMethod")); + assertTrue(error.getMessage().contains(_0002__NonTransactionalChange.class.getSimpleName())); + } + } + + @Nested + @DisplayName("Aggregated Failures") + class AggregatedFailuresTests { + + @Test + @DisplayName("Should report all failures in a single AssertionError") + void shouldReportAllFailuresTogether() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withId("wrong-id") + .withAuthor("wrong-author") + .withOrder("9999"); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withId")); + assertTrue(error.getMessage().contains("withAuthor")); + assertTrue(error.getMessage().contains("withOrder")); + } + + @Test + @DisplayName("Should only report failed assertions, not passing ones") + void shouldOnlyReportFailedAssertions() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withId("fully-annotated") + .withAuthor("wrong-author"); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withAuthor")); + assertFalse(error.getMessage().contains("withId")); + } + + @Test + @DisplayName("Should include the change class simple name in the error header") + void shouldIncludeClassNameInErrorHeader() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withId("wrong-id"); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains(_0001__FullyAnnotatedChange.class.getSimpleName())); + } + + @Test + @DisplayName("Should combine assertions across all assertion types") + void shouldCombineAssertionsAcrossAllTypes() { + CodeBasedChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + .withId("wrong-id") + .isTransactional() + .withTargetSystem("wrong-system") + .withRecovery(RecoveryStrategy.ALWAYS_RETRY) + .hasRollbackMethod(); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withId")); + assertTrue(error.getMessage().contains("isTransactional")); + assertTrue(error.getMessage().contains("withTargetSystem")); + assertTrue(error.getMessage().contains("withRecovery")); + assertTrue(error.getMessage().contains("hasRollbackMethod")); + } + } +} diff --git a/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoApplyMethodChange.java b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoApplyMethodChange.java new file mode 100644 index 000000000..866ebd3f2 --- /dev/null +++ b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoApplyMethodChange.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.support.change.fixtures; + +import io.flamingock.api.annotations.Change; + +/** + * Fixture for ChangeValidatorTest. + * Has @Change but no @Apply method — used to verify that ChangeValidator.of() throws + * IllegalArgumentException when the mandatory apply method is missing. + */ +@Change(id = "no-apply", author = "test-author") +public class NoApplyMethodChange { + + public void doSomething() { + } +} diff --git a/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoChangeAnnotationClass.java b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoChangeAnnotationClass.java new file mode 100644 index 000000000..853fcc7c5 --- /dev/null +++ b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoChangeAnnotationClass.java @@ -0,0 +1,27 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.support.change.fixtures; + +/** + * Fixture for ChangeValidatorTest. + * Intentionally missing @Change — used to verify that ChangeValidator.of() throws + * IllegalArgumentException when the class is not a change. + */ +public class NoChangeAnnotationClass { + + public void apply() { + } +} diff --git a/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoOrderPrefixChange.java b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoOrderPrefixChange.java new file mode 100644 index 000000000..73d48ca73 --- /dev/null +++ b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoOrderPrefixChange.java @@ -0,0 +1,32 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.support.change.fixtures; + +import io.flamingock.api.annotations.Apply; +import io.flamingock.api.annotations.Change; + +/** + * Fixture for ChangeValidatorTest. + * Valid @Change and @Apply, but the class name does NOT follow the _ORDER__Name convention. + * Used to verify that withOrder() produces a descriptive error when order cannot be extracted. + */ +@Change(id = "no-order-prefix", author = "test-author") +public class NoOrderPrefixChange { + + @Apply + public void apply() { + } +} diff --git a/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0001__FullyAnnotatedChange.java b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0001__FullyAnnotatedChange.java new file mode 100644 index 000000000..3cf27c8ce --- /dev/null +++ b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0001__FullyAnnotatedChange.java @@ -0,0 +1,43 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.support.change.fixtures; + +import io.flamingock.api.RecoveryStrategy; +import io.flamingock.api.annotations.Apply; +import io.flamingock.api.annotations.Change; +import io.flamingock.api.annotations.Recovery; +import io.flamingock.api.annotations.Rollback; +import io.flamingock.api.annotations.TargetSystem; + +/** + * Fixture change for ChangeValidatorTest. + * All optional annotations present: @TargetSystem, @Recovery(ALWAYS_RETRY), @Rollback. + * transactional=true (explicit). + * order = "0001" (from class name prefix). + */ +@Change(id = "fully-annotated", author = "test-author", transactional = true) +@TargetSystem(id = "mongodb") +@Recovery(strategy = RecoveryStrategy.ALWAYS_RETRY) +public class _0001__FullyAnnotatedChange { + + @Apply + public void apply() { + } + + @Rollback + public void rollback() { + } +} diff --git a/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0002__NonTransactionalChange.java b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0002__NonTransactionalChange.java new file mode 100644 index 000000000..78e3c69c5 --- /dev/null +++ b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0002__NonTransactionalChange.java @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.support.change.fixtures; + +import io.flamingock.api.annotations.Apply; +import io.flamingock.api.annotations.Change; +import io.flamingock.api.annotations.TargetSystem; + +/** + * Fixture change for ChangeValidatorTest. + * transactional=false, @TargetSystem present, no @Recovery (default = MANUAL_INTERVENTION), + * no @Rollback. + * order = "0002" (from class name prefix). + */ +@Change(id = "non-transactional", author = "test-author", transactional = false) +@TargetSystem(id = "kafka") +public class _0002__NonTransactionalChange { + + @Apply + public void apply() { + } +} diff --git a/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0003__NoTargetSystemChange.java b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0003__NoTargetSystemChange.java new file mode 100644 index 000000000..22aa23dd1 --- /dev/null +++ b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0003__NoTargetSystemChange.java @@ -0,0 +1,32 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 io.flamingock.support.change.fixtures; + +import io.flamingock.api.annotations.Apply; +import io.flamingock.api.annotations.Change; + +/** + * Fixture change for ChangeValidatorTest. + * No @TargetSystem, no @Recovery, no @Rollback. transactional=true (default). + * order = "0003" (from class name prefix). + */ +@Change(id = "no-target-system", author = "test-author") +public class _0003__NoTargetSystemChange { + + @Apply + public void apply() { + } +}