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.
+ * + * @paramValidates 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 ListOrder 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(SupplierAll 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() { + ListReads 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}.
+ * + *{@link #of(Class)} checks eagerly that: + *
{@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 ChangeValidatorOnly 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() { + } +}