diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/domain/AuditEntryExpectation.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/domain/AuditEntryExpectation.java index 264e51219..c393a8feb 100644 --- a/core/flamingock-test-support/src/main/java/io/flamingock/support/domain/AuditEntryExpectation.java +++ b/core/flamingock-test-support/src/main/java/io/flamingock/support/domain/AuditEntryExpectation.java @@ -16,11 +16,15 @@ package io.flamingock.support.domain; import io.flamingock.api.annotations.Apply; +import io.flamingock.api.annotations.Change; +import io.flamingock.api.annotations.Rollback; +import io.flamingock.api.annotations.TargetSystem; import io.flamingock.internal.common.core.audit.AuditEntry; -import io.flamingock.internal.common.core.audit.AuditTxType; import io.flamingock.support.stages.ThenStage; import io.flamingock.support.stages.WhenStage; +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; import java.time.LocalDateTime; import static io.flamingock.internal.common.core.audit.AuditEntry.Status.APPLIED; @@ -33,7 +37,7 @@ * *

This class provides a fluent API for specifying the expected values of an audit entry * that should be created during change execution. It supports selective field verification, - * meaning only the fields explicitly set via {@code withXxx()} methods will be validated.

+ * meaning only fields with non-null expected values will be validated.

* *

Basic Usage

*

Create expectations using the static factory methods and optionally chain field specifications:

@@ -41,25 +45,19 @@ * // Simple expectation - only verifies change ID and status * AuditEntryExpectation.APPLIED("my-change-id") * - * // Detailed expectation - verifies additional fields - * AuditEntryExpectation.APPLIED("my-change-id") - * .withClass(MyChange.class) - * .withAuthor("dev-team") + * // Class-based expectation - auto-extracts metadata from annotations + * AuditEntryExpectation.APPLIED(MyChange.class) + * + * // Detailed expectation with overrides + * AuditEntryExpectation.APPLIED(MyChange.class) + * .withAuthor("custom-author") * .withTargetSystemId("mongodb-main") * } * - *

Selective Field Verification

- *

By default, only the change ID and status (specified via factory methods) are verified. - * Additional fields are only verified when explicitly set:

- * - * *

Factory Methods

+ *

Two variants are available for each status:

+ * + *

String-based (manual configuration)

* * + *

Class-based (auto-extraction from annotations)

+ * + * + *

Selective Field Verification

+ *

A field is verified only if its expected value is non-null:

+ * + * * @see WhenStage#thenExpectAuditSequenceStrict(AuditEntryExpectation...) * @see ThenStage#andExpectAuditSequenceStrict(AuditEntryExpectation...) */ @@ -79,28 +93,18 @@ public class AuditEntryExpectation { private String expectedStageId; private String expectedAuthor; private LocalDateTime expectedCreatedAt; - private AuditEntry.ExecutionType expectedType; private String expectedClassName; private String expectedMethodName; private Object expectedMetadata; private Long expectedExecutionMillis; private String expectedExecutionHostname; private String expectedErrorTrace; - private Boolean expectedSystemChange; - private AuditTxType expectedTxType; private String expectedTargetSystemId; // Time range for flexible timestamp verification private LocalDateTime timestampAfter; private LocalDateTime timestampBefore; - // Flags for optional field verification - private boolean shouldVerifyExecutionId = false; - private boolean shouldVerifyStageId = false; - private boolean shouldVerifyTimestamp = false; - private boolean shouldVerifyExecutionMillis = false; - private boolean shouldVerifyExecutionHostname = false; - private AuditEntryExpectation(String expectedChangeId, AuditEntry.Status expectedState) { this.expectedChangeId = expectedChangeId; this.expectedState = expectedState; @@ -125,11 +129,11 @@ public static AuditEntryExpectation APPLIED(String expectedChangeId) { *

Use this when the change is expected to fail during execution and be recorded * with {@code FAILED} status in the audit store.

* - * @param taskId the unique identifier of the expected change + * @param expectedChangeId the unique identifier of the expected change * @return a new expectation builder for further configuration */ - public static AuditEntryExpectation FAILED(String taskId) { - return new AuditEntryExpectation(taskId, FAILED); + public static AuditEntryExpectation FAILED(String expectedChangeId) { + return new AuditEntryExpectation(expectedChangeId, FAILED); } /** @@ -138,11 +142,11 @@ public static AuditEntryExpectation FAILED(String taskId) { *

Use this when the change is expected to have been rolled back successfully * and be recorded with {@code ROLLED_BACK} status in the audit store.

* - * @param taskId the unique identifier of the expected change + * @param expectedChangeId the unique identifier of the expected change * @return a new expectation builder for further configuration */ - public static AuditEntryExpectation ROLLED_BACK(String taskId) { - return new AuditEntryExpectation(taskId, ROLLED_BACK); + public static AuditEntryExpectation ROLLED_BACK(String expectedChangeId) { + return new AuditEntryExpectation(expectedChangeId, ROLLED_BACK); } /** @@ -151,11 +155,145 @@ public static AuditEntryExpectation ROLLED_BACK(String taskId) { *

Use this when the change's rollback operation is expected to fail * and be recorded with {@code ROLLBACK_FAILED} status in the audit store.

* - * @param taskId the unique identifier of the expected change + * @param expectedChangeId the unique identifier of the expected change * @return a new expectation builder for further configuration */ - public static AuditEntryExpectation ROLLBACK_FAILED(String taskId) { - return new AuditEntryExpectation(taskId, ROLLBACK_FAILED); + public static AuditEntryExpectation ROLLBACK_FAILED(String expectedChangeId) { + return new AuditEntryExpectation(expectedChangeId, ROLLBACK_FAILED); + } + + // ==================== Class-Based Factory Methods ==================== + + /** + * Creates an expectation for a successfully applied change by extracting + * metadata from the change class annotations. + * + *

This factory method uses reflection to extract:

+ * + * + *

Values can be overridden after creation using {@code withXxx()} methods.

+ * + * @param changeClass the change class annotated with {@code @Change} + * @return a new expectation builder pre-populated with annotation values + * @throws IllegalArgumentException if the class is not annotated with {@code @Change} + * or does not contain a method annotated with {@code @Apply} + */ + public static AuditEntryExpectation APPLIED(Class changeClass) { + return fromChangeClass(changeClass, APPLIED); + } + + /** + * Creates an expectation for a failed change by extracting + * metadata from the change class annotations. + * + *

This factory method uses reflection to extract:

+ * + * + *

Values can be overridden after creation using {@code withXxx()} methods.

+ * + * @param changeClass the change class annotated with {@code @Change} + * @return a new expectation builder pre-populated with annotation values + * @throws IllegalArgumentException if the class is not annotated with {@code @Change} + * or does not contain a method annotated with {@code @Apply} + */ + public static AuditEntryExpectation FAILED(Class changeClass) { + return fromChangeClass(changeClass, FAILED); + } + + /** + * Creates an expectation for a rolled-back change by extracting + * metadata from the change class annotations. + * + *

This factory method uses reflection to extract:

+ * + * + *

Values can be overridden after creation using {@code withXxx()} methods.

+ * + * @param changeClass the change class annotated with {@code @Change} + * @return a new expectation builder pre-populated with annotation values + * @throws IllegalArgumentException if the class is not annotated with {@code @Change} + * or does not contain a method annotated with {@code @Rollback} + */ + public static AuditEntryExpectation ROLLED_BACK(Class changeClass) { + return fromChangeClass(changeClass, ROLLED_BACK); + } + + /** + * Creates an expectation for a change whose rollback failed by extracting + * metadata from the change class annotations. + * + *

This factory method uses reflection to extract:

+ * + * + *

Values can be overridden after creation using {@code withXxx()} methods.

+ * + * @param changeClass the change class annotated with {@code @Change} + * @return a new expectation builder pre-populated with annotation values + * @throws IllegalArgumentException if the class is not annotated with {@code @Change} + * or does not contain a method annotated with {@code @Rollback} + */ + public static AuditEntryExpectation ROLLBACK_FAILED(Class changeClass) { + return fromChangeClass(changeClass, ROLLBACK_FAILED); + } + + private static AuditEntryExpectation fromChangeClass(Class changeClass, AuditEntry.Status status) { + Change changeAnnotation = changeClass.getAnnotation(Change.class); + if (changeAnnotation == null) { + throw new IllegalArgumentException( + String.format("Class [%s] must be annotated with @Change", changeClass.getName())); + } + + AuditEntryExpectation expectation = new AuditEntryExpectation( + changeAnnotation.id(), + status + ); + + expectation.expectedAuthor = changeAnnotation.author(); + expectation.expectedClassName = changeClass.getName(); + expectation.expectedMethodName = findMethodName(changeClass, status); + + TargetSystem targetSystem = changeClass.getAnnotation(TargetSystem.class); + if (targetSystem != null) { + expectation.expectedTargetSystemId = targetSystem.id(); + } + + return expectation; + } + + private static String findMethodName(Class changeClass, AuditEntry.Status status) { + Class annotationClass = + (status == APPLIED || status == FAILED) ? Apply.class : Rollback.class; + + for (Method method : changeClass.getDeclaredMethods()) { + if (method.isAnnotationPresent(annotationClass)) { + return method.getName(); + } + } + + throw new IllegalArgumentException(String.format( + "Class [%s] must contain a method annotated with @%s", + changeClass.getName(), + annotationClass.getSimpleName())); } // ==================== Identity Fields ==================== @@ -170,7 +308,6 @@ public static AuditEntryExpectation ROLLBACK_FAILED(String taskId) { */ public AuditEntryExpectation withExecutionId(String executionId) { this.expectedExecutionId = executionId; - this.shouldVerifyExecutionId = true; return this; } @@ -184,7 +321,6 @@ public AuditEntryExpectation withExecutionId(String executionId) { */ public AuditEntryExpectation withStageId(String stageId) { this.expectedStageId = stageId; - this.shouldVerifyStageId = true; return this; } @@ -212,7 +348,6 @@ public AuditEntryExpectation withAuthor(String author) { */ public AuditEntryExpectation withCreatedAt(LocalDateTime createdAt) { this.expectedCreatedAt = createdAt; - this.shouldVerifyTimestamp = true; return this; } @@ -230,18 +365,6 @@ public AuditEntryExpectation withCreatedAt(LocalDateTime createdAt) { public AuditEntryExpectation withTimestampBetween(LocalDateTime after, LocalDateTime before) { this.timestampAfter = after; this.timestampBefore = before; - this.shouldVerifyTimestamp = true; - return this; - } - - /** - * Sets the expected execution type. - * - * @param type the expected execution type - * @return this builder for method chaining - */ - public AuditEntryExpectation withType(AuditEntry.ExecutionType type) { - this.expectedType = type; return this; } @@ -250,9 +373,11 @@ public AuditEntryExpectation withType(AuditEntry.ExecutionType type) { /** * Sets the expected fully-qualified class name. * + *

This is useful for overriding the class name after using a class-based + * factory method, or when using string-based factory methods.

+ * * @param className the expected class name * @return this builder for method chaining - * @see #withClass(Class) */ public AuditEntryExpectation withClassName(String className) { this.expectedClassName = className; @@ -262,42 +387,17 @@ public AuditEntryExpectation withClassName(String className) { /** * Sets the expected method name. * + *

This is useful for overriding the method name after using a class-based + * factory method, or when using string-based factory methods.

+ * * @param methodName the expected method name * @return this builder for method chaining - * @see #withClass(Class) */ public AuditEntryExpectation withMethodName(String methodName) { this.expectedMethodName = methodName; return this; } - /** - * Sets both class name and method name by inspecting the provided change class. - * - *

This method uses reflection to find the method annotated with {@code @Apply} - * and automatically sets both {@code className} and {@code methodName} fields.

- * - *

This is the recommended way to set execution details as it avoids - * hardcoding string values that may become stale.

- * - * @param clazz the change class to inspect - * @return this builder for method chaining - * @throws RuntimeException if no method annotated with {@code @Apply} is found - */ - public AuditEntryExpectation withClass(Class clazz) { - this.expectedClassName = clazz.getName(); - - java.lang.reflect.Method[] methods = clazz.getDeclaredMethods(); - for (java.lang.reflect.Method method : methods) { - if (method.isAnnotationPresent(Apply.class)) { - this.expectedMethodName = method.getName(); - return this; - } - } - - throw new RuntimeException(String.format("Class[%s] should contain a method annotated with @Apply", expectedClassName)); - } - /** * Sets the expected metadata object. * @@ -321,7 +421,6 @@ public AuditEntryExpectation withMetadata(Object metadata) { */ public AuditEntryExpectation withExecutionMillis(Long executionMillis) { this.expectedExecutionMillis = executionMillis; - this.shouldVerifyExecutionMillis = true; return this; } @@ -335,7 +434,6 @@ public AuditEntryExpectation withExecutionMillis(Long executionMillis) { */ public AuditEntryExpectation withExecutionHostname(String hostname) { this.expectedExecutionHostname = hostname; - this.shouldVerifyExecutionHostname = true; return this; } @@ -355,31 +453,7 @@ public AuditEntryExpectation withErrorTrace(String errorTrace) { return this; } - // ==================== System Fields ==================== - - /** - * Sets whether this is expected to be a system change. - * - * @param systemChange {@code true} if this should be a system change - * @return this builder for method chaining - */ - public AuditEntryExpectation withSystemChange(Boolean systemChange) { - this.expectedSystemChange = systemChange; - return this; - } - - // ==================== Transaction Fields ==================== - - /** - * Sets the expected transaction type. - * - * @param txStrategy the expected transaction type - * @return this builder for method chaining - */ - public AuditEntryExpectation withTxType(AuditTxType txStrategy) { - this.expectedTxType = txStrategy; - return this; - } + // ==================== Target System Fields ==================== /** * Sets the expected target system identifier. @@ -412,9 +486,6 @@ public AuditEntryExpectation withTargetSystemId(String targetSystemId) { /** Returns the expected audit entry status. */ public AuditEntry.Status getExpectedState() { return expectedState; } - /** Returns the expected execution type. */ - public AuditEntry.ExecutionType getExpectedType() { return expectedType; } - /** Returns the expected class name. */ public String getExpectedClassName() { return expectedClassName; } @@ -433,12 +504,6 @@ public AuditEntryExpectation withTargetSystemId(String targetSystemId) { /** Returns the expected error trace. */ public String getExpectedErrorTrace() { return expectedErrorTrace; } - /** Returns whether this is expected to be a system change. */ - public Boolean getExpectedSystemChange() { return expectedSystemChange; } - - /** Returns the expected transaction type. */ - public AuditTxType getExpectedTxType() { return expectedTxType; } - /** Returns the expected target system ID. */ public String getExpectedTargetSystemId() { return expectedTargetSystemId; } @@ -447,21 +512,4 @@ public AuditEntryExpectation withTargetSystemId(String targetSystemId) { /** Returns the upper bound for timestamp range verification. */ public LocalDateTime getTimestampBefore() { return timestampBefore; } - - // ==================== Verification Flags ==================== - - /** Returns {@code true} if execution ID should be verified. */ - public boolean shouldVerifyExecutionId() { return shouldVerifyExecutionId; } - - /** Returns {@code true} if stage ID should be verified. */ - public boolean shouldVerifyStageId() { return shouldVerifyStageId; } - - /** Returns {@code true} if timestamp should be verified. */ - public boolean shouldVerifyTimestamp() { return shouldVerifyTimestamp; } - - /** Returns {@code true} if execution millis should be verified. */ - public boolean shouldVerifyExecutionMillis() { return shouldVerifyExecutionMillis; } - - /** Returns {@code true} if execution hostname should be verified. */ - public boolean shouldVerifyExecutionHostname() { return shouldVerifyExecutionHostname; } } diff --git a/core/target-systems/sql-target-system/build.gradle.kts b/core/target-systems/sql-target-system/build.gradle.kts index f06d2eb9f..cb3b20aea 100644 --- a/core/target-systems/sql-target-system/build.gradle.kts +++ b/core/target-systems/sql-target-system/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.kotlin.gradle.utils.extendsFrom +import java.time.Duration dependencies { //Flamingock @@ -36,3 +37,25 @@ java { configurations.testImplementation { extendsFrom(configurations.compileOnly.get()) } + + +tasks.test { + // CI-specific configuration + val isCI = System.getenv("CI")?.toBoolean() ?: false + val enabledDialects = System.getProperty("sql.test.dialects") ?: if (isCI) "mysql,oracle" else "mysql,oracle,sqlserver" + + systemProperty("sql.test.dialects", enabledDialects) + + // Parallel execution control + maxParallelForks = if (isCI) 1 else (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) + + // Timeout for long-running database tests + if (isCI) { + timeout.set(Duration.ofMinutes(30)) + } + + testLogging { + events("passed", "skipped", "failed") + showStandardStreams = false + } +} \ No newline at end of file diff --git a/core/target-systems/sql-target-system/src/test/java/io/flamingock/targetsystem/sql/SqlAuditMarkerDialectHelperTest.java b/core/target-systems/sql-target-system/src/test/java/io/flamingock/targetsystem/sql/SqlAuditMarkerDialectHelperTest.java index 106227db8..1dd89e1e6 100644 --- a/core/target-systems/sql-target-system/src/test/java/io/flamingock/targetsystem/sql/SqlAuditMarkerDialectHelperTest.java +++ b/core/target-systems/sql-target-system/src/test/java/io/flamingock/targetsystem/sql/SqlAuditMarkerDialectHelperTest.java @@ -22,7 +22,9 @@ import io.flamingock.internal.core.transaction.TransactionManager; import org.junit.jupiter.api.*; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; import org.testcontainers.containers.JdbcDatabaseContainer; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.containers.PostgreSQLContainer; @@ -32,7 +34,10 @@ import javax.sql.DataSource; import java.sql.*; +import java.util.Arrays; import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; @TestInstance(TestInstance.Lifecycle.PER_METHOD) public class SqlAuditMarkerDialectHelperTest { @@ -132,8 +137,32 @@ private void dropTable() throws SQLException { } } + static Stream dialectProvider() { + String enabledDialects = System.getProperty("sql.test.dialects", "mysql"); + Set enabled = Arrays.stream(enabledDialects.split(",")) + .map(String::trim) + .collect(Collectors.toSet()); + + Stream allDialects = Stream.of( + Arguments.of(SqlDialect.MYSQL, "mysql"), + Arguments.of(SqlDialect.SQLSERVER, "sqlserver"), + Arguments.of(SqlDialect.ORACLE, "oracle"), + Arguments.of(SqlDialect.POSTGRESQL, "postgresql"), + Arguments.of(SqlDialect.MARIADB, "mariadb"), + Arguments.of(SqlDialect.H2, "h2"), + Arguments.of(SqlDialect.SQLITE, "sqlite"), + Arguments.of(SqlDialect.INFORMIX, "informix"), + Arguments.of(SqlDialect.FIREBIRD, "firebird") + ); + + return allDialects.filter(args -> { + String dialectName = (String) args.get()[1]; + return enabled.contains(dialectName); + }); + } + @ParameterizedTest(name = "[{index}] dialect={0} - Should add and list two marks successfully") - @EnumSource(SqlDialect.class) + @MethodSource("dialectProvider") void addOngoingTaskMark(SqlDialect dialect) { JdbcDatabaseContainer container = createContainerForDialect(dialect); Assumptions.assumeTrue(container != null || @@ -179,7 +208,7 @@ void addOngoingTaskMark(SqlDialect dialect) { } @ParameterizedTest(name = "[{index}] dialect={0} - Should remove all marks successfully") - @EnumSource(SqlDialect.class) + @MethodSource("dialectProvider") void removeOngoingTaskMark(SqlDialect dialect) { JdbcDatabaseContainer container = createContainerForDialect(dialect); Assumptions.assumeTrue(container != null ||