diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/validation/impl/AuditEntryExpectation.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/validation/impl/AuditEntryExpectation.java
index f2c75a984..eb8f583b4 100644
--- a/core/flamingock-test-support/src/main/java/io/flamingock/support/validation/impl/AuditEntryExpectation.java
+++ b/core/flamingock-test-support/src/main/java/io/flamingock/support/validation/impl/AuditEntryExpectation.java
@@ -30,11 +30,11 @@
* against expected definitions. Users should not interact with this class directly;
* they should use {@link AuditEntryDefinition} instead.
*/
-class AuditEntryExpectation {
+public class AuditEntryExpectation {
private final AuditEntryDefinition definition;
- AuditEntryExpectation(AuditEntryDefinition definition) {
+ public AuditEntryExpectation(AuditEntryDefinition definition) {
this.definition = definition;
}
@@ -48,7 +48,7 @@ class AuditEntryExpectation {
* @param actual the actual audit entry to compare against
* @return list of field mismatch errors (empty if all match)
*/
- List compareWith(AuditEntry actual) {
+ public List compareWith(AuditEntry actual) {
List errors = new ArrayList<>();
// Required fields - always verified
diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/validation/impl/AuditSequenceStrictValidator.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/validation/impl/AuditSequenceStrictValidator.java
index d8d89b853..189a0dcbd 100644
--- a/core/flamingock-test-support/src/main/java/io/flamingock/support/validation/impl/AuditSequenceStrictValidator.java
+++ b/core/flamingock-test-support/src/main/java/io/flamingock/support/validation/impl/AuditSequenceStrictValidator.java
@@ -15,33 +15,111 @@
*/
package io.flamingock.support.validation.impl;
+import io.flamingock.internal.common.core.audit.AuditEntry;
import io.flamingock.internal.common.core.audit.AuditReader;
import io.flamingock.support.domain.AuditEntryDefinition;
import io.flamingock.support.validation.SimpleValidator;
-import io.flamingock.support.validation.error.ValidationResult;
+import io.flamingock.support.validation.error.*;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
+/**
+ * Validator that performs strict sequence validation of audit entries.
+ *
+ * This validator verifies that the actual audit entries match the expected
+ * sequence exactly, both in count and in field values. Checking:
+ *
+ * - Exact count match between expected and actual entries
+ * - Strict field-by-field validation for each entry at each index
+ * - Order preservation (expected[0] must match actual[0], etc.)
+ *
+ */
public class AuditSequenceStrictValidator implements SimpleValidator {
private static final String VALIDATOR_NAME = "Audit Sequence (Strict)";
private final AuditReader auditReader;
private final List expectations;
-
+ private final List actualEntries;
public AuditSequenceStrictValidator(AuditReader auditReader, AuditEntryDefinition... definitions) {
this.auditReader = auditReader;
this.expectations = Arrays.stream(definitions)
.map(AuditEntryExpectation::new)
.collect(Collectors.toList());
+ this.actualEntries = auditReader.getAuditHistory();
+ }
+
+ /**
+ * Internal constructor for direct list initialization (used by tests).
+ */
+ AuditSequenceStrictValidator(List expectedDefinitions, List actualEntries, AuditReader auditReader) {
+ this.expectations = expectedDefinitions != null
+ ? expectedDefinitions.stream()
+ .map(AuditEntryExpectation::new)
+ .collect(Collectors.toList())
+ : new ArrayList<>();
+ this.auditReader = auditReader;
+ this.actualEntries = actualEntries != null ? actualEntries : new ArrayList<>();
}
@Override
public ValidationResult validate() {
- // TODO: Implement actual validation logic
- return ValidationResult.success(VALIDATOR_NAME);
+ List allErrors = new ArrayList<>();
+
+ int expectedSize = expectations.size();
+ int actualSize = actualEntries.size();
+
+ if (expectedSize != actualSize) {
+ allErrors.add(new CountMismatchError(getExpectedChangeIds(), getActualChangeIds()));
+ }
+
+ allErrors.addAll(getValidationErrors(expectations, actualEntries));
+
+ if (allErrors.isEmpty()) {
+ return ValidationResult.success(VALIDATOR_NAME);
+ }
+
+ return ValidationResult.failure(VALIDATOR_NAME, allErrors.toArray(new ValidationError[0]));
+ }
+
+ private static List getValidationErrors(List expectedEntries, List actualEntries) {
+ List allErrors = new ArrayList<>();
+ if (expectedEntries.isEmpty()) {
+ return allErrors;
+ }
+ int actualSize = actualEntries.size();
+ int limit = Math.max(expectedEntries.size(), actualSize);
+
+ for (int i = 0; i < limit; i++) {
+ AuditEntryExpectation expected = i < expectedEntries.size() ? expectedEntries.get(i) : null;
+ AuditEntry actual = i < actualEntries.size() ? actualEntries.get(i) : null;
+ if( expected != null && actual != null) {
+ allErrors.addAll(expected.compareWith(actual));
+ } else if( expected != null) {
+ AuditEntryDefinition def = expected.getDefinition();
+ allErrors.add(new MissingEntryError(i, def.getChangeId(), def.getState()));
+ } else {
+ assert actual != null;
+ allErrors.add(new UnexpectedEntryError(i, actual.getTaskId(), actual.getState()));
+ }
+
+ }
+ return allErrors;
+ }
+
+ private List getExpectedChangeIds() {
+ return expectations.stream()
+ .map(exp -> exp.getDefinition().getChangeId())
+ .collect(Collectors.toList());
+ }
+
+ private List getActualChangeIds() {
+ return actualEntries.stream()
+ .map(AuditEntry::getTaskId)
+ .collect(Collectors.toList());
}
}
diff --git a/core/flamingock-test-support/src/test/java/io/flamingock/support/validation/impl/AuditSequenceStrictValidatorTest.java b/core/flamingock-test-support/src/test/java/io/flamingock/support/validation/impl/AuditSequenceStrictValidatorTest.java
new file mode 100644
index 000000000..dce673ac8
--- /dev/null
+++ b/core/flamingock-test-support/src/test/java/io/flamingock/support/validation/impl/AuditSequenceStrictValidatorTest.java
@@ -0,0 +1,296 @@
+/*
+ * Copyright 2025 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.validation.impl;
+
+import io.flamingock.internal.common.core.audit.AuditEntry;
+import io.flamingock.support.domain.AuditEntryDefinition;
+import io.flamingock.support.validation.error.CountMismatchError;
+import io.flamingock.support.validation.error.FieldMismatchError;
+import io.flamingock.support.validation.error.MissingEntryError;
+import io.flamingock.support.validation.error.UnexpectedEntryError;
+import io.flamingock.support.validation.error.ValidationResult;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static io.flamingock.internal.common.core.audit.AuditEntry.ExecutionType.EXECUTION;
+import static io.flamingock.internal.common.core.audit.AuditEntry.Status.APPLIED;
+import static io.flamingock.internal.common.core.audit.AuditEntry.Status.FAILED;
+import static io.flamingock.support.domain.AuditEntryDefinition.APPLIED;
+import static io.flamingock.support.domain.AuditEntryDefinition.FAILED;
+import static org.junit.jupiter.api.Assertions.*;
+
+class AuditSequenceStrictValidatorTest {
+
+ private List actualEntries;
+
+ @BeforeEach
+ void setUp() {
+ actualEntries = Arrays.asList(
+ createAuditEntry("change-1", APPLIED),
+ createAuditEntry("change-2", APPLIED),
+ createAuditEntry("change-3", FAILED)
+ );
+ }
+
+ @Test
+ @DisplayName("AuditSequenceStrictValidator passes when entries match exactly")
+ void shouldPassValidation_whenEntriesMatchExactly() {
+ List expectedDefinitions = Arrays.asList(
+ APPLIED("change-1"),
+ APPLIED("change-2"),
+ FAILED("change-3")
+ );
+
+ AuditSequenceStrictValidator validator = new AuditSequenceStrictValidator(expectedDefinitions, actualEntries, null);
+ ValidationResult result = validator.validate();
+
+ assertTrue(result.isSuccess());
+ }
+
+ @Test
+ @DisplayName("AuditSequenceStrictValidator fails when counts mismatch")
+ void shouldFailValidation_whenCountMismatch() {
+ List expectedDefinitions = Arrays.asList(
+ APPLIED("change-1"),
+ APPLIED("change-2")
+ );
+
+ AuditSequenceStrictValidator validator = new AuditSequenceStrictValidator(expectedDefinitions, actualEntries, null);
+ ValidationResult result = validator.validate();
+
+ assertFalse(result.isSuccess());
+ assertEquals(2, result.getErrors().size());
+ assertTrue(result.getErrors().stream().anyMatch(e -> e instanceof CountMismatchError));
+ assertTrue(result.getErrors().stream().anyMatch(e -> e instanceof UnexpectedEntryError));
+ }
+
+
+ @Test
+ @DisplayName("AuditSequenceStrictValidator fails when a field status mismatches")
+ void shouldFailValidation_whenStatusMismatch() {
+ List expectedDefinitions = Arrays.asList(
+ APPLIED("change-1"),
+ APPLIED("change-2"),
+ APPLIED("change-3") // Expected APPLIED but actual is FAILED
+ );
+
+ AuditSequenceStrictValidator validator = new AuditSequenceStrictValidator(expectedDefinitions, actualEntries, null);
+ ValidationResult result = validator.validate();
+
+ assertFalse(result.isSuccess());
+ assertEquals(1, result.getErrors().size());
+ assertInstanceOf(FieldMismatchError.class, result.getErrors().get(0));
+
+ FieldMismatchError error = (FieldMismatchError) result.getErrors().get(0);
+ assertEquals("status", error.getFieldName());
+ }
+
+ @Test
+ @DisplayName("AuditSequenceStrictValidator fails when changeId mismatches")
+ void shouldFailValidation_whenChangeIdMismatch() {
+ List expectedDefinitions = Arrays.asList(
+ APPLIED("change-1"),
+ APPLIED("wrong-id"), // Mismatch
+ AuditEntryDefinition.FAILED("change-3")
+ );
+
+ AuditSequenceStrictValidator validator = new AuditSequenceStrictValidator(expectedDefinitions, actualEntries, null);
+ ValidationResult result = validator.validate();
+
+ assertFalse(result.isSuccess());
+ assertEquals(1, result.getErrors().size());
+ assertInstanceOf(FieldMismatchError.class, result.getErrors().get(0));
+
+ FieldMismatchError error = (FieldMismatchError) result.getErrors().get(0);
+ assertEquals("changeId", error.getFieldName());
+ }
+
+ @Test
+ @DisplayName("AuditSequenceStrictValidator fails when an expected entry is missing (count + missing entry error)")
+ void shouldFailValidation_whenMissingEntry() {
+ List actualEntriesSubset = Arrays.asList(
+ createAuditEntry("change-1", APPLIED),
+ createAuditEntry("change-2", APPLIED)
+ );
+
+ List expectedDefinitions = Arrays.asList(
+ APPLIED("change-1"),
+ APPLIED("change-2"),
+ AuditEntryDefinition.FAILED("change-3")
+ );
+
+ AuditSequenceStrictValidator validator = new AuditSequenceStrictValidator(expectedDefinitions, actualEntriesSubset, null);
+ ValidationResult result = validator.validate();
+
+ assertFalse(result.isSuccess());
+ assertTrue(result.getErrors().stream().anyMatch(e -> e instanceof CountMismatchError));
+ assertTrue(result.getErrors().stream().anyMatch(e -> e instanceof MissingEntryError));
+ MissingEntryError missing = (MissingEntryError) result.getErrors().stream()
+ .filter(e -> e instanceof MissingEntryError)
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("Expected a MissingEntryError but none was found"));
+ assertEquals(2, missing.getExpectedIndex());
+ assertEquals("change-3", missing.getExpectedChangeId());
+ }
+
+ @Test
+ @DisplayName("AuditSequenceStrictValidator fails when there is an unexpected actual entry (count + unexpected entry error)")
+ void shouldFailValidation_whenUnexpectedEntry() {
+ List actualEntriesExtra = Arrays.asList(
+ createAuditEntry("change-1", APPLIED),
+ createAuditEntry("change-2", APPLIED),
+ createAuditEntry("change-3", FAILED),
+ createAuditEntry("change-4", APPLIED)
+ );
+
+ List expectedDefinitions = Arrays.asList(
+ APPLIED("change-1"),
+ APPLIED("change-2"),
+ AuditEntryDefinition.FAILED("change-3")
+ );
+
+ AuditSequenceStrictValidator validator = new AuditSequenceStrictValidator(expectedDefinitions, actualEntriesExtra, null);
+ ValidationResult result = validator.validate();
+
+ assertFalse(result.isSuccess());
+ assertTrue(result.getErrors().stream().anyMatch(e -> e instanceof CountMismatchError));
+ assertTrue(result.getErrors().stream().anyMatch(e -> e instanceof UnexpectedEntryError));
+ UnexpectedEntryError unexpected = (UnexpectedEntryError) result.getErrors().stream()
+ .filter(e -> e instanceof UnexpectedEntryError)
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("Expected an UnexpectedEntryError but none was found"));
+ assertEquals(3, unexpected.getIndex());
+ assertEquals("change-4", unexpected.getActualChangeId());
+ }
+
+ @Test
+ @DisplayName("AuditSequenceStrictValidator passes when optional fields match")
+ void shouldPassValidation_whenOptionalFieldsMatch() {
+ AuditEntry actualWithOptionalFields = new AuditEntry(
+ "exec-1",
+ "stage-1",
+ "change-1",
+ "author",
+ LocalDateTime.now(),
+ APPLIED,
+ EXECUTION,
+ "com.example.Change",
+ "apply",
+ 100L,
+ "host",
+ null,
+ false,
+ null,
+ null,
+ "target-1",
+ "1",
+ null,
+ true
+ );
+
+ AuditEntryDefinition expectedWithOptionalFields = APPLIED("change-1")
+ .withAuthor("author")
+ .withClassName("com.example.Change")
+ .withMethodName("apply")
+ .withTargetSystemId("target-1")
+ .withOrder("1")
+ .withTransactional(true);
+
+ AuditSequenceStrictValidator validator = new AuditSequenceStrictValidator(
+ Collections.singletonList(expectedWithOptionalFields),
+ Collections.singletonList(actualWithOptionalFields),
+ null
+ );
+
+ ValidationResult result = validator.validate();
+
+ assertTrue(result.isSuccess());
+ }
+
+ @Test
+ @DisplayName("AuditSequenceStrictValidator fails when an optional field mismatches")
+ void shouldFailValidation_whenOptionalFieldMismatch() {
+ AuditEntry actualEntry = new AuditEntry(
+ "exec-1",
+ "stage-1",
+ "change-1",
+ "author",
+ LocalDateTime.now(),
+ APPLIED,
+ EXECUTION,
+ "com.example.Change",
+ "apply",
+ 100L,
+ "host",
+ null,
+ false,
+ null,
+ null,
+ "target-1",
+ null,
+ null,
+ null
+ );
+
+ AuditEntryDefinition expectedWithDifferentOptional = APPLIED("change-1")
+ .withTargetSystemId("different-target");
+
+ AuditSequenceStrictValidator validator = new AuditSequenceStrictValidator(
+ Collections.singletonList(expectedWithDifferentOptional),
+ Collections.singletonList(actualEntry),
+ null
+ );
+
+ ValidationResult result = validator.validate();
+
+ assertFalse(result.isSuccess());
+ assertEquals(1, result.getErrors().size());
+ assertInstanceOf(FieldMismatchError.class, result.getErrors().get(0));
+
+ FieldMismatchError error = (FieldMismatchError) result.getErrors().get(0);
+ assertEquals("targetSystemId", error.getFieldName());
+ }
+
+ private AuditEntry createAuditEntry(String changeId, AuditEntry.Status status) {
+ return new AuditEntry(
+ "exec-id",
+ "stage-id",
+ changeId,
+ "test-author",
+ LocalDateTime.now(),
+ status,
+ EXECUTION,
+ "com.example.TestChange",
+ "apply",
+ 100L,
+ "localhost",
+ null,
+ false,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null
+ );
+ }
+}