From 7ecb849f7c0c0e63613fade8f27b6b7620674db7 Mon Sep 17 00:00:00 2001 From: davidfrigolet Date: Fri, 27 Feb 2026 18:38:28 +0000 Subject: [PATCH] feat: template based change validator --- .../support/change/ChangeValidator.java | 16 + .../change/TemplateBasedChangeValidator.java | 278 ++++++++++ .../support/change/ChangeValidatorTest.java | 2 +- .../TemplateBasedChangeValidatorTest.java | 513 ++++++++++++++++++ .../fixtures/_0001__simple_with_rollback.yaml | 7 + .../fixtures/_0002__simple_no_rollback.yaml | 4 + .../_0003__multi_step_all_rollback.yaml | 9 + .../_0004__multi_step_partial_rollback.yaml | 6 + .../_0005__with_author_and_recovery.yaml | 9 + 9 files changed, 843 insertions(+), 1 deletion(-) create mode 100644 core/flamingock-test-support/src/main/java/io/flamingock/support/change/TemplateBasedChangeValidator.java create mode 100644 core/flamingock-test-support/src/test/java/io/flamingock/support/change/TemplateBasedChangeValidatorTest.java create mode 100644 core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0001__simple_with_rollback.yaml create mode 100644 core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0002__simple_no_rollback.yaml create mode 100644 core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0003__multi_step_all_rollback.yaml create mode 100644 core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0004__multi_step_partial_rollback.yaml create mode 100644 core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0005__with_author_and_recovery.yaml 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 index a907494c7..e67f76375 100644 --- 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 @@ -18,6 +18,7 @@ import io.flamingock.api.RecoveryStrategy; import org.jetbrains.annotations.NotNull; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -62,6 +63,21 @@ public static CodeBasedChangeValidator of(Class changeClass) { return new CodeBasedChangeValidator(changeClass); } + /** + * Creates a {@code ChangeValidator} for the given template-based change YAML file. + * + *

Validates eagerly that the file exists, the {@code id} and {@code template} fields are + * present and non-empty, and that either an {@code apply} field or a {@code steps} list is + * present.

+ * + * @param yamlPath path to the YAML change file; must not be {@code null} + * @return a new validator ready for assertion chaining + * @throws IllegalArgumentException if the file does not exist or required fields are missing + */ + public static TemplateBasedChangeValidator of(Path yamlPath) { + return new TemplateBasedChangeValidator(yamlPath); + } + /** Display name used in error messages (class simple name or file name). */ protected final String displayName; diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/TemplateBasedChangeValidator.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/TemplateBasedChangeValidator.java new file mode 100644 index 000000000..9a66c66c8 --- /dev/null +++ b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/TemplateBasedChangeValidator.java @@ -0,0 +1,278 @@ +/* + * 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.internal.common.core.task.RecoveryDescriptor; +import io.flamingock.internal.common.core.template.ChangeTemplateFileContent; +import io.flamingock.internal.util.FileUtil; + +import java.io.File; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +/** + * Fluent assertion utility for validating that a template-based change YAML file is correctly + * structured. + * + *

Parses the YAML file using the same {@link ChangeTemplateFileContent} model that the + * Flamingock runtime uses, then exposes assertions for the fields that are meaningful for + * template changes: id, author, template name, transactionality, target system, recovery + * strategy, step count, and rollback presence.

+ * + *

All assertions use a soft-assertion pattern: each chained call queues an + * assertion, and {@link #validate()} executes them all together, collecting every failure into a + * single {@link AssertionError}. This means you see all problems at once rather than stopping at + * the first mismatch.

+ * + *

Implicit validation at construction

+ *

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

+ * + *

Usage example — simple template

+ *
{@code
+ * ChangeValidator.of(Paths.get("src/test/java/.../changes/_0001__create_users_collection.yaml"))
+ *     .withId("create-users-collection")
+ *     .withOrder("0001")
+ *     .withTemplateName("MongoChangeTemplate")
+ *     .isNotTransactional()
+ *     .hasRollback()
+ *     .validate();
+ * }
+ * + *

Usage example — multi-step template

+ *
{@code
+ * ChangeValidator.of(Paths.get("src/test/java/.../changes/_0005__step_based_change.yaml"))
+ *     .withId("step-based-change")
+ *     .withOrder("0005")
+ *     .withStepCount(3)
+ *     .hasRollbackForStep(0)
+ *     .validate();
+ * }
+ * + * @see ChangeValidator + */ +public final class TemplateBasedChangeValidator extends ChangeValidator { + + private final ChangeTemplateFileContent content; + + TemplateBasedChangeValidator(Path yamlPath) { + super( + nameWithoutExtension(yamlPath), + ChangeNamingConvention.extractOrder(nameWithoutExtension(yamlPath)) + ); + File file = yamlPath.toFile(); + if (!file.exists()) { + throw new IllegalArgumentException( + String.format("YAML file does not exist: %s", yamlPath.toAbsolutePath())); + } + this.content = FileUtil.getFromYamlFile(file, ChangeTemplateFileContent.class); + + if (content.getId() == null || content.getId().isEmpty()) { + throw new IllegalArgumentException( + String.format("YAML file [%s] must have a non-empty 'id' field", displayName)); + } + if (content.getTemplate() == null || content.getTemplate().isEmpty()) { + throw new IllegalArgumentException( + String.format("YAML file [%s] must have a non-empty 'template' field", displayName)); + } + if (content.getApply() == null && !(content.getSteps() instanceof List)) { + throw new IllegalArgumentException( + String.format("YAML file [%s] must have either an 'apply' field or a 'steps' list", displayName)); + } + } + + private static String nameWithoutExtension(Path yamlPath) { + String fileName = yamlPath.getFileName().toString(); + int dotIndex = fileName.lastIndexOf('.'); + return dotIndex > 0 ? fileName.substring(0, dotIndex) : fileName; + } + + @Override + protected String getId() { + return content.getId(); + } + + @Override + protected String getAuthor() { + return content.getAuthor(); + } + + /** + * Asserts that the author field in the YAML matches the expected value. + * + *

Overrides the base implementation to handle the case where no {@code author} field is + * present in the YAML (in which case {@link #getAuthor()} returns {@code null}). Calling this + * method with a non-null {@code expected} when the YAML has no author reports a clear failure + * message rather than throwing a {@link NullPointerException}.

+ * + * @param expected the expected author string, or {@code null} to assert no author is set + * @return this validator for chaining + */ + @Override + public TemplateBasedChangeValidator withAuthor(String expected) { + addAssertion(() -> { + String actual = getAuthor(); + if (expected == null && actual == null) { + return ChangeValidatorResult.OK(); + } + if (actual == null) { + return ChangeValidatorResult.error(String.format( + "withAuthor: expected \"%s\" but no 'author' field is set in the YAML", expected)); + } + return actual.equals(expected) + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format( + "withAuthor: expected \"%s\" but was \"%s\"", expected, actual)); + }); + return this; + } + + @Override + protected boolean isTransactionalValue() { + return content.getTransactional() == null || content.getTransactional(); + } + + @Override + protected String getTargetSystemId() { + return content.getTargetSystem() != null ? content.getTargetSystem().getId() : null; + } + + @Override + protected RecoveryStrategy getRecovery() { + RecoveryDescriptor recovery = content.getRecovery(); + return recovery != null ? recovery.getStrategy() : RecoveryStrategy.MANUAL_INTERVENTION; + } + + private boolean isMultiStep() { + return content.getSteps() instanceof List; + } + + /** + * Asserts that the {@code template} field in the YAML matches the expected template name. + * + * @param expected the expected template simple name (e.g. {@code "MongoChangeTemplate"}) + * @return this validator for chaining + */ + public TemplateBasedChangeValidator withTemplateName(String expected) { + addAssertion(() -> { + String actual = content.getTemplate(); + return actual.equals(expected) + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format( + "withTemplateName: expected \"%s\" but was \"%s\"", expected, actual)); + }); + return this; + } + + /** + * Asserts that the template has the given number of steps. + * + *

Reports a descriptive error if this is a simple (non-multi-step) template, i.e. the + * YAML has an {@code apply} field rather than a {@code steps} list.

+ * + * @param expected the expected step count + * @return this validator for chaining + */ + public TemplateBasedChangeValidator withStepCount(int expected) { + addAssertion(() -> { + if (!isMultiStep()) { + return ChangeValidatorResult.error( + "withStepCount: this is a simple template (no 'steps' list found); " + + "withStepCount is only applicable to multi-step templates"); + } + List steps = (List) content.getSteps(); + int actual = steps.size(); + return actual == expected + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format( + "withStepCount: expected %d steps but found %d", expected, actual)); + }); + return this; + } + + /** + * Asserts that a rollback is defined for the change. + * + * + * + * @return this validator for chaining + */ + public TemplateBasedChangeValidator hasRollback() { + addAssertion(() -> { + if (isMultiStep()) { + List steps = (List) content.getSteps(); + for (int i = 0; i < steps.size(); i++) { + Object step = steps.get(i); + if (!(step instanceof Map) || ((Map) step).get("rollback") == null) { + return ChangeValidatorResult.error(String.format( + "hasRollback: step %d is missing a 'rollback' field", i)); + } + } + return ChangeValidatorResult.OK(); + } else { + return content.getRollback() != null + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error("hasRollback: no top-level 'rollback' field found"); + } + }); + return this; + } + + /** + * Asserts that the step at the given 0-based index has a {@code rollback} field defined. + * + *

Reports a descriptive error if this is a simple (non-multi-step) template, or if the + * index is out of bounds.

+ * + * @param stepIndex 0-based index of the step to check + * @return this validator for chaining + */ + public TemplateBasedChangeValidator hasRollbackForStep(int stepIndex) { + addAssertion(() -> { + if (!isMultiStep()) { + return ChangeValidatorResult.error( + "hasRollbackForStep: this is a simple template (no 'steps' list found); " + + "hasRollbackForStep is only applicable to multi-step templates"); + } + List steps = (List) content.getSteps(); + if (stepIndex < 0 || stepIndex >= steps.size()) { + return ChangeValidatorResult.error(String.format( + "hasRollbackForStep: step index %d is out of bounds (template has %d steps)", + stepIndex, steps.size())); + } + Object step = steps.get(stepIndex); + if (step instanceof Map && ((Map) step).get("rollback") != null) { + return ChangeValidatorResult.OK(); + } + return ChangeValidatorResult.error(String.format( + "hasRollbackForStep: step %d is missing a 'rollback' field", stepIndex)); + }); + return this; + } +} 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 index efd88f9b9..a90e7f96a 100644 --- 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 @@ -55,7 +55,7 @@ void shouldThrowWhenNoApplyMethod() { @Test @DisplayName("Should throw NullPointerException when changeClass is null") void shouldThrowWhenChangeClassIsNull() { - assertThrows(NullPointerException.class, () -> ChangeValidator.of(null)); + assertThrows(NullPointerException.class, () -> ChangeValidator.of((Class) null)); } @Test diff --git a/core/flamingock-test-support/src/test/java/io/flamingock/support/change/TemplateBasedChangeValidatorTest.java b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/TemplateBasedChangeValidatorTest.java new file mode 100644 index 000000000..20bdc98e4 --- /dev/null +++ b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/TemplateBasedChangeValidatorTest.java @@ -0,0 +1,513 @@ +/* + * 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.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TemplateBasedChangeValidatorTest { + + private static final String FIXTURES_BASE = "io/flamingock/support/change/fixtures/"; + + private Path fixture(String fileName) { + try { + return Paths.get(Objects.requireNonNull(getClass().getClassLoader().getResource(FIXTURES_BASE + fileName)).toURI()); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } + + @Nested + @DisplayName("Construction") + class ConstructionTests { + + @Test + @DisplayName("Should throw IllegalArgumentException when file does not exist") + void shouldThrowWhenFileDoesNotExist() { + Path missing = Paths.get("/tmp/nonexistent_flamingock_fixture.yaml"); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ChangeValidator.of(missing)); + assertTrue(ex.getMessage().contains("does not exist")); + } + + @Test + @DisplayName("Should throw IllegalArgumentException when 'id' field is absent") + void shouldThrowWhenIdAbsent() throws Exception { + Path tmp = java.nio.file.Files.createTempFile("_0001__no_id", ".yaml"); + java.nio.file.Files.write(tmp, "template: MongoChangeTemplate\napply: something\n".getBytes()); + try { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ChangeValidator.of(tmp)); + assertTrue(ex.getMessage().contains("id")); + } finally { + java.nio.file.Files.deleteIfExists(tmp); + } + } + + @Test + @DisplayName("Should throw IllegalArgumentException when 'template' field is absent") + void shouldThrowWhenTemplateAbsent() throws Exception { + Path tmp = java.nio.file.Files.createTempFile("_0001__no_template", ".yaml"); + java.nio.file.Files.write(tmp, "id: some-id\napply: something\n".getBytes()); + try { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ChangeValidator.of(tmp)); + assertTrue(ex.getMessage().contains("template")); + } finally { + java.nio.file.Files.deleteIfExists(tmp); + } + } + + @Test + @DisplayName("Should throw IllegalArgumentException when both 'apply' and 'steps' are absent") + void shouldThrowWhenApplyAndStepsAbsent() throws Exception { + Path tmp = java.nio.file.Files.createTempFile("_0001__no_apply", ".yaml"); + java.nio.file.Files.write(tmp, "id: some-id\ntemplate: SomeTemplate\n".getBytes()); + try { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ChangeValidator.of(tmp)); + assertTrue(ex.getMessage().contains("apply") || ex.getMessage().contains("steps")); + } finally { + java.nio.file.Files.deleteIfExists(tmp); + } + } + + @Test + @DisplayName("Should construct successfully for a valid simple YAML file") + void shouldConstructSuccessfullyForSimpleTemplate() { + assertDoesNotThrow(() -> ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml"))); + } + + @Test + @DisplayName("Should construct successfully for a valid multi-step YAML file") + void shouldConstructSuccessfullyForMultiStepTemplate() { + assertDoesNotThrow(() -> ChangeValidator.of(fixture("_0003__multi_step_all_rollback.yaml"))); + } + + @Test + @DisplayName("Should pass validate() with no assertions added") + void shouldPassValidateWithNoAssertions() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")); + assertDoesNotThrow(validator::validate); + } + } + + @Nested + @DisplayName("withId") + class WithIdTests { + + @Test + @DisplayName("Should pass when id matches") + void shouldPassWhenIdMatches() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")) + .withId("simple-with-rollback"); + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when id does not match") + void shouldFailWhenIdDoesNotMatch() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")) + .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("simple-with-rollback")); + } + } + + @Nested + @DisplayName("withAuthor") + class WithAuthorTests { + + @Test + @DisplayName("Should pass when author matches") + void shouldPassWhenAuthorMatches() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0005__with_author_and_recovery.yaml")) + .withAuthor("test-author"); + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should pass when no author in YAML and withAuthor(null) is called") + void shouldPassWhenNoAuthorInYamlAndNullExpected() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")) + .withAuthor(null); + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when expected author but none is set in YAML") + void shouldFailWhenExpectedAuthorButNoneInYaml() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")) + .withAuthor("expected-author"); + AssertionError error = assertThrows(AssertionError.class, validator::validate); + assertTrue(error.getMessage().contains("withAuthor")); + assertTrue(error.getMessage().contains("expected-author")); + } + + @Test + @DisplayName("Should fail when author does not match") + void shouldFailWhenAuthorDoesNotMatch() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0005__with_author_and_recovery.yaml")) + .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 file name prefix") + void shouldPassWhenOrderMatches() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")) + .withOrder("0001"); + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when order does not match") + void shouldFailWhenOrderDoesNotMatch() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")) + .withOrder("9999"); + AssertionError error = assertThrows(AssertionError.class, validator::validate); + assertTrue(error.getMessage().contains("withOrder")); + assertTrue(error.getMessage().contains("9999")); + assertTrue(error.getMessage().contains("0001")); + } + } + + @Nested + @DisplayName("withTemplateName") + class WithTemplateNameTests { + + @Test + @DisplayName("Should pass when template name matches") + void shouldPassWhenTemplateNameMatches() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")) + .withTemplateName("MongoChangeTemplate"); + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when template name does not match") + void shouldFailWhenTemplateNameDoesNotMatch() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")) + .withTemplateName("WrongTemplate"); + AssertionError error = assertThrows(AssertionError.class, validator::validate); + assertTrue(error.getMessage().contains("withTemplateName")); + assertTrue(error.getMessage().contains("WrongTemplate")); + assertTrue(error.getMessage().contains("MongoChangeTemplate")); + } + } + + @Nested + @DisplayName("isTransactional") + class IsTransactionalTests { + + @Test + @DisplayName("Should pass when transactional: true is explicit in YAML") + void shouldPassWhenExplicitlyTransactional() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")) + .isTransactional(); + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should pass when transactional field is absent (defaults to true)") + void shouldPassWhenTransactionalFieldAbsent() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0003__multi_step_all_rollback.yaml")) + .isTransactional(); + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when transactional: false is set") + void shouldFailWhenTransactionalFalse() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0002__simple_no_rollback.yaml")) + .isTransactional(); + AssertionError error = assertThrows(AssertionError.class, validator::validate); + assertTrue(error.getMessage().contains("isTransactional")); + } + } + + @Nested + @DisplayName("isNotTransactional") + class IsNotTransactionalTests { + + @Test + @DisplayName("Should pass when transactional: false is set") + void shouldPassWhenTransactionalFalse() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0002__simple_no_rollback.yaml")) + .isNotTransactional(); + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when transactional: true is set") + void shouldFailWhenTransactionalTrue() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")) + .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() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")) + .withTargetSystem("mongodb"); + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when target system id does not match") + void shouldFailWhenTargetSystemDoesNotMatch() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")) + .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 is absent in YAML") + void shouldFailWhenTargetSystemAbsent() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0002__simple_no_rollback.yaml")) + .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.strategy is set in YAML") + void shouldPassForAlwaysRetry() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0005__with_author_and_recovery.yaml")) + .withRecovery(RecoveryStrategy.ALWAYS_RETRY); + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should pass for MANUAL_INTERVENTION when recovery field is absent (default)") + void shouldPassForDefaultManualIntervention() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")) + .withRecovery(RecoveryStrategy.MANUAL_INTERVENTION); + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when recovery strategy does not match") + void shouldFailWhenRecoveryDoesNotMatch() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0005__with_author_and_recovery.yaml")) + .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")); + } + } + + @Nested + @DisplayName("withStepCount") + class WithStepCountTests { + + @Test + @DisplayName("Should pass for multi-step template with correct step count") + void shouldPassForMultiStepWithCorrectCount() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0003__multi_step_all_rollback.yaml")) + .withStepCount(3); + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail for multi-step template with wrong step count") + void shouldFailForMultiStepWithWrongCount() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0003__multi_step_all_rollback.yaml")) + .withStepCount(5); + AssertionError error = assertThrows(AssertionError.class, validator::validate); + assertTrue(error.getMessage().contains("withStepCount")); + assertTrue(error.getMessage().contains("5")); + assertTrue(error.getMessage().contains("3")); + } + + @Test + @DisplayName("Should fail with descriptive error when applied to a simple template") + void shouldFailWithDescriptiveErrorOnSimpleTemplate() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")) + .withStepCount(1); + AssertionError error = assertThrows(AssertionError.class, validator::validate); + assertTrue(error.getMessage().contains("withStepCount")); + assertTrue(error.getMessage().contains("simple")); + } + } + + @Nested + @DisplayName("hasRollback") + class HasRollbackTests { + + @Test + @DisplayName("Should pass for simple template with top-level rollback") + void shouldPassForSimpleTemplateWithRollback() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")) + .hasRollback(); + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail for simple template without rollback") + void shouldFailForSimpleTemplateWithoutRollback() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0002__simple_no_rollback.yaml")) + .hasRollback(); + AssertionError error = assertThrows(AssertionError.class, validator::validate); + assertTrue(error.getMessage().contains("hasRollback")); + } + + @Test + @DisplayName("Should pass for multi-step template where all steps have rollback") + void shouldPassForMultiStepAllHaveRollback() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0003__multi_step_all_rollback.yaml")) + .hasRollback(); + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail for multi-step template where some steps are missing rollback") + void shouldFailForMultiStepPartialRollback() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0004__multi_step_partial_rollback.yaml")) + .hasRollback(); + AssertionError error = assertThrows(AssertionError.class, validator::validate); + assertTrue(error.getMessage().contains("hasRollback")); + assertTrue(error.getMessage().contains("step 1")); + } + } + + @Nested + @DisplayName("hasRollbackForStep") + class HasRollbackForStepTests { + + @Test + @DisplayName("Should pass when the specified step has a rollback") + void shouldPassWhenStepHasRollback() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0004__multi_step_partial_rollback.yaml")) + .hasRollbackForStep(0); + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when the specified step is missing rollback") + void shouldFailWhenStepMissingRollback() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0004__multi_step_partial_rollback.yaml")) + .hasRollbackForStep(1); + AssertionError error = assertThrows(AssertionError.class, validator::validate); + assertTrue(error.getMessage().contains("hasRollbackForStep")); + assertTrue(error.getMessage().contains("step 1")); + } + + @Test + @DisplayName("Should fail with descriptive error when applied to a simple template") + void shouldFailWithDescriptiveErrorOnSimpleTemplate() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")) + .hasRollbackForStep(0); + AssertionError error = assertThrows(AssertionError.class, validator::validate); + assertTrue(error.getMessage().contains("hasRollbackForStep")); + assertTrue(error.getMessage().contains("simple")); + } + } + + @Nested + @DisplayName("Aggregated failures") + class AggregatedFailuresTests { + + @Test + @DisplayName("Should report all failures in a single AssertionError") + void shouldReportAllFailuresTogether() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")) + .withId("wrong-id") + .withTemplateName("WrongTemplate") + .withOrder("9999"); + AssertionError error = assertThrows(AssertionError.class, validator::validate); + assertTrue(error.getMessage().contains("withId")); + assertTrue(error.getMessage().contains("withTemplateName")); + assertTrue(error.getMessage().contains("withOrder")); + } + + @Test + @DisplayName("Should only report failed assertions, not passing ones") + void shouldOnlyReportFailedAssertions() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")) + .withId("simple-with-rollback") + .withTemplateName("WrongTemplate"); + AssertionError error = assertThrows(AssertionError.class, validator::validate); + assertTrue(error.getMessage().contains("withTemplateName")); + assertFalse(error.getMessage().contains("withId")); + } + + @Test + @DisplayName("Should include the file name in the error header") + void shouldIncludeFileNameInErrorHeader() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0001__simple_with_rollback.yaml")) + .withId("wrong-id"); + AssertionError error = assertThrows(AssertionError.class, validator::validate); + assertTrue(error.getMessage().contains("_0001__simple_with_rollback")); + } + + @Test + @DisplayName("Should combine assertions across all assertion types") + void shouldCombineAssertionsAcrossAllTypes() { + TemplateBasedChangeValidator validator = ChangeValidator.of(fixture("_0002__simple_no_rollback.yaml")) + .withId("wrong-id") + .isTransactional() + .withTargetSystem("mongodb") + .withRecovery(RecoveryStrategy.ALWAYS_RETRY) + .hasRollback(); + 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("hasRollback")); + } + } +} diff --git a/core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0001__simple_with_rollback.yaml b/core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0001__simple_with_rollback.yaml new file mode 100644 index 000000000..236ed9a0e --- /dev/null +++ b/core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0001__simple_with_rollback.yaml @@ -0,0 +1,7 @@ +id: simple-with-rollback +template: MongoChangeTemplate +transactional: true +targetSystem: + id: mongodb +apply: "db.createCollection('users')" +rollback: "db.users.drop()" diff --git a/core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0002__simple_no_rollback.yaml b/core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0002__simple_no_rollback.yaml new file mode 100644 index 000000000..b734223c6 --- /dev/null +++ b/core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0002__simple_no_rollback.yaml @@ -0,0 +1,4 @@ +id: simple-no-rollback +template: MongoChangeTemplate +transactional: false +apply: "db.createCollection('logs')" diff --git a/core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0003__multi_step_all_rollback.yaml b/core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0003__multi_step_all_rollback.yaml new file mode 100644 index 000000000..a60bf1fa6 --- /dev/null +++ b/core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0003__multi_step_all_rollback.yaml @@ -0,0 +1,9 @@ +id: multi-step-all-rollback +template: MongoChangeTemplate +steps: + - apply: "db.createCollection('users')" + rollback: "db.users.drop()" + - apply: "db.createCollection('orders')" + rollback: "db.orders.drop()" + - apply: "db.createCollection('products')" + rollback: "db.products.drop()" diff --git a/core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0004__multi_step_partial_rollback.yaml b/core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0004__multi_step_partial_rollback.yaml new file mode 100644 index 000000000..82d6ad3d1 --- /dev/null +++ b/core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0004__multi_step_partial_rollback.yaml @@ -0,0 +1,6 @@ +id: multi-step-partial-rollback +template: MongoChangeTemplate +steps: + - apply: "db.createCollection('events')" + rollback: "db.events.drop()" + - apply: "db.createIndex('events', {name: 1})" diff --git a/core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0005__with_author_and_recovery.yaml b/core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0005__with_author_and_recovery.yaml new file mode 100644 index 000000000..487eb7f61 --- /dev/null +++ b/core/flamingock-test-support/src/test/resources/io/flamingock/support/change/fixtures/_0005__with_author_and_recovery.yaml @@ -0,0 +1,9 @@ +id: with-author-and-recovery +author: test-author +template: MongoChangeTemplate +targetSystem: + id: mongodb +recovery: + strategy: ALWAYS_RETRY +apply: "db.createCollection('audit')" +rollback: "db.audit.drop()"