From 92696fd202e9d57cce26cbc84cbaad4d1be4b560 Mon Sep 17 00:00:00 2001 From: Francisco Barbudo <123982983+francisco-bru@users.noreply.github.com> Date: Thu, 7 May 2026 16:39:49 +0200 Subject: [PATCH 1/2] Initial setup of the library code (#2) * chore: Initial setup of the library code --- .gitattributes | 2 + .github/ISSUE_TEMPLATE/1-bug.yml | 50 - .github/ISSUE_TEMPLATE/2-feature.yml | 33 - .github/ISSUE_TEMPLATE/3-other.yml | 37 - .github/ISSUE_TEMPLATE/bug-issue.md | 16 + .github/ISSUE_TEMPLATE/config.yml | 3 - .github/ISSUE_TEMPLATE/feature-issue.md | 14 + .github/PULL_REQUEST_TEMPLATE.md | 6 - .github/workflows/code-PR_sync_to_develop.yml | 167 ++++ .github/workflows/code-PR_verify-fallback.yml | 13 + .../workflows/code-maven_java-PR_verify.yml | 152 +++ .../code-maven_java-build_snapshot.yml | 288 ++++++ .github/workflows/code-maven_java-release.yml | 249 +++++ .../code-maven_java-sonarcloud-analysis.yml | 160 ++++ .github/workflows/code-release_preview.yml | 231 +++++ .../workflows/docs/code-PR_sync_to_develop.md | 19 + .../workflows/docs/code-PR_verify-fallback.md | 19 + .../docs/code-maven_java-PR_verify.md | 39 + .../docs/code-maven_java-build_snapshot.md | 51 + .../workflows/docs/code-maven_java-release.md | 46 + .../code-maven_java-sonarcloud-analysis.md | 43 + .../workflows/docs/code-release_preview.md | 49 + .gitignore | 47 + NOTICE | 2 +- README.md | 635 +++++++++++- REUSE.toml | 27 +- code/.mvn/extensions.xml | 3 + code/.mvn/jvm.config | 0 code/.mvn/maven.config | 1 + code/.tool-versions | 2 + code/CHANGELOG.md | 10 + code/CODING-CONVENTIONS.md | 107 +++ code/jacoco-report-aggregate/pom.xml | 86 ++ code/lombok.config | 1 + code/oscos-curation.yml | 6 + code/pom.xml | 553 +++++++++++ code/scs-outbox-it/pom.xml | 17 + code/scs-outbox-it/scs-outbox-jdbc-it/pom.xml | 77 ++ .../it/jdbc/AfterCommitTriggerIT.java | 57 ++ .../it/jdbc/BatchSizeHotRefreshIT.java | 153 +++ .../jdbc/DedicatedPublishingDataSourceIT.java | 173 ++++ .../it/jdbc/ErrorMessageFilteringIT.java | 99 ++ .../it/jdbc/GlobalPauseHotRefreshIT.java | 172 ++++ .../jdbc/PausedDestinationsHotRefreshIT.java | 182 ++++ .../PayloadSerializationCombinationsIT.java | 281 ++++++ .../scsoutbox/it/jdbc/ScsOutboxJdbcIT.java | 112 +++ .../it/jdbc/TransactionRollbackIT.java | 224 +++++ .../src/test/resources/application.yml | 51 + .../test/resources/compose/docker-compose.yml | 44 + .../compose/postgres/data/01-schemas.sql | 1 + .../compose/postgres/data/02-tables.sql | 34 + .../scs-outbox-mongodb-it/pom.xml | 89 ++ .../DedicatedPublishingMongoTemplateIT.java | 150 +++ .../it/mongo/ScsOutboxMongodbIT.java | 89 ++ .../src/test/resources/application.yml | 46 + .../test/resources/compose/docker-compose.yml | 50 + .../compose/mongodb/mongo.properties | 1 + code/scs-outbox-libs/pom.xml | 26 + .../scs-outbox-archive-jdbc/pom.xml | 89 ++ .../jdbc/JdbcArchivedMessageRepository.java | 76 ++ .../config/JdbcArchiveAutoConfiguration.java | 63 ++ .../jdbc/config/JdbcArchiveProperties.java | 21 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...ractJdbcArchivedMessageRepositoryTest.java | 51 + .../archive/jdbc/ArchivedMessageMother.java | 25 + .../JdbcArchivedMessageRepositoryTest.java | 17 + ...ariaDbJdbcArchivedMessageRepositoryIT.java | 43 + ...greSqlJdbcArchivedMessageRepositoryIT.java | 41 + .../JdbcArchiveAutoConfigurationTest.java | 103 ++ .../config/JdbcArchivePropertiesTest.java | 117 +++ .../scripts/mariadb-archive-table.sql | 14 + .../scripts/postgresql-archive-table.sql | 14 + .../scs-outbox-archive-mongodb/pom.xml | 80 ++ .../mongodb/ArchivedMessageDocument.java | 50 + .../MongoDbArchivedMessageRepository.java | 42 + .../MongoDbArchiveAutoConfiguration.java | 47 + .../config/MongoDbArchiveProperties.java | 30 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../MongoDbArchivedMessageRepositoryIT.java | 98 ++ .../MongoDbArchiveAutoConfigurationIT.java | 79 ++ .../config/MongoDbArchivePropertiesTest.java | 64 ++ .../scs-outbox-archive/pom.xml | 54 ++ ...hiveOutboxMessagePublisherInterceptor.java | 17 + .../publish/archive/ArchiveService.java | 68 ++ .../publish/archive/ArchivedMessage.java | 43 + .../archive/ArchivedMessageRepository.java | 7 + .../archive/ArchivedMessageSerializer.java | 88 ++ .../config/ArchiveAutoConfiguration.java | 66 ++ .../archive/config/ArchiveProperties.java | 16 + .../archive/json/AvroToJsonMapper.java | 30 + .../archive/json/CompositeJsonMapper.java | 22 + .../archive/json/DefaultJsonMapper.java | 15 + .../publish/archive/json/JsonMapper.java | 14 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...OutboxMessagePublisherInterceptorTest.java | 35 + .../publish/archive/ArchiveServiceTest.java | 192 ++++ .../ArchivedMessageSerializerTest.java | 142 +++ .../config/ArchiveAutoConfigurationTest.java | 65 ++ .../archive/config/ArchivePropertiesTest.java | 27 + .../archive/json/AvroToJsonMapperTest.java | 94 ++ .../archive/json/BookCreatedMessage.java | 321 +++++++ .../archive/json/CompositeJsonMapperTest.java | 52 + .../scsoutbox/publish/archive/json/Cycle.java | 902 ++++++++++++++++++ .../archive/json/DefaultJsonMapperTest.java | 32 + .../publish/archive/json/Product.java | 573 +++++++++++ code/scs-outbox-libs/scs-outbox-core/pom.xml | 75 ++ .../scsoutbox/MessageCaptureTxService.java | 32 + .../dev/inditex/scsoutbox/OutboxMessage.java | 41 + .../scsoutbox/OutboxMessageRepository.java | 43 + .../scsoutbox/OutboxServiceProperties.java | 100 ++ .../scsoutbox/config/BindingMatcher.java | 98 ++ .../config/OutboxAutoConfiguration.java | 176 ++++ .../scsoutbox/config/OutboxProperties.java | 53 + .../interceptor/MessageChannelAccessor.java | 25 + .../interceptor/OutboxChannelInterceptor.java | 63 ++ .../DestinationGroupingKeyGenerator.java | 17 + .../scsoutbox/publish/GroupingKey.java | 18 + .../publish/GroupingKeyGenerator.java | 14 + .../scsoutbox/publish/GroupingStrategy.java | 10 + .../scsoutbox/publish/GroupingValues.java | 30 + .../publish/KafkaKeyGroupingKeyGenerator.java | 20 + .../publish/KeyGroupingStrategy.java | 27 + .../publish/MessageNotPublishedException.java | 8 + .../publish/OutboxMessageConverter.java | 67 ++ .../publish/OutboxMessagePublisher.java | 38 + .../OutboxMessagePublisherInterceptor.java | 9 + .../publish/OutboxMessageSender.java | 8 + .../publish/OutboxPublishingTask.java | 68 ++ .../publish/OutboxPublishingTaskReport.java | 44 + .../scsoutbox/publish/ParallelPublisher.java | 101 ++ .../StreamBridgeOutboxMessageSender.java | 67 ++ .../publish/config/PublishingProperties.java | 46 + .../scheduler/AfterCommitTrigger.java | 38 + .../scheduler/OutboxScheduledService.java | 30 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../InMemoryOutboxMessageRepository.java | 70 ++ .../MessageCaptureTxServiceTest.java | 74 ++ .../scsoutbox/OutboxMessageMother.java | 32 + .../inditex/scsoutbox/OutboxMessageTest.java | 99 ++ .../OutboxServicePropertiesTest.java | 292 ++++++ .../scsoutbox/config/BindingMatcherTest.java | 132 +++ .../ExecutorServiceAutoConfigurationTest.java | 150 +++ .../config/OutboxAutoConfigurationTest.java | 270 ++++++ .../config/OutboxPropertiesTest.java | 97 ++ .../MessageChannelAccessorTest.java | 27 + .../OutboxChannelInterceptorTest.java | 95 ++ .../AbstractOutboxPublishingTaskTest.java | 102 ++ .../DestinationGroupingKeyGeneratorTest.java | 44 + .../scsoutbox/publish/GroupingKeyTest.java | 49 + .../scsoutbox/publish/GroupingValuesTest.java | 73 ++ .../KafkaKeyGroupingKeyGeneratorTest.java | 67 ++ ...ultiThreadingOutboxPublishingTaskTest.java | 83 ++ .../publish/OutboxMessageConverterTest.java | 162 ++++ .../publish/OutboxMessagePublisherTest.java | 60 ++ .../OutboxPublishingTaskReportTest.java | 40 + .../publish/OutboxPublishingTaskTest.java | 254 +++++ .../StreamBridgeOutboxMessageSenderTest.java | 164 ++++ .../config/PublishingPropertiesTest.java | 59 ++ .../scheduler/AfterCommitTriggerTest.java | 42 + code/scs-outbox-libs/scs-outbox-jdbc/pom.xml | 89 ++ .../scsoutbox/jdbc/DataSourceMetadata.java | 112 +++ .../scsoutbox/jdbc/DbNamingValidator.java | 37 + .../scsoutbox/jdbc/DbSchemaResolver.java | 54 ++ .../jdbc/JdbcOutboxDataSourceProvider.java | 76 ++ .../jdbc/JdbcOutboxMessageRepository.java | 270 ++++++ .../JdbcOutboxMessageRepositoryFactory.java | 49 + .../MariadbJdbcOutboxMessageRepository.java | 24 + .../jdbc/OutboxDataSourceProvider.java | 39 + ...PostgresqlJdbcOutboxMessageRepository.java | 24 + .../inditex/scsoutbox/jdbc/SchemaName.java | 17 + .../dev/inditex/scsoutbox/jdbc/Table.java | 31 + .../dev/inditex/scsoutbox/jdbc/TableName.java | 17 + .../jdbc/config/AbstractJdbcProperties.java | 45 + .../JdbcDataSourceAutoConfiguration.java | 43 + .../config/JdbcOutboxAutoConfiguration.java | 98 ++ .../scsoutbox/jdbc/config/JdbcProperties.java | 35 + .../config/JdbcShedlockAutoConfiguration.java | 31 + ...ot.autoconfigure.AutoConfiguration.imports | 3 + ...stractJdbcOutboxMessageRepositoryTest.java | 232 +++++ .../jdbc/DataSourceMetadataTest.java | 136 +++ .../scsoutbox/jdbc/DbSchemaResolverTest.java | 82 ++ .../JdbcOutboxDataSourceProviderTest.java | 77 ++ ...xMessageRepositoryDeserializationTest.java | 184 ++++ ...dbcOutboxMessageRepositoryFactoryTest.java | 87 ++ .../jdbc/JdbcOutboxMessageRepositoryTest.java | 17 + .../MariaDbJdbcOutboxMessageRepositoryIT.java | 44 + ...stgreSqlJdbcOutboxMessageRepositoryIT.java | 42 + .../inditex/scsoutbox/jdbc/SchemaTest.java | 61 ++ .../inditex/scsoutbox/jdbc/TableNameTest.java | 61 ++ .../dev/inditex/scsoutbox/jdbc/TableTest.java | 47 + .../JdbcDataSourceAutoConfigurationTest.java | 72 ++ .../JdbcOutboxAutoConfigurationTest.java | 77 ++ .../jdbc/config/JdbcPropertiesTest.java | 99 ++ .../JdbcShedlockAutoConfigurationTest.java | 55 ++ .../scripts/mariadb-outbox-table.sql | 11 + .../scripts/postgresql-outbox-table.sql | 11 + .../test/resources/scripts/shedlock-table.sql | 8 + .../scs-outbox-metrics/pom.xml | 63 ++ .../metrics/MessagesPendingMeter.java | 34 + .../metrics/PublishingDelayMeter.java | 32 + .../metrics/PublishingTaskMeter.java | 30 + .../metrics/SpringIntegrationMeterFilter.java | 44 + .../OutboxMetricsAutoConfiguration.java | 43 + ...ngIntegrationMetricsAutoConfiguration.java | 21 + ...ot.autoconfigure.AutoConfiguration.imports | 2 + .../scsoutbox/OutboxMessageMother.java | 32 + .../metrics/MessagesPendingMeterTest.java | 57 ++ .../metrics/PublishingDelayMeterTest.java | 56 ++ .../metrics/PublishingTaskMeterTest.java | 71 ++ .../SpringIntegrationMeterFilterTest.java | 132 +++ .../OutboxMetricsAutoConfigurationTest.java | 66 ++ ...tegrationMetricsAutoConfigurationTest.java | 45 + .../scs-outbox-mongodb/pom.xml | 83 ++ .../MongoDbOutboxMessageRepository.java | 141 +++ .../MongoDbOutboxTemplateProvider.java | 51 + .../mongodb/OutboxMessageDocument.java | 42 + .../mongodb/OutboxMongoTemplateProvider.java | 26 + .../MongoDbOutboxAutoConfiguration.java | 99 ++ .../mongodb/config/MongoDbProperties.java | 30 + .../MongoDbShedlockAutoConfiguration.java | 25 + ...ot.autoconfigure.AutoConfiguration.imports | 2 + .../MongoDbOutboxMessageRepositoryIT.java | 285 ++++++ .../MongoDbOutboxMessageRepositoryTest.java | 427 +++++++++ .../MongoDbOutboxTemplateProviderTest.java | 42 + .../MongoDbOutboxAutoConfigurationTest.java | 101 ++ .../mongodb/config/MongoDbPropertiesTest.java | 64 ++ .../MongoDbShedlockAutoConfigurationTest.java | 65 ++ .../scs-outbox-serialization/pom.xml | 51 + .../serialization/HeadersMapper.java | 10 + .../serialization/JavaSerialization.java | 32 + .../serialization/JsonHeadersMapper.java | 73 ++ .../OutboxMessageReconverter.java | 28 + .../OutboxMessageSerializer.java | 81 ++ .../serialization/SerializationEngine.java | 8 + .../SerializationAutoConfiguration.java | 47 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../serialization/JsonHeadersMapperTest.java | 99 ++ .../OutboxMessageReconverterTest.java | 121 +++ .../OutboxMessageSerializerTest.java | 223 +++++ .../SerializationAutoConfigurationTest.java | 161 ++++ .../src/test/java/test/app/TestApp.java | 13 + .../scs-outbox-test-support/pom.xml | 16 + .../scsoutbox/test/ContainerImages.java | 22 + .../test/container-images.properties | 0 code/scs-outbox-starters/pom.xml | 20 + .../scs-outbox-jdbc-starter/pom.xml | 29 + .../scs-outbox-mongodb-starter/pom.xml | 28 + .../checkstyle-java-google-style-17.xml | 327 +++++++ .../config/checkstyle-java-google-style.xml | 297 ++++++ .../main/config/checkstyle-suppressions.xml | 29 + .../eclipse-java-google-style.importorder | 8 + .../main/config/eclipse-java-google-style.xml | 366 +++++++ .../config/intellij-java-google-style.xml | 852 +++++++++++++++++ code/src/main/config/pom-code-convention.xml | 641 +++++++++++++ .../dev/inditex/scsoutbox/package-info.java | 1 + .../inditex/scsoutbox/test/package-info.java | 1 + .../0001-no-cache-for-isOutboxEnabledFor.md | 142 +++ docs/adr/0002-code-formatting-toolchain.md | 129 +++ repolinter.json | 2 +- 259 files changed, 21639 insertions(+), 168 deletions(-) create mode 100644 .gitattributes delete mode 100644 .github/ISSUE_TEMPLATE/1-bug.yml delete mode 100644 .github/ISSUE_TEMPLATE/2-feature.yml delete mode 100644 .github/ISSUE_TEMPLATE/3-other.yml create mode 100644 .github/ISSUE_TEMPLATE/bug-issue.md delete mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature-issue.md create mode 100644 .github/workflows/code-PR_sync_to_develop.yml create mode 100644 .github/workflows/code-PR_verify-fallback.yml create mode 100644 .github/workflows/code-maven_java-PR_verify.yml create mode 100644 .github/workflows/code-maven_java-build_snapshot.yml create mode 100644 .github/workflows/code-maven_java-release.yml create mode 100644 .github/workflows/code-maven_java-sonarcloud-analysis.yml create mode 100644 .github/workflows/code-release_preview.yml create mode 100644 .github/workflows/docs/code-PR_sync_to_develop.md create mode 100644 .github/workflows/docs/code-PR_verify-fallback.md create mode 100644 .github/workflows/docs/code-maven_java-PR_verify.md create mode 100644 .github/workflows/docs/code-maven_java-build_snapshot.md create mode 100644 .github/workflows/docs/code-maven_java-release.md create mode 100644 .github/workflows/docs/code-maven_java-sonarcloud-analysis.md create mode 100644 .github/workflows/docs/code-release_preview.md create mode 100644 code/.mvn/extensions.xml create mode 100644 code/.mvn/jvm.config create mode 100644 code/.mvn/maven.config create mode 100644 code/.tool-versions create mode 100644 code/CHANGELOG.md create mode 100644 code/CODING-CONVENTIONS.md create mode 100644 code/jacoco-report-aggregate/pom.xml create mode 100755 code/lombok.config create mode 100644 code/oscos-curation.yml create mode 100644 code/pom.xml create mode 100644 code/scs-outbox-it/pom.xml create mode 100644 code/scs-outbox-it/scs-outbox-jdbc-it/pom.xml create mode 100644 code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/AfterCommitTriggerIT.java create mode 100644 code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/BatchSizeHotRefreshIT.java create mode 100644 code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/DedicatedPublishingDataSourceIT.java create mode 100644 code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/ErrorMessageFilteringIT.java create mode 100644 code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/GlobalPauseHotRefreshIT.java create mode 100644 code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/PausedDestinationsHotRefreshIT.java create mode 100644 code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/PayloadSerializationCombinationsIT.java create mode 100644 code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/ScsOutboxJdbcIT.java create mode 100644 code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/TransactionRollbackIT.java create mode 100644 code/scs-outbox-it/scs-outbox-jdbc-it/src/test/resources/application.yml create mode 100644 code/scs-outbox-it/scs-outbox-jdbc-it/src/test/resources/compose/docker-compose.yml create mode 100755 code/scs-outbox-it/scs-outbox-jdbc-it/src/test/resources/compose/postgres/data/01-schemas.sql create mode 100755 code/scs-outbox-it/scs-outbox-jdbc-it/src/test/resources/compose/postgres/data/02-tables.sql create mode 100644 code/scs-outbox-it/scs-outbox-mongodb-it/pom.xml create mode 100644 code/scs-outbox-it/scs-outbox-mongodb-it/src/test/java/dev/inditex/scsoutbox/it/mongo/DedicatedPublishingMongoTemplateIT.java create mode 100644 code/scs-outbox-it/scs-outbox-mongodb-it/src/test/java/dev/inditex/scsoutbox/it/mongo/ScsOutboxMongodbIT.java create mode 100644 code/scs-outbox-it/scs-outbox-mongodb-it/src/test/resources/application.yml create mode 100644 code/scs-outbox-it/scs-outbox-mongodb-it/src/test/resources/compose/docker-compose.yml create mode 100644 code/scs-outbox-it/scs-outbox-mongodb-it/src/test/resources/compose/mongodb/mongo.properties create mode 100644 code/scs-outbox-libs/pom.xml create mode 100644 code/scs-outbox-libs/scs-outbox-archive-jdbc/pom.xml create mode 100755 code/scs-outbox-libs/scs-outbox-archive-jdbc/src/main/java/dev/inditex/scsoutbox/publish/archive/jdbc/JdbcArchivedMessageRepository.java create mode 100755 code/scs-outbox-libs/scs-outbox-archive-jdbc/src/main/java/dev/inditex/scsoutbox/publish/archive/jdbc/config/JdbcArchiveAutoConfiguration.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive-jdbc/src/main/java/dev/inditex/scsoutbox/publish/archive/jdbc/config/JdbcArchiveProperties.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive-jdbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/AbstractJdbcArchivedMessageRepositoryTest.java create mode 100755 code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/ArchivedMessageMother.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/JdbcArchivedMessageRepositoryTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/MariaDbJdbcArchivedMessageRepositoryIT.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/PostgreSqlJdbcArchivedMessageRepositoryIT.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/config/JdbcArchiveAutoConfigurationTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/config/JdbcArchivePropertiesTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/resources/scripts/mariadb-archive-table.sql create mode 100644 code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/resources/scripts/postgresql-archive-table.sql create mode 100644 code/scs-outbox-libs/scs-outbox-archive-mongodb/pom.xml create mode 100644 code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/java/dev/inditex/scsoutbox/publish/archive/mongodb/ArchivedMessageDocument.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/java/dev/inditex/scsoutbox/publish/archive/mongodb/MongoDbArchivedMessageRepository.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/java/dev/inditex/scsoutbox/publish/archive/mongodb/config/MongoDbArchiveAutoConfiguration.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/java/dev/inditex/scsoutbox/publish/archive/mongodb/config/MongoDbArchiveProperties.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 code/scs-outbox-libs/scs-outbox-archive-mongodb/src/test/java/dev/inditex/scsoutbox/publish/archive/mongodb/MongoDbArchivedMessageRepositoryIT.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive-mongodb/src/test/java/dev/inditex/scsoutbox/publish/archive/mongodb/config/MongoDbArchiveAutoConfigurationIT.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive-mongodb/src/test/java/dev/inditex/scsoutbox/publish/archive/mongodb/config/MongoDbArchivePropertiesTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/pom.xml create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchiveOutboxMessagePublisherInterceptor.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchiveService.java create mode 100755 code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchivedMessage.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchivedMessageRepository.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchivedMessageSerializer.java create mode 100755 code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/config/ArchiveAutoConfiguration.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/config/ArchiveProperties.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/json/AvroToJsonMapper.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/json/CompositeJsonMapper.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/json/DefaultJsonMapper.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/json/JsonMapper.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/ArchiveOutboxMessagePublisherInterceptorTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/ArchiveServiceTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/ArchivedMessageSerializerTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/config/ArchiveAutoConfigurationTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/config/ArchivePropertiesTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/AvroToJsonMapperTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/BookCreatedMessage.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/CompositeJsonMapperTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/Cycle.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/DefaultJsonMapperTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/Product.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/pom.xml create mode 100755 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/MessageCaptureTxService.java create mode 100755 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/OutboxMessage.java create mode 100755 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/OutboxMessageRepository.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/OutboxServiceProperties.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/config/BindingMatcher.java create mode 100755 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/config/OutboxAutoConfiguration.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/config/OutboxProperties.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/interceptor/MessageChannelAccessor.java create mode 100755 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/interceptor/OutboxChannelInterceptor.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/DestinationGroupingKeyGenerator.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/GroupingKey.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/GroupingKeyGenerator.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/GroupingStrategy.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/GroupingValues.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/KafkaKeyGroupingKeyGenerator.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/KeyGroupingStrategy.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/MessageNotPublishedException.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxMessageConverter.java create mode 100755 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxMessagePublisher.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxMessagePublisherInterceptor.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxMessageSender.java create mode 100755 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxPublishingTask.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxPublishingTaskReport.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/ParallelPublisher.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/StreamBridgeOutboxMessageSender.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/config/PublishingProperties.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/scheduler/AfterCommitTrigger.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/scheduler/OutboxScheduledService.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100755 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/InMemoryOutboxMessageRepository.java create mode 100755 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/MessageCaptureTxServiceTest.java create mode 100755 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/OutboxMessageMother.java create mode 100755 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/OutboxMessageTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/OutboxServicePropertiesTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/config/BindingMatcherTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/config/ExecutorServiceAutoConfigurationTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/config/OutboxAutoConfigurationTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/config/OutboxPropertiesTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/interceptor/MessageChannelAccessorTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/interceptor/OutboxChannelInterceptorTest.java create mode 100755 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/AbstractOutboxPublishingTaskTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/DestinationGroupingKeyGeneratorTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/GroupingKeyTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/GroupingValuesTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/KafkaKeyGroupingKeyGeneratorTest.java create mode 100755 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/MultiThreadingOutboxPublishingTaskTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/OutboxMessageConverterTest.java create mode 100755 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/OutboxMessagePublisherTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/OutboxPublishingTaskReportTest.java create mode 100755 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/OutboxPublishingTaskTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/StreamBridgeOutboxMessageSenderTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/config/PublishingPropertiesTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/scheduler/AfterCommitTriggerTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/pom.xml create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/DataSourceMetadata.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/DbNamingValidator.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/DbSchemaResolver.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxDataSourceProvider.java create mode 100755 code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepository.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepositoryFactory.java create mode 100755 code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/MariadbJdbcOutboxMessageRepository.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/OutboxDataSourceProvider.java create mode 100755 code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/PostgresqlJdbcOutboxMessageRepository.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/SchemaName.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/Table.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/TableName.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/AbstractJdbcProperties.java create mode 100755 code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/JdbcDataSourceAutoConfiguration.java create mode 100755 code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/JdbcOutboxAutoConfiguration.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/JdbcProperties.java create mode 100755 code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/JdbcShedlockAutoConfiguration.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/AbstractJdbcOutboxMessageRepositoryTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/DataSourceMetadataTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/DbSchemaResolverTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxDataSourceProviderTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepositoryDeserializationTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepositoryFactoryTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepositoryTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/MariaDbJdbcOutboxMessageRepositoryIT.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/PostgreSqlJdbcOutboxMessageRepositoryIT.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/SchemaTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/TableNameTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/TableTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/config/JdbcDataSourceAutoConfigurationTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/config/JdbcOutboxAutoConfigurationTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/config/JdbcPropertiesTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/config/JdbcShedlockAutoConfigurationTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/test/resources/scripts/mariadb-outbox-table.sql create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/test/resources/scripts/postgresql-outbox-table.sql create mode 100644 code/scs-outbox-libs/scs-outbox-jdbc/src/test/resources/scripts/shedlock-table.sql create mode 100644 code/scs-outbox-libs/scs-outbox-metrics/pom.xml create mode 100644 code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/MessagesPendingMeter.java create mode 100644 code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/PublishingDelayMeter.java create mode 100644 code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/PublishingTaskMeter.java create mode 100644 code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/SpringIntegrationMeterFilter.java create mode 100644 code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/config/OutboxMetricsAutoConfiguration.java create mode 100644 code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/config/SpringIntegrationMetricsAutoConfiguration.java create mode 100644 code/scs-outbox-libs/scs-outbox-metrics/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100755 code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/OutboxMessageMother.java create mode 100644 code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/MessagesPendingMeterTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/PublishingDelayMeterTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/PublishingTaskMeterTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/SpringIntegrationMeterFilterTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/config/OutboxMetricsAutoConfigurationTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/config/SpringIntegrationMetricsAutoConfigurationTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-mongodb/pom.xml create mode 100755 code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxMessageRepository.java create mode 100644 code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxTemplateProvider.java create mode 100644 code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/OutboxMessageDocument.java create mode 100644 code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/OutboxMongoTemplateProvider.java create mode 100755 code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/config/MongoDbOutboxAutoConfiguration.java create mode 100644 code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/config/MongoDbProperties.java create mode 100755 code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/config/MongoDbShedlockAutoConfiguration.java create mode 100644 code/scs-outbox-libs/scs-outbox-mongodb/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxMessageRepositoryIT.java create mode 100644 code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxMessageRepositoryTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxTemplateProviderTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/config/MongoDbOutboxAutoConfigurationTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/config/MongoDbPropertiesTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/config/MongoDbShedlockAutoConfigurationTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-serialization/pom.xml create mode 100644 code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/HeadersMapper.java create mode 100755 code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/JavaSerialization.java create mode 100644 code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/JsonHeadersMapper.java create mode 100644 code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/OutboxMessageReconverter.java create mode 100644 code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/OutboxMessageSerializer.java create mode 100644 code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/SerializationEngine.java create mode 100755 code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/config/SerializationAutoConfiguration.java create mode 100644 code/scs-outbox-libs/scs-outbox-serialization/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 code/scs-outbox-libs/scs-outbox-serialization/src/test/java/dev/inditex/scsoutbox/serialization/JsonHeadersMapperTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-serialization/src/test/java/dev/inditex/scsoutbox/serialization/OutboxMessageReconverterTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-serialization/src/test/java/dev/inditex/scsoutbox/serialization/OutboxMessageSerializerTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-serialization/src/test/java/dev/inditex/scsoutbox/serialization/config/SerializationAutoConfigurationTest.java create mode 100644 code/scs-outbox-libs/scs-outbox-serialization/src/test/java/test/app/TestApp.java create mode 100644 code/scs-outbox-libs/scs-outbox-test-support/pom.xml create mode 100644 code/scs-outbox-libs/scs-outbox-test-support/src/main/java/dev/inditex/scsoutbox/test/ContainerImages.java create mode 100644 code/scs-outbox-libs/scs-outbox-test-support/src/main/resources/dev/inditex/scsoutbox/test/container-images.properties create mode 100644 code/scs-outbox-starters/pom.xml create mode 100644 code/scs-outbox-starters/scs-outbox-jdbc-starter/pom.xml create mode 100644 code/scs-outbox-starters/scs-outbox-mongodb-starter/pom.xml create mode 100644 code/src/main/config/checkstyle-java-google-style-17.xml create mode 100644 code/src/main/config/checkstyle-java-google-style.xml create mode 100644 code/src/main/config/checkstyle-suppressions.xml create mode 100644 code/src/main/config/eclipse-java-google-style.importorder create mode 100644 code/src/main/config/eclipse-java-google-style.xml create mode 100644 code/src/main/config/intellij-java-google-style.xml create mode 100644 code/src/main/config/pom-code-convention.xml create mode 100644 code/src/main/java/dev/inditex/scsoutbox/package-info.java create mode 100644 code/src/test/java/dev/inditex/scsoutbox/test/package-info.java create mode 100644 docs/adr/0001-no-cache-for-isOutboxEnabledFor.md create mode 100644 docs/adr/0002-code-formatting-toolchain.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/ISSUE_TEMPLATE/1-bug.yml b/.github/ISSUE_TEMPLATE/1-bug.yml deleted file mode 100644 index 25bcdcc..0000000 --- a/.github/ISSUE_TEMPLATE/1-bug.yml +++ /dev/null @@ -1,50 +0,0 @@ -# SPDX-FileCopyrightText: 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) -# SPDX-License-Identifier: Apache-2.0 -name: Bug Report -description: Report a reproducible bug -title: '[Bug] ' -labels: ['bug'] -assignees: [] - -body: - - type: textarea - id: description - attributes: - label: Description - description: What's the problem? - validations: - required: true - - - type: textarea - id: steps - attributes: - label: Steps to Reproduce - description: How can we reproduce the bug? - placeholder: | - 1. Go to ... - 2. Run ... - 3. See error - validations: - required: true - - - type: textarea - id: expected - attributes: - label: Expected Behavior - validations: - required: false - - - type: input - id: version - attributes: - label: Version / Environment - placeholder: "e.g. v0.3.2, Node 18, macOS" - validations: - required: false - - - type: textarea - id: notes - attributes: - label: Additional context or logs - validations: - required: false diff --git a/.github/ISSUE_TEMPLATE/2-feature.yml b/.github/ISSUE_TEMPLATE/2-feature.yml deleted file mode 100644 index 3150b0c..0000000 --- a/.github/ISSUE_TEMPLATE/2-feature.yml +++ /dev/null @@ -1,33 +0,0 @@ -# SPDX-FileCopyrightText: 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) -# SPDX-License-Identifier: Apache-2.0 -name: Feature Request -description: Suggest a feature or improvement -title: '[Feature] ' -labels: ['enhancement'] -assignees: [] - -body: - - type: textarea - id: proposal - attributes: - label: Feature description - description: What would you like to see added or changed? - validations: - required: true - - - type: textarea - id: motivation - attributes: - label: Use case or motivation - description: Why is this feature useful? - - - type: dropdown - id: contribution - attributes: - label: Would you like to work on this? - options: - - Yes, I'd like to open a PR - - Maybe, I'd need help - - No, I'm just proposing it - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/3-other.yml b/.github/ISSUE_TEMPLATE/3-other.yml deleted file mode 100644 index 1e2a82a..0000000 --- a/.github/ISSUE_TEMPLATE/3-other.yml +++ /dev/null @@ -1,37 +0,0 @@ -# SPDX-FileCopyrightText: 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) -# SPDX-License-Identifier: Apache-2.0 -name: Other -description: Use this for anything that doesn't fit the other templates -title: '[Other] ' -labels: ['discussion'] -assignees: [] - -body: - - type: textarea - id: context - attributes: - label: Context or topic - description: Briefly explain what this is about — question, idea, feedback, etc. - validations: - required: true - - - type: textarea - id: details - attributes: - label: Details or background - description: Add any supporting info, links, logs, or notes that may be useful. - placeholder: | - Example: - - I'm wondering if we should improve X - - This tool might be useful for Y - - We discussed this in meeting Z - - - type: dropdown - id: nextstep - attributes: - label: What are you hoping to do next? - options: - - Just opening this for awareness - - Looking for input before starting work - - Need help or advice - - Happy to open a PR based on feedback diff --git a/.github/ISSUE_TEMPLATE/bug-issue.md b/.github/ISSUE_TEMPLATE/bug-issue.md new file mode 100644 index 0000000..e10d232 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-issue.md @@ -0,0 +1,16 @@ +--- +name: Bug Report +about: Use this template to report a bug +title: '' +labels: kind/bug +assignees: '' + +--- + +### Detailed description + +A clear and concise description of what the problem is. + +### Expected behaviour + +Expected behaviour one the problem is fixed. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 242f03a..0000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,3 +0,0 @@ -# SPDX-FileCopyrightText: 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) -# SPDX-License-Identifier: Apache-2.0 -blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature-issue.md b/.github/ISSUE_TEMPLATE/feature-issue.md new file mode 100644 index 0000000..198bb16 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-issue.md @@ -0,0 +1,14 @@ +--- +name: Feature request +about: Suggest an idea/feature for this project +title: '' +labels: 'kind/feature' +assignees: '' + +--- + +### Motivation +Describe here the motivation of the request. + +### Acceptance criteria +- [ ] A check list of tasks to be done to assume the issue addressed diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2704e61..a1ba6ea 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,9 +1,3 @@ - - ## Summary Briefly describe the purpose of this PR and what changes it introduces. diff --git a/.github/workflows/code-PR_sync_to_develop.yml b/.github/workflows/code-PR_sync_to_develop.yml new file mode 100644 index 0000000..162d023 --- /dev/null +++ b/.github/workflows/code-PR_sync_to_develop.yml @@ -0,0 +1,167 @@ +--- +name: code-PR-sync-to-develop + +on: + pull_request: + types: [opened, closed] + branches: ['main', 'main-*'] + paths-ignore: ['code/**'] + +jobs: + add-friendly-reminder: + name: Add Friendly Reminder Comment + if: github.head_ref != 'develop' && !startsWith(github.head_ref, 'develop-') && vars.DEVELOPMENT_FLOW != 'trunk-based-development' + timeout-minutes: 30 + runs-on: ubuntu-24.04 + outputs: + detected: ${{ steps.changes.outputs.paths }} + develop-branch: ${{ steps.sync-branch.outputs.DEVELOP_BRANCH }} + sync-branch: ${{ steps.sync-branch.outputs.SYNC_BRANCH }} + main-branch: ${{ steps.sync-branch.outputs.MAIN_BRANCH }} + steps: + - name: Check for changed files in specific paths + id: changes + uses: dorny/paths-filter@ebc4d7e9ebcb0b1eb21480bb8f43113e996ac77a + + with: + filters: | + paths: + - 'code/**' + + - name: Calculate SYNC, DEVELOP and MAIN branches + id: sync-branch + run: | + BASELINE_BRANCH=${{ github.base_ref }} + DEVELOP_BRANCH=${BASELINE_BRANCH/main/develop} + { + echo "DEVELOP_BRANCH=$DEVELOP_BRANCH" + echo "SYNC_BRANCH=automated/sync-from-$BASELINE_BRANCH-to-$DEVELOP_BRANCH" + echo "MAIN_BRANCH=$BASELINE_BRANCH" + } >> "$GITHUB_OUTPUT" + + - name: Checkout + if: steps.changes.outputs.paths == 'false' && github.event.pull_request.merged == false + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Add PR comment - Friendly reminder + if: steps.changes.outputs.paths == 'false' && github.event.pull_request.merged == false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + sync_branch="${{ steps.sync-branch.outputs.SYNC_BRANCH }}" + develop_branch="${{ steps.sync-branch.outputs.DEVELOP_BRANCH }}" + if [[ -z $(git ls-remote --heads origin $sync_branch) ]]; then + body=" + ### :eyes: Friendly reminder + - When this **pull request has been merged, its commits will be synchronized** from an automated pull request (\`$sync_branch → $develop_branch\`). + " + else + pull_request=$(gh api "/repos/${{ github.repository }}/pulls" | jq -r ".[] | select(.head.ref==\"$sync_branch\") | .number") + if [[ -n $pull_request ]]; then + body=" + ### :eyes: Friendly reminder + - When this **pull request has been merged, its commits will be synchronized** from an existent automated pull request [\`$sync_branch → $develop_branch\`](https://github.com/${{ github.repository }}/pull/$pull_request), rebasing the branch with the new changes introduced. + " + else + body=" + ### :eyes: Friendly reminder + - When this **pull request has been merged, its commits will be synchronized** from an automated pull request (\`$sync_branch → $develop_branch\`) rebasing the previous existent branch with the new changes introduced. + " + fi + fi + gh pr comment ${{ github.event.number }} --body "$body" + + sync-to-develop: + name: Code / Sync To Develop Branch + timeout-minutes: 30 + needs: add-friendly-reminder + if: needs.add-friendly-reminder.outputs.detected == 'false' && github.event.pull_request.merged == true && vars.DEVELOPMENT_FLOW != 'trunk-based-development' + runs-on: ubuntu-24.04 + concurrency: + group: ${{ github.workflow }}-${{ github.job }} + cancel-in-progress: true + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.base_ref }} + + - name: Get existent branches and pull requests from repository + id: get-info + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + sync_branch="${{ needs.add-friendly-reminder.outputs.sync-branch }}" + if [[ -n $(git ls-remote --heads origin $sync_branch) ]]; then + pull_request=$(gh api "/repos/${{ github.repository }}/pulls" | jq -r ".[] | select(.head.ref==\"$sync_branch\") | .number") + echo "PULL_REQUEST=$pull_request" >> "$GITHUB_OUTPUT" + fi + + - name: Commit changes + id: commit + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN_PUSH }} + run: | + develop="${{ needs.add-friendly-reminder.outputs.develop-branch }}" + if [[ -z $(git ls-remote --heads origin $develop) ]]; then + # Avoid creating sync PR if the corresponding development branch does not exist + echo "The $develop branch does not exist in remote. Skipping the creation of the sync PR" + else + sync_branch="${{ needs.add-friendly-reminder.outputs.sync-branch }}" + main_branch="${{ needs.add-friendly-reminder.outputs.main-branch }}" + + if [[ -n $(git ls-remote --heads origin $sync_branch) ]]; then + git checkout "$sync_branch" + git rebase $main_branch + git push --no-verify -u origin HEAD + echo "EXIST_BRANCH=True" >> "$GITHUB_OUTPUT" + else + git checkout -b "$sync_branch" + git push --no-verify -u origin HEAD + echo "EXIST_BRANCH=False" >> "$GITHUB_OUTPUT" + fi + fi + + - name: Create PR body + id: pr-body + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + pr_body="**Automated Pull Request** related to:" + pr_body="$pr_body"$'\n'"- #${{ github.event.pull_request.number }}" + + delimiter="$(openssl rand -hex 8)" + echo "PR_BODY<<${delimiter}" >> "$GITHUB_OUTPUT" + echo "$pr_body" >> "$GITHUB_OUTPUT" + echo "${delimiter}" >> "${GITHUB_OUTPUT}" + + - name: Create Automated PR + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN_PUSH }} + run: | + develop="${{ needs.add-friendly-reminder.outputs.develop-branch }}" + pull_request="${{ steps.get-info.outputs.PULL_REQUEST }}" + main_branch="${{ needs.add-friendly-reminder.outputs.main-branch }}" + pr_body="${{ steps.pr-body.outputs.PR_BODY }}" + + if [[ $pull_request != "" ]]; then + gh pr edit $pull_request -b "$pr_body" + else + gh pr create --base "$develop" \ + --title "Sync from $main_branch to $develop" \ + --label 'kind/internal' \ + --body "$pr_body" + fi + + - name: Add PR comment - On Failure + if: ${{ failure() && !cancelled() }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr comment ${{ github.event.number }} --body " + ### :exclamation: :exclamation: :exclamation: Sync to develop failure + - See the [workflow log](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}). + " diff --git a/.github/workflows/code-PR_verify-fallback.yml b/.github/workflows/code-PR_verify-fallback.yml new file mode 100644 index 0000000..14c4402 --- /dev/null +++ b/.github/workflows/code-PR_verify-fallback.yml @@ -0,0 +1,13 @@ +--- +name: code-PR-verify-fallback + +on: + pull_request: + +jobs: + unit-tests: + if: 'false' + name: Code / Verify + runs-on: ubuntu-24.04 + steps: + - run: 'echo "No Code / Verify required"' diff --git a/.github/workflows/code-maven_java-PR_verify.yml b/.github/workflows/code-maven_java-PR_verify.yml new file mode 100644 index 0000000..3904eac --- /dev/null +++ b/.github/workflows/code-maven_java-PR_verify.yml @@ -0,0 +1,152 @@ +--- +name: code-maven-PR-verify + +concurrency: + group: code-PR-verify-${{ github.event.pull_request.number }} + cancel-in-progress: true + +on: + pull_request: + paths: + - 'code/**' + - '.github/workflows/code*' + +env: + MAVEN_OPTS: "-Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn" + SONAR_MAVEN_PLUGIN_VERSION: 5.5.0.6356 + SONAR_JAVA_VERSION: temurin-21.0.4+7.0.LTS + +jobs: + unit-tests: + name: Code / Verify + runs-on: ubuntu-24.04 + permissions: + contents: write + checks: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Setup Maven Cache + uses: actions/cache@v4 + continue-on-error: true + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Setup asdf Cache + uses: actions/cache@v4 + continue-on-error: true + with: + path: ~/.asdf/data + key: ${{ runner.os }}-asdf-${{ hashFiles('**/.tool-versions') }} + restore-keys: | + ${{ runner.os }}-asdf- + + - name: Validate tool-versions content + run: | + if grep -Evq '^[a-zA-Z0-9_-]+ [a-zA-Z0-9._+-]+$' code/.tool-versions; then + echo "::error::Invalid .tool-versions content detected" + exit 1 + fi + + - name: Save tool-versions content + run: | + { + echo "TOOL_VERSIONS<> "$GITHUB_ENV" + + - name: Maven / Setup asdf environment + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4 + with: + tool_versions: ${{ env.TOOL_VERSIONS }} + + - name: Setup Java environment vars + working-directory: code + run: | + JAVA_HOME="$(asdf where java)" + echo "JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV + + - name: Store project information + if: ${{ vars.IS_INDITEXTECH_REPO == 'true' }} + id: version + run: | + echo "app-version=$(yq -oy '.project.version' code/pom.xml)" >> "$GITHUB_OUTPUT" + echo "app-name=$(yq -oy '.project.artifactId' code/pom.xml)" >> "$GITHUB_OUTPUT" + echo "github-repository=$(echo $GITHUB_REPOSITORY | cut -d'/' -f2)" >> "$GITHUB_OUTPUT" + + - name: Maven / Verify artifact with coverage + if: ${{ !(contains(github.event.pull_request.labels.*.name, 'autopublish/snapshot-binaries')) }} + working-directory: code + run: | + mvn -B clean verify -Djacoco.skip=false -DskipITs -DfailIfNoTests=false -Dmaven.test.failure.ignore=false -DskipEnforceSnapshots=true + + - name: Maven / Process Surefire report and annotate PR + if: ${{ always() && !cancelled() }} + uses: scacap/action-surefire-report@a2911bd1a4412ec18dde2d93b1758b3e56d2a880 # v1 + with: + fail_if_no_tests: false + create_check: false + check_name: "Code / Verify" + + - name: SonarCloud / Setup asdf tools + if: ${{ vars.IS_INDITEXTECH_REPO == 'true' }} + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4 + with: + tool_versions: | + java ${{ env.SONAR_JAVA_VERSION }} + nodejs 20.10.0 + maven 3.9.4 + + - name: SonarCloud / Set asdf versions + if: ${{ vars.IS_INDITEXTECH_REPO == 'true' }} + working-directory: code + run: | + cat > .tool-versions <> $GITHUB_ENV + + - name: SonarCloud / Run Maven Sonar goal + if: ${{ vars.IS_INDITEXTECH_REPO == 'true' }} + env: + PR_HEAD_REF: ${{ github.head_ref }} + LOGIN: ${{ secrets.SONAR_TOKEN }} + SONAR_SCANNER_OPTS: '' + working-directory: code + run: | + JACOCO_REPORT_PATH="$GITHUB_WORKSPACE/code/jacoco-report-aggregate/target/site/jacoco-aggregate/jacoco.xml" + mvn org.sonarsource.scanner.maven:sonar-maven-plugin:${{ env.SONAR_MAVEN_PLUGIN_VERSION }}:sonar \ + -Dsonar.projectKey=InditexTech_"${{ steps.version.outputs.github-repository }}" \ + -Dsonar.projectName="${{ steps.version.outputs.app-name }}" \ + -Dsonar.projectVersion="${{ steps.version.outputs.app-version }}" \ + -Dsonar.host.url="https://sonarcloud.io/" \ + -Dsonar.organization=inditextech \ + -Dsonar.token="${LOGIN}" \ + -Dsonar.pullrequest.key=${{ github.event.pull_request.number }} \ + -Dsonar.pullrequest.branch="$PR_HEAD_REF" \ + -Dsonar.pullrequest.base=${{ github.base_ref }} \ + -Dsonar.scm.revision=${{ github.event.pull_request.head.sha }} \ + -Dsonar.qualitygate.wait=true \ + -Dsonar.qualitygate.timeout=300 \ + -Dsonar.pullrequest.provider=GitHub \ + -Dsonar.pullrequest.github.repository="${{ github.repository }}" \ + -Dsonar.pullrequest.github.summary_comment=true \ + -Dsonar.coverage.jacoco.xmlReportPaths="$JACOCO_REPORT_PATH" diff --git a/.github/workflows/code-maven_java-build_snapshot.yml b/.github/workflows/code-maven_java-build_snapshot.yml new file mode 100644 index 0000000..df97660 --- /dev/null +++ b/.github/workflows/code-maven_java-build_snapshot.yml @@ -0,0 +1,288 @@ +--- +name: code-maven-build-snapshot +run-name: "Publish Snapshot: ${{ github.event_name }}" + +on: + issue_comment: + types: [created] + workflow_dispatch: + inputs: + BASELINE: + description: 'Branch to publish snapshot from' + required: true + default: 'develop' + +env: + MAVEN_OPTS: "-Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn" + +jobs: + publish-snapshot-from-pr: + name: Publish Snapshot (PR) + concurrency: code-build-snapshot + permissions: + contents: read + issues: write + pull-requests: write + if: > + github.event_name == 'issue_comment' && + github.event.issue.pull_request != null && + github.event.comment.body == '/publish-snapshot' + runs-on: ubuntu-24.04 + steps: + - name: Validate admin permissions + uses: actions/github-script@v7 + with: + script: | + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.actor + }); + const permission = data.permission; + core.info(`User permission level: ${permission}`); + if (permission !== 'admin') { + core.setFailed(`User @${context.actor} is not a repository admin.`); + } + + - name: Get PR head SHA + id: pr-head + uses: actions/github-script@v7 + with: + script: | + const { data } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + core.setOutput('sha', data.head.sha); + + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ steps.pr-head.outputs.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Maven Cache + uses: actions/cache@v4 + continue-on-error: true + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Setup asdf Cache + uses: actions/cache@v4 + continue-on-error: true + with: + path: ~/.asdf/data + key: ${{ runner.os }}-asdf-${{ hashFiles('**/.tool-versions') }} + restore-keys: | + ${{ runner.os }}-asdf- + + - name: Validate tool-versions content + run: | + if grep -Evq '^[a-zA-Z0-9_-]+ [a-zA-Z0-9._+-]+$' code/.tool-versions; then + echo "::error::Invalid .tool-versions content detected" + exit 1 + fi + + - name: Save tool-versions content + run: | + { + echo "TOOL_VERSIONS<> "$GITHUB_ENV" + + - name: Setup asdf environment + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4 + with: + tool_versions: ${{ env.TOOL_VERSIONS }} + + - name: Setup Java environment vars + run: | + JAVA_HOME="$(asdf where java)" + echo "JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV + + - name: Prepare committer information + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GPG_PRIVATE_KEY: ${{ secrets.CI_GPG_SECRET_KEY }} + GPG_PASSPHRASE: ${{ secrets.CI_GPG_SECRET_KEY_PASSWORD }} + run: | + git config --global credential.helper store + cat <> ~/.git-credentials + https://ci-user:$GITHUB_TOKEN@github.com + EOT + + # GPG: non-interactive signing setup + mkdir -p ~/.gnupg && chmod 700 ~/.gnupg + printf 'allow-loopback-pinentry\nallow-preset-passphrase\n' > ~/.gnupg/gpg-agent.conf + printf 'use-agent\npinentry-mode loopback\n' > ~/.gnupg/gpg.conf + gpgconf --kill gpg-agent || true + echo "$GPG_PRIVATE_KEY" | gpg --batch --import + KEY_DATA=$(gpg --list-secret-keys --with-colons) + echo "$KEY_DATA" | awk -F: '/^grp:/ {print $10}' | while read -r GRIP; do + /usr/lib/gnupg/gpg-preset-passphrase --preset "$GRIP" <<< "$GPG_PASSPHRASE" + done + + # Git: identity and signing + FPR=$(echo "$KEY_DATA" | awk -F: '/^fpr:/ {print $10; exit}') + git config user.name "srvcosoitxtech" + git config user.email "oso@inditex.com" + git config user.signingkey "$FPR" + git config commit.gpgsign true + git config tag.gpgsign true + + - name: Build & Deploy Snapshot + env: + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.CI_GPG_SECRET_KEY_PASSWORD }} + working-directory: code + run: | + mvn -B clean deploy -DskipTests -DskipUTs -DskipITs -DskipEnforceSnapshots=true --settings=.mvn/settings.xml + + - name: Comment on PR with result + if: always() + uses: actions/github-script@v7 + with: + script: | + const status = '${{ job.status }}'; + let body; + if (status === 'success') { + body = '### :rocket: Snapshot published successfully'; + } else { + const workflowUrl = `${context.payload.repository.html_url}/actions/runs/${context.runId}`; + body = `### :x: Snapshot publication failed\n\n[View workflow logs](${workflowUrl})`; + } + + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }); + + publish-snapshot-from-dispatch: + name: Publish Snapshot (Manual) + concurrency: code-build-snapshot + permissions: + contents: read + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + steps: + - name: Validate admin permissions + uses: actions/github-script@v7 + with: + script: | + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.actor + }); + const permission = data.permission; + core.info(`User permission level: ${permission}`); + if (permission !== 'admin') { + core.setFailed(`User @${context.actor} is not a repository admin.`); + } + + - name: Checkout branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.BASELINE }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Maven Cache + uses: actions/cache@v4 + continue-on-error: true + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Setup asdf Cache + uses: actions/cache@v4 + continue-on-error: true + with: + path: ~/.asdf/data + key: ${{ runner.os }}-asdf-${{ hashFiles('**/.tool-versions') }} + restore-keys: | + ${{ runner.os }}-asdf- + + - name: Validate tool-versions content + run: | + if grep -Evq '^[a-zA-Z0-9_-]+ [a-zA-Z0-9._+-]+$' code/.tool-versions; then + echo "::error::Invalid .tool-versions content detected" + exit 1 + fi + + - name: Save tool-versions content + run: | + { + echo "TOOL_VERSIONS<> "$GITHUB_ENV" + + - name: Setup asdf environment + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4 + with: + tool_versions: ${{ env.TOOL_VERSIONS }} + + - name: Setup Java environment vars + run: | + JAVA_HOME="$(asdf where java)" + echo "JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV + + - name: Prepare committer information + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GPG_PRIVATE_KEY: ${{ secrets.CI_GPG_SECRET_KEY }} + GPG_PASSPHRASE: ${{ secrets.CI_GPG_SECRET_KEY_PASSWORD }} + run: | + git config --global credential.helper store + cat <> ~/.git-credentials + https://ci-user:$GITHUB_TOKEN@github.com + EOT + + # GPG: non-interactive signing setup + mkdir -p ~/.gnupg && chmod 700 ~/.gnupg + printf 'allow-loopback-pinentry\nallow-preset-passphrase\n' > ~/.gnupg/gpg-agent.conf + printf 'use-agent\npinentry-mode loopback\n' > ~/.gnupg/gpg.conf + gpgconf --kill gpg-agent || true + echo "$GPG_PRIVATE_KEY" | gpg --batch --import + KEY_DATA=$(gpg --list-secret-keys --with-colons) + echo "$KEY_DATA" | awk -F: '/^grp:/ {print $10}' | while read -r GRIP; do + /usr/lib/gnupg/gpg-preset-passphrase --preset "$GRIP" <<< "$GPG_PASSPHRASE" + done + + # Git: identity and signing + FPR=$(echo "$KEY_DATA" | awk -F: '/^fpr:/ {print $10; exit}') + git config user.name "srvcosoitxtech" + git config user.email "oso@inditex.com" + git config user.signingkey "$FPR" + git config commit.gpgsign true + git config tag.gpgsign true + + - name: Build & Deploy Snapshot + env: + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.CI_GPG_SECRET_KEY_PASSWORD }} + working-directory: code + run: | + mvn -B clean deploy -DskipTests -DskipUTs -DskipITs -DskipEnforceSnapshots=true --settings=.mvn/settings.xml + + - name: Write Job Summary + if: always() + run: | + if [[ "${{ job.status }}" == "success" ]]; then + echo "### :rocket: Snapshot published successfully" >> "$GITHUB_STEP_SUMMARY" + else + echo "### :x: Snapshot publication failed" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/code-maven_java-release.yml b/.github/workflows/code-maven_java-release.yml new file mode 100644 index 0000000..96548e9 --- /dev/null +++ b/.github/workflows/code-maven_java-release.yml @@ -0,0 +1,249 @@ +--- +name: code-maven-release +run-name: "Release labeled ${{ inputs.RELEASE_TYPE || 'in PR' }}" + +concurrency: code-release-${{ github.ref }} + +on: + pull_request: + types: [closed] + branches: ['main', 'main-*'] + paths: ['code/**', '.github/workflows/code**'] + workflow_dispatch: + inputs: + BASELINE: + description: 'Baseline branch' + required: true + default: 'main' + RELEASE_TYPE: + description: 'Release type to use' + required: true + default: 'release-type/minor' + type: choice + options: + - 'release-type/hotfix' + - 'release-type/multi-hotfix' + - 'release-type/major' + - 'release-type/minor' + - 'release-type/patch' + +env: + MAVEN_OPTS: "-Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn" + +jobs: + release: + name: Release + permissions: + contents: write + pull-requests: write + if: github.event_name == 'workflow_dispatch' + || (github.event.pull_request.merged == true && !contains(join(github.event.pull_request.labels.*.name, ', '), 'skip-release') + && (contains(join(github.event.pull_request.labels.*.name, ', '), 'release-type') + || vars.DEVELOPMENT_FLOW == 'trunk-based-development' )) + runs-on: ubuntu-24.04 + steps: + - name: Get input parameters + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + RELEASE_LABELS="${{ github.event.inputs.RELEASE_TYPE }}" + else + RELEASE_LABELS="${{ join(github.event.pull_request.labels.*.name, ', ') }}" + if [[ $RELEASE_LABELS != *release-type/* ]]; then + RELEASE_LABELS="$RELEASE_LABELS, release-type/minor" + fi + fi + echo "RELEASE_LABELS=$RELEASE_LABELS" >> "$GITHUB_ENV" + BASELINE_BRANCH=${{ github.event.inputs.BASELINE || github.ref }} + echo "BASELINE_BRANCH=${BASELINE_BRANCH#refs/heads/}" >> "$GITHUB_ENV" + + - name: Checkout merge commit + uses: actions/checkout@v4 + with: + ref: ${{ env.BASELINE_BRANCH }} + fetch-depth: 0 + persist-credentials: false + + - name: Check if CHANGELOG.md has changes + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if git diff --quiet HEAD^ HEAD -- code/CHANGELOG.md; then + echo "::error title={No CHANGELOG.md changes}::{No CHANGELOG.md changes were found. Update the UNRELEASED section with the new changes.}" + gh pr comment ${{ github.event.number }} --body " + ### :x: No changes in the \`CHANGELOG.md\` file + No changes were found in the \`CHANGELOG.md\` file. Please, update the UNRELEASED section, listing the new changes that applies to this release." + exit 1 + fi + + - name: Setup Maven Cache + uses: actions/cache@v4 + continue-on-error: true + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Setup asdf Cache + id: asdf-cache + uses: actions/cache@v4 + continue-on-error: true + with: + path: ~/.asdf/data + key: ${{ runner.os }}-asdf-${{ hashFiles('**/.tool-versions') }} + restore-keys: | + ${{ runner.os }}-asdf- + + - name: Validate tool-versions content + run: | + if grep -Evq '^[a-zA-Z0-9_-]+ [a-zA-Z0-9._+-]+$' code/.tool-versions; then + echo "::error::Invalid .tool-versions content detected" + exit 1 + fi + + - name: Save tool-versions content + run: | + { + echo "TOOL_VERSIONS<> "$GITHUB_ENV" + + - name: Setup asdf environment + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4 + # https://github.com/asdf-vm/actions/issues/356 + if: steps.asdf-cache.outputs.cache-hit != 'true' + with: + tool_versions: ${{ env.TOOL_VERSIONS }} + + - name: Setup Java environment vars + run: | + JAVA_HOME="$(asdf where java)" + echo "JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV + + - name: Setup patch release type version + if: contains(env.RELEASE_LABELS, 'release-type/hotfix') + || contains(env.RELEASE_LABELS, 'release-type/multi-hotfix') + || contains(env.RELEASE_LABELS, 'release-type/patch') + run: echo "RELEASE_VERSION=patch" >> "$GITHUB_ENV" + + - name: Setup minor release type version + if: contains(env.RELEASE_LABELS, 'release-type/minor') + run: echo "RELEASE_VERSION=minor" >> "$GITHUB_ENV" + + - name: Setup minor release type version when no label set and TBD + if: ${{ !contains(env.RELEASE_LABELS, 'release-type') && vars.DEVELOPMENT_FLOW == 'trunk-based-development' }} + run: echo "RELEASE_VERSION=minor" >> "$GITHUB_ENV" + + - name: Setup major release type version + if: contains(env.RELEASE_LABELS, 'release-type/major') + run: echo "RELEASE_VERSION=major" >> "$GITHUB_ENV" + + - name: Prepare committer information + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GPG_PRIVATE_KEY: ${{ secrets.CI_GPG_SECRET_KEY }} + GPG_PASSPHRASE: ${{ secrets.CI_GPG_SECRET_KEY_PASSWORD }} + run: | + git config --global credential.helper store + cat <> ~/.git-credentials + https://ci-user:$GITHUB_TOKEN@github.com + EOT + + # GPG: non-interactive signing setup + mkdir -p ~/.gnupg && chmod 700 ~/.gnupg + printf 'allow-loopback-pinentry\nallow-preset-passphrase\n' > ~/.gnupg/gpg-agent.conf + printf 'use-agent\npinentry-mode loopback\n' > ~/.gnupg/gpg.conf + gpgconf --kill gpg-agent || true + echo "$GPG_PRIVATE_KEY" | gpg --batch --import + KEY_DATA=$(gpg --list-secret-keys --with-colons) + echo "$KEY_DATA" | awk -F: '/^grp:/ {print $10}' | while read -r GRIP; do + /usr/lib/gnupg/gpg-preset-passphrase --preset "$GRIP" <<< "$GPG_PASSPHRASE" + done + + # Git: identity and signing + FPR=$(echo "$KEY_DATA" | awk -F: '/^fpr:/ {print $10; exit}') + git config user.name "srvcosoitxtech" + git config user.email "oso@inditex.com" + git config user.signingkey "$FPR" + git config commit.gpgsign true + git config tag.gpgsign true + + - name: Update CHANGELOG.md + id: update-changelog + uses: release-flow/keep-a-changelog-action@74931dec7ecdbfc8e38ac9ae7e8dd84c08db2f32 # v3.0.0 + with: + command: bump + version: ${{ env.RELEASE_VERSION }} + changelog: code/CHANGELOG.md + fail-on-empty-release-notes: false + keep-unreleased-section: true + tag-prefix: "" + + - name: Deploy released artifact + id: maven-release-deploy + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.CI_GPG_SECRET_KEY_PASSWORD }} + working-directory: code + run: | + git add CHANGELOG.md + git commit -m "chore: Update CHANGELOG with ${{ steps.update-changelog.outputs.version }} version" + git push --no-verify -u origin HEAD + mvn -B release:prepare release:perform --settings=.mvn/settings.xml -DreleaseVersion=${{ steps.update-changelog.outputs.version }} + + - name: Next Development Iteration / Create Sync Branch PR into Develop + id: sync-branch-pr + continue-on-error: true + if: ${{ vars.DEVELOPMENT_FLOW != 'trunk-based-development' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + DEVELOP=${BASELINE_BRANCH/main/develop} + echo "DEVELOP=$DEVELOP" >> "$GITHUB_OUTPUT" + # Avoid creating sync PR if the corresponding development branch does not exist + if [[ -z $(git ls-remote --heads origin "$DEVELOP") ]]; then + echo "The '$DEVELOP' branch does not exist in remote. Skipping the creation of sync PR" + else + git checkout -b "automated/sync-release-${{ steps.update-changelog.outputs.version }}-to-$DEVELOP" + git push --no-verify -u origin HEAD + gh pr create --base "$DEVELOP" \ + --title "Sync release ${{ steps.update-changelog.outputs.version }} to $DEVELOP" \ + --body "**Automated Pull Request**" + fi + + - name: Github Release / Create + uses: ncipollo/release-action@2c591bcc8ecdcd2db72b97d6147f871fcd833ba5 # v1.14.0 + id: github-release + continue-on-error: true + with: + name: ${{ steps.update-changelog.outputs.version }} + tag: ${{ steps.update-changelog.outputs.version }} + token: ${{ secrets.GITHUB_TOKEN }} + body: | + Check out the [changelog](code/CHANGELOG.md) for version ${{ steps.update-changelog.outputs.version }} + + - name: Comment in PR / Sync PR creation failed + if: ${{ vars.DEVELOPMENT_FLOW != 'trunk-based-development' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + DEVELOP=${BASELINE_BRANCH/main/develop} + git remote set-url origin "https://x-access-token:$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY" + # shellcheck disable=SC2140 + if ! git ls-remote --exit-code --heads origin automated/sync-release-"${{ steps.update-changelog.outputs.version }}"-to-"$DEVELOP"; then + gh pr comment "${{ github.event.number }}" --body "An error occurred creating the \`sync\` branch that synchronizes the \`$BASELINE_BRANCH\` and \`$DEVELOP\` branches. + Please create a branch from \`$BASELINE_BRANCH\` (e.g. \`internal/sync-$BASELINE_BRANCH-with-$DEVELOP\`) and then create a pull request against \`$DEVELOP\` to finish the release process." + fi + + - name: Comment in PR / Release creation failed + if: ${{ steps.github-release.outcome == 'failure' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: > + gh pr comment ${{ github.event.number }} --body "An error occurred creating the Github Release. + Don't panic! Your artifacts were successfully uploaded to the Distribution Platform and the new release tag was created. + You can manually complete the release by creating it in the [releases](https://github.com/${{ github.repository }}/releases) + page." \ No newline at end of file diff --git a/.github/workflows/code-maven_java-sonarcloud-analysis.yml b/.github/workflows/code-maven_java-sonarcloud-analysis.yml new file mode 100644 index 0000000..76d08cf --- /dev/null +++ b/.github/workflows/code-maven_java-sonarcloud-analysis.yml @@ -0,0 +1,160 @@ +--- +name: code-maven-sonarcloud-analysis +run-name: Sonarcloud analysis on ${{ github.base_ref || github.ref_name }} branch + +concurrency: + group: sonarcloud-${{ github.ref }} + cancel-in-progress: true + +on: + workflow_dispatch: + pull_request: + types: [closed] + branches: ['develop', 'develop-*', 'main', 'main-*'] + paths: ['code/**', '.github/workflows/code-*-sonarcloud-analysis.yml'] + release: + types: + - published + +env: + MAVEN_OPTS: "-Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn" + SONAR_MAVEN_PLUGIN_VERSION: 5.5.0.6356 + SONAR_JAVA_VERSION: temurin-21.0.4+7.0.LTS + +jobs: + unit-tests: + name: SonarCloud / Unit Tests + permissions: + contents: read + timeout-minutes: 30 + if: ${{ ((github.event.pull_request.merged == true && (vars.DEVELOPMENT_FLOW != 'trunk-based-development' && (github.base_ref == 'develop' || startsWith(github.base_ref, 'develop-'))) || + (vars.DEVELOPMENT_FLOW == 'trunk-based-development' && (github.base_ref == 'main' || startsWith(github.base_ref, 'main-')))) || + github.event_name == 'workflow_dispatch' || + github.event_name == 'release') + && vars.IS_INDITEXTECH_REPO == 'true' }} + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Maven Cache + uses: actions/cache@v4 + continue-on-error: true + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Setup asdf Cache + uses: actions/cache@v4 + continue-on-error: true + with: + path: ~/.asdf/data + key: ${{ runner.os }}-asdf-${{ hashFiles('**/.tool-versions') }} + restore-keys: | + ${{ runner.os }}-asdf- + + - name: Validate tool-versions content + run: | + if grep -Evq '^[a-zA-Z0-9_-]+ [a-zA-Z0-9._+-]+$' code/.tool-versions; then + echo "::error::Invalid .tool-versions content detected" + exit 1 + fi + + - name: Save tool-versions content + run: | + { + echo "TOOL_VERSIONS<> "$GITHUB_ENV" + + - name: Maven / Setup asdf tools + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4 + with: + tool_versions: ${{ env.TOOL_VERSIONS }} + + - name: Setup Java environment vars + working-directory: code + run: | + JAVA_HOME="$(asdf where java)" + echo "JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV + + - name: Maven / Run unit tests with release event + if: github.event_name == 'release' + working-directory: code + run: | + mvn -B clean verify -Djacoco.skip=false -DskipEnforceSnapshots -DskipITs -DfailIfNoTests=false -Dmaven.test.failure.ignore=false + + - name: Maven / Run unit tests + if: github.event_name != 'release' + working-directory: code + run: | + mvn -B clean verify -Djacoco.skip=false -DskipITs -DfailIfNoTests=false -Dmaven.test.failure.ignore=false + + - name: Store project information + id: version + run: | + echo "app-version=$(yq -oy '.project.version' code/pom.xml)" >> "$GITHUB_OUTPUT" + echo "app-name=$(yq -oy '.project.artifactId' code/pom.xml)" >> "$GITHUB_OUTPUT" + echo "github-repository=$(echo $GITHUB_REPOSITORY | cut -d'/' -f2)" >> "$GITHUB_OUTPUT" + + - name: SonarCloud / Setup asdf tools + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4 + with: + tool_versions: | + java ${{ env.SONAR_JAVA_VERSION }} + nodejs 20.10.0 + maven 3.9.4 + + - name: SonarCloud / Set asdf versions + working-directory: code + run: | + asdf local java ${{ env.SONAR_JAVA_VERSION }} + asdf local nodejs 20.10.0 + asdf local maven 3.9.4 + + - name: Setup Java environment vars + working-directory: code + run: | + JAVA_HOME="$(asdf where java)" + echo "JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV + + - name: SonarCloud / Run Maven Sonar goal with release event + env: + LOGIN: ${{ secrets.SONAR_TOKEN }} + SONAR_SCANNER_OPTS: '' + if: ${{ github.event_name == 'release' }} + working-directory: code + run: | + JACOCO_REPORT_PATH="$GITHUB_WORKSPACE/code/jacoco-report-aggregate/target/site/jacoco-aggregate/jacoco.xml" + mvn org.sonarsource.scanner.maven:sonar-maven-plugin:${{ env.SONAR_MAVEN_PLUGIN_VERSION }}:sonar \ + -Dsonar.projectKey=InditexTech_"${{ steps.version.outputs.github-repository }}" \ + -Dsonar.projectName="${{ steps.version.outputs.app-name }}" \ + -Dsonar.projectVersion="${{ github.event.release.tag_name }}" \ + -Dsonar.branch.name="release/${{ github.event.release.tag_name }}" \ + -Dsonar.host.url="https://sonarcloud.io/" \ + -Dsonar.organization=inditextech \ + -Dsonar.token="${LOGIN}" \ + -Dsonar.coverage.jacoco.xmlReportPaths="$JACOCO_REPORT_PATH" + + - name: SonarCloud / Run Maven Sonar goal + env: + LOGIN: ${{ secrets.SONAR_TOKEN }} + SONAR_SCANNER_OPTS: '' + if: ${{ github.event_name != 'release' }} + working-directory: code + run: | + JACOCO_REPORT_PATH="$GITHUB_WORKSPACE/code/jacoco-report-aggregate/target/site/jacoco-aggregate/jacoco.xml" + mvn org.sonarsource.scanner.maven:sonar-maven-plugin:${{ env.SONAR_MAVEN_PLUGIN_VERSION }}:sonar \ + -Dsonar.projectKey=InditexTech_"${{ steps.version.outputs.github-repository }}" \ + -Dsonar.projectName="${{ steps.version.outputs.app-name }}" \ + -Dsonar.projectVersion="${{ steps.version.outputs.app-version }}" \ + -Dsonar.branch.name="${{ github.base_ref || github.ref_name }}" \ + -Dsonar.host.url="https://sonarcloud.io/" \ + -Dsonar.organization=inditextech \ + -Dsonar.token="${LOGIN}" \ + -Dsonar.coverage.jacoco.xmlReportPaths="$JACOCO_REPORT_PATH" \ No newline at end of file diff --git a/.github/workflows/code-release_preview.yml b/.github/workflows/code-release_preview.yml new file mode 100644 index 0000000..a657a6b --- /dev/null +++ b/.github/workflows/code-release_preview.yml @@ -0,0 +1,231 @@ +--- +name: code-release-preview + +concurrency: + group: release-preview + cancel-in-progress: true + +on: + pull_request: + types: [labeled, synchronize, ready_for_review, opened] + branches: ['main', 'main-*'] + +env: + PR_HEAD_REF: ${{ github.head_ref }} + +jobs: + check-changes-in-paths: + name: Check for changes in corresponding paths + permissions: + contents: read + pull-requests: read + runs-on: ubuntu-24.04 + if: ${{ github.event.pull_request.draft == false || contains(join(github.event.pull_request.labels.*.name, ', '), 'release-type') || contains(join(github.event.pull_request.labels.*.name, ', '), 'release-preview') }} + outputs: + detected: ${{ steps.changes.outputs.paths }} + steps: + - name: Check for changed files in specific paths + id: changes + uses: dorny/paths-filter@ebc4d7e9ebcb0b1eb21480bb8f43113e996ac77a # v3.0.1 + with: + filters: | + paths: + - 'code/**' + - '.github/workflows/code*' + + release-preview: + name: Release Preview + permissions: + contents: read + pull-requests: write + needs: check-changes-in-paths + if: ${{ (contains(join(github.event.pull_request.labels.*.name, ', '), 'release-type') || contains(join(github.event.pull_request.labels.*.name, ', '), 'release-preview')) && needs.check-changes-in-paths.outputs.detected == 'true' }} + runs-on: ubuntu-24.04 + steps: + - name: Checkout merge commit + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Maven Cache + uses: actions/cache@v4 + continue-on-error: true + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Setup asdf Cache + uses: actions/cache@v4 + continue-on-error: true + with: + path: ~/.asdf/data + key: ${{ runner.os }}-asdf-${{ hashFiles('**/.tool-versions') }} + restore-keys: | + ${{ runner.os }}-asdf- + + - name: Validate tool-versions content + run: | + if grep -Evq '^[a-zA-Z0-9_-]+ [a-zA-Z0-9._+-]+$' code/.tool-versions; then + echo "::error::Invalid .tool-versions content detected" + exit 1 + fi + + - name: Save tool-versions content + run: | + { + echo "TOOL_VERSIONS<> "$GITHUB_ENV" + + - name: Setup asdf environment + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4 + with: + tool_versions: ${{ env.TOOL_VERSIONS }} + + - name: Setup Java environment vars + run: | + JAVA_HOME="$(asdf where java)" + echo "JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV + + - name: Setup hotfix release type version and simulate merge + if: ${{ contains(github.event.pull_request.labels.*.name, 'release-type/hotfix') }} + run: echo "RELEASE_VERSION=patch" >> "$GITHUB_ENV" + + - name: Setup multi-hotfix release type version + if: contains(github.event.pull_request.labels.*.name, 'release-type/multi-hotfix') + run: echo "RELEASE_VERSION=patch" >> "$GITHUB_ENV" + + - name: Setup patch release type version + if: contains(github.event.pull_request.labels.*.name, 'release-type/patch') + run: echo "RELEASE_VERSION=patch" >> "$GITHUB_ENV" + + - name: Setup minor release type version + if: contains(github.event.pull_request.labels.*.name, 'release-type/minor') + run: echo "RELEASE_VERSION=minor" >> "$GITHUB_ENV" + + - name: Setup major release type version + if: contains(github.event.pull_request.labels.*.name, 'release-type/major') + run: echo "RELEASE_VERSION=major" >> "$GITHUB_ENV" + + - name: Check merge strategy + run: | + if [[ "$PR_HEAD_REF" == hotfix* && "${{ contains(github.event.pull_request.labels.*.name, 'release-type/multi-hotfix') }}" != "true" ]] ; + then + echo "MERGE_STRATEGY=Squash and Merge" >> "$GITHUB_ENV" + elif [[ "${{vars.DEVELOPMENT_FLOW}}" == 'trunk-based-development' && ("$PR_HEAD_REF" == hotfix* && "${{ contains(github.event.pull_request.labels.*.name, 'release-type/multi-hotfix') }}" == "true") ]] ; + then + echo "MERGE_STRATEGY=Create a merge commit" >> "$GITHUB_ENV" + elif [[ "${{ vars.DEVELOPMENT_FLOW }}" == 'trunk-based-development' ]] ; + then + echo "MERGE_STRATEGY=Squash and Merge" >> "$GITHUB_ENV" + else + echo "MERGE_STRATEGY=Create a merge commit" >> "$GITHUB_ENV" + fi + + - name: Check if CHANGELOG.md has changes + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if git diff --quiet HEAD^ HEAD -- code/CHANGELOG.md; then + echo "::error title={No CHANGELOG.md changes}::{No CHANGELOG.md changes were found. Update the UNRELEASED section with the new changes.}" + gh pr comment ${{ github.event.number }} --body " + ### :x: No changes in the \`CHANGELOG.md\` file + No changes were found in the \`CHANGELOG.md\` file. Please, update the UNRELEASED section, listing the new changes that applies to this release." + exit 1 + fi + + - name: Update CHANGELOG.md + id: update-changelog + uses: release-flow/keep-a-changelog-action@74931dec7ecdbfc8e38ac9ae7e8dd84c08db2f32 # v3.0.0 + with: + command: bump + version: ${{ env.RELEASE_VERSION }} + changelog: code/CHANGELOG.md + fail-on-empty-release-notes: false + keep-unreleased-section: true + tag-prefix: "" + + - name: Add PR comment with release preview + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TASKS=$(awk -v version="${{ steps.update-changelog.outputs.version }}" ' + BEGIN { capture=0; found_version=0 } + $0 ~ "## \\[" version "\\] -" { capture=1; found_version=1; print; next } + capture && $0 ~ /^## \[.*\] -/ { capture=0 } + capture { print } + END { + if (found_version) { + while ((getline line < "code/CHANGELOG.md") > 0) { + if (line ~ /^\[.*\]:/) { + print line + } + } + } + } + ' code/CHANGELOG.md) + MESSAGE=" + + ### :rocket: Release Preview Success + You are going to release the version **${RELEASE_VERSION}** with the following changes: + $TASKS + + ### 💡 Merge Strategy: $MERGE_STRATEGY + + Remember to use the **'$MERGE_STRATEGY'** strategy to merge this _Pull Request (\`$PR_HEAD_REF\` → \`${{ github.event.pull_request.base.ref }}\`)_. + " + + gh pr comment ${{ github.event.number }} --body "$(echo -e "$MESSAGE")" + + release-preview-no-code-changes: + name: Add PR comment with configuration management information + permissions: + contents: read + pull-requests: write + needs: check-changes-in-paths + if: ${{ (contains(join(github.event.pull_request.labels.*.name, ', '), 'release-type') || (contains(join(github.event.pull_request.labels.*.name, ', '), 'release-preview'))) && needs.check-changes-in-paths.outputs.detected == 'false' }} + runs-on: ubuntu-24.04 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout merge commit + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Add PR comment with release preview + run: | + BODY=" + ### :exclamation: :exclamation: :exclamation: This Pull Request will not trigger a Release + A Pull Request with no changes to the \`code/\` folder will not trigger a release" + + gh pr comment ${{ github.event.number }} --body "$BODY" + + release-preview-no-release-labels: + name: Add PR comment with release information + permissions: + contents: read + pull-requests: write + needs: check-changes-in-paths + if: ${{ !contains(join(github.event.pull_request.labels.*.name, ', '), 'release-type') && needs.check-changes-in-paths.outputs.detected == 'true' && github.event.pull_request.draft == false && vars.DEVELOPMENT_FLOW != 'trunk-based-development' }} + runs-on: ubuntu-24.04 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout merge commit + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Add PR comment with release preview + run: | + gh pr comment ${{ github.event.number }} --body " + ### :exclamation: :exclamation: :exclamation: This Pull Request will not trigger a Release + A Pull Request with no \`release-type/...\` labels will not trigger a release, so you need to label this PR if you want to create a release. + " \ No newline at end of file diff --git a/.github/workflows/docs/code-PR_sync_to_develop.md b/.github/workflows/docs/code-PR_sync_to_develop.md new file mode 100644 index 0000000..94e0aaa --- /dev/null +++ b/.github/workflows/docs/code-PR_sync_to_develop.md @@ -0,0 +1,19 @@ +# `code-PR_sync_to_develop` + +[`code-PR_sync_to_develop.yml`](../code-PR_sync_to_develop.yml) generates an automated pull request from main to develop (only applicable with GitFlow development flow). + +## Trigger + +Any pull request `merged` with `non-code` changes. + +## Where does it run? + +`ubuntu-24.04` GitHub infrastructure. + +## Jobs + +- ### `sync-to-develop` + + - **Steps** + + - Create a pull request with the changes merged in `main`. diff --git a/.github/workflows/docs/code-PR_verify-fallback.md b/.github/workflows/docs/code-PR_verify-fallback.md new file mode 100644 index 0000000..26f4685 --- /dev/null +++ b/.github/workflows/docs/code-PR_verify-fallback.md @@ -0,0 +1,19 @@ +# `code-PR_verify-fallback` + +[`code-PR_verify-fallback.yml`](../code-PR_verify-fallback.yml) workflow allows to pass Required Status Checks in pull request with changes outside the monitored paths: +- `code/**` +- `.github/workflows/code*` + +Take a look to the related documentation: [Handling skipped but required checks](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/troubleshooting-required-status-checks#handling-skipped-but-required-checks) + +## Trigger +Any pull request `opened` with only changes outside `code` folder or `code-*` workflows. + +## Where does it run? + +`ubuntu-24.04` GitHub infrastructure. + +## Jobs + +- ### `unit-test` +This job is defined **exclusively** to fulfill the Required Status Check of **"Code / Verify"**, and it **does not actually run**, since it is enough to be skipped to comply with the required check (`if: 'false'`). diff --git a/.github/workflows/docs/code-maven_java-PR_verify.md b/.github/workflows/docs/code-maven_java-PR_verify.md new file mode 100644 index 0000000..43dc112 --- /dev/null +++ b/.github/workflows/docs/code-maven_java-PR_verify.md @@ -0,0 +1,39 @@ +# `code-maven-PR_verify` + +[`code-maven_java-PR_verify.yml`](../code-maven_java-PR_verify.yml) workflow allows to **run** different types of tests. + +## Trigger + +Any pull request `opened` with changes about `code` folder. + +## Where does it run? + +`ubuntu-24.04` GitHub infrastructure. + +## Versions used + +`asdf` and any `Java`, `Maven` and `Node`. + +## How does it work? + +This workflow relies on asdf to automatically load any tool version defined on the project's `code/.tool-versions` file. + +## Jobs + +- ### `unit-tests` + + - **Steps** + - Checkout the repository in the specific pull request. + - Setup Maven and Asdf caches. + - Configure asdf environment with the added tools in the `.tool-versions` file. + - Setup JAVA_HOME env var. + - Store project version and name. + - Verify artifact with coverage. + - if (repository variable IS_INDITEXTECH_REPO has true value): + - Prepare committer information and set GPG key, that needs to be configured in the repository. + - Verify artifact with coverage and deploy PR-versioned SNAPSHOT binaries. Several secrets are needed. + - Process Surefire report and annotate PR + - if (repository variable IS_INDITEXTECH_REPO has true value): + - Setup asdf tools for sonarcloud usage. + - Set JAVA_HOME env var. + - Run Maven Sonar goal. diff --git a/.github/workflows/docs/code-maven_java-build_snapshot.md b/.github/workflows/docs/code-maven_java-build_snapshot.md new file mode 100644 index 0000000..ff1c5de --- /dev/null +++ b/.github/workflows/docs/code-maven_java-build_snapshot.md @@ -0,0 +1,51 @@ +_# `code-maven-build_snapshot` + +[`code-maven_java-build_snapshot.yml`](../code-maven_java-build_snapshot.yml) workflow deploys a snapshot version to the distribution platform + +## Trigger + +- Using GitFlow: Any push on `develop` / `develop-*` branches with changes in `code` path. +- Using TBD: Any push on `main` / `main-*` branches with changes in `code` path. + +## Where does it run? + +`ubuntu-24.04` GitHub infrastructure. + +## Versions used + +`asdf` and any `Java`, `Maven` and `Node`. + +## How does it work? + +This workflow relies on asdf to automatically load any tool version defined on the project's `code/.tool-versions` file. + +## Jobs + +- ### `publish-snapshot-from-pr` + + Publishes a snapshot version from a pull request when triggered by the `/publish-snapshot` comment. + + - **Steps** + - Validates admin permissions + - Checks out PR branch + - Sets up caches and asdf environment + - Configures GPG and Git + - Runs `mvn deploy` to publish snapshot to OSSRH + +- ### `publish-snapshot-from-dispatch` + + Publishes a snapshot version from a branch when manually triggered. + + - **Steps** + - Validates admin permissions + - Checks out specified branch + - Sets up caches and asdf environment + - Configures GPG and Git + - Runs `mvn deploy` to publish snapshot to OSSRH + - Writes job summary with version information + +## Configuration + +Snapshots are published to **OSSRH** (OSS Repository Hosting) at `https://s01.oss.sonatype.org/content/repositories/snapshots`. + +**Note**: Maven Central's `central-publishing-maven-plugin` does not support snapshot deployments. Snapshots use the traditional `maven-deploy-plugin` with OSSRH repository configuration. diff --git a/.github/workflows/docs/code-maven_java-release.md b/.github/workflows/docs/code-maven_java-release.md new file mode 100644 index 0000000..8e03474 --- /dev/null +++ b/.github/workflows/docs/code-maven_java-release.md @@ -0,0 +1,46 @@ +# `code-maven-release` + +[`code-maven_java-release.yml`](../code-maven_java-release.yml) workflow releases a new version to Maven Central and also generates the associated release in GitHub. + +## Triggers + +- Any `closed` pull request to `main` branch on `code` path, if no `skip-release` label AND if a `release-type/*` label is added if using Gitflow development flow. +- A manual dispatch (`workflow_dispatch`) invoked from the GitHub UI. + +## Where does it run? + +`ubuntu-24.04` GitHub infrastructure. + +## Versions used + +`asdf` and any `Java`, `Maven` and `Node`. + +## How does it work? + +This workflow relies on asdf to automatically load any tool version defined on the project's `code/.tool-versions` file. + +## Jobs + +- ### `release` + + - **Steps** + + - Get the release labels and the baseline branch. + - Checkout the specific merge commit or the baseline branch if using workflow_dispatch trigger. + - Throw an error if CHANGELOG.md changes are not added. + - Setup Maven and Asdf caches. + - Configure asdf environment with the added tools in the `.tool-versions` file. + - Setup JAVA_HOME env var. + - Update the version based on the input release type. + - Setup the version increment depending on the introduced release labels. + - Prepare committer information and set GPG key, that needs to be configured in the repository. + - Update CHANGELOG.md and calculate next version using `release-flow/keep-a-changelog-action` action + - CHANGELOG.md is restored + - Goal Maven with `mvn release` + - `mvn deploy` artifacts into Maven Central + - `git tag` on GitHub with final version setted in previous steps + - Prepare the possible next version in the `pom.xml` files + - Create Sync Branch PR into Develop if using Gitflow development flow. + - Publish release on GitHub + - Comment in PR if sync PR failed + - Comment in PR if the release creation has failed \ No newline at end of file diff --git a/.github/workflows/docs/code-maven_java-sonarcloud-analysis.md b/.github/workflows/docs/code-maven_java-sonarcloud-analysis.md new file mode 100644 index 0000000..2ac7394 --- /dev/null +++ b/.github/workflows/docs/code-maven_java-sonarcloud-analysis.md @@ -0,0 +1,43 @@ +# `code-maven_java-sonarcloud-analysis` + +[`code-maven_java-sonarcloud-analysis.yml`](../code-maven_java-sonarcloud-analysis.yml) workflow allows to **run** the Sonarcloud scanner. + +## Trigger + +- Any `closed` and `merged` pull request to `main` branch with changes on `code` path if using Trunk-based development, AND IS_INDITEXTECH_REPO repository variable has `true` value. +- Any `closed` and `merged` pull request to `develop` branch with changes on `code` path if using Gitflow, AND IS_INDITEXTECH_REPO repository variable has `true` value. +- A manual dispatch (`workflow_dispatch`) invoked from the GitHub UI AND IS_INDITEXTECH_REPO repository variable has `true` value. +- When publishing a GitHub release AND IS_INDITEXTECH_REPO repository variable has `true` value. + +## Where does it run? + +`ubuntu-24.04` GitHub infrastructure. + +## Versions used + +`asdf` and any `Java`, `Maven` and `Node`. + +## How does it work? + +This workflow relies on asdf to automatically load any tool version defined on the project's `code/.tool-versions` file. + +## Jobs + +- ### `unit-tests` + + - **Steps** + - Checkout the repository. + - Setup Maven and Asdf caches. + - Configure asdf environment with the added tools in the `.tool-versions` file. + - Setup JAVA_HOME env var. + - if (event_name == `release`): + - Run verify command skipping enforcing snapshots (if using enforcer Maven plugin) + - if (event_name != `release`): + - Run verify command + - Store project name and version. + - Setup asdf tools for sonarcloud usage. + - Set JAVA_HOME env var. + - if (event_name == `release`): + - Run Maven Sonar goal in a `release/*` branch in SonarCloud. + - if (event_name != `release`): + - Run Maven Sonar goal. \ No newline at end of file diff --git a/.github/workflows/docs/code-release_preview.md b/.github/workflows/docs/code-release_preview.md new file mode 100644 index 0000000..b0a13b0 --- /dev/null +++ b/.github/workflows/docs/code-release_preview.md @@ -0,0 +1,49 @@ +# `code-release_preview` + +[`code-release_preview.yml`](../code-release_preview.yml) generates a `CHANGELOG.md` preview in a comment pull request. + +## Trigger + +Any pull request `labeled` to `main` branch about `code` path with `release-type/` or `release-preview` labels. + +## Where does it run? + +`ubuntu-24.04` GitHub infrastructure. + +## Preconditions + +In case the changelog generation is desirable, a requirement for a successful run is to fill the CHANGELOG.md unreleased section with the issues that are included in the release. + +## Jobs + +- ### `check-changes-in-paths` + - Executed if (pull_request is not in draft OR a `release-type/*` label is added OR a `release-preview` label is added) + - **Steps** + + - Usage of `dorny/paths-filter` action to check for changed files in `code/` and `.github/workflows/code*.` + +- ### `release-preview` + - Executed if ((a `release-type/*` label is added OR a `release-preview` label is added) AND the previous `dorny/paths-filter` action detects changes in the configured paths) + - **Steps** + + - Checkout the repository in the specific pull request. + - Setup Maven and Asdf caches. + - Configure asdf environment with the added tools in the `.tool-versions` file. + - Setup JAVA_HOME env var. + - Get release type from a label on the pull request. It could be one of the following: `release-type/hotfix`, `release-type/multi-hotfix`, `release-type/patch`, `release-type/minor` and `release-type/major`. + - Check the merge strategy needed, depending if it is a hotfix release and the development flow used. + - Check if CHANGELOG.md changes are added. If not, the workflow will throw an error and a message will be shown. + - Usage of `release-flow/keep-a-changelog-action` action to generate the `CHANGELOG.md` preview. + - Add a PR comment with the `CHANGELOG.md` on the open pull request. + +- ### `release-preview-no-code-changes` + - Executed if ((a `release-type/*` label is added OR a `release-preview` label is added) AND the previous `dorny/paths-filter` action DOES NOT detects changes in the configured paths) + - **Steps** + - Checkout the repository in the specific pull request. + - An error message is shown explaining that this Pull Request will not trigger a Release without `code/` changes. + +- ### `release-preview-no-release-labels` + - Executed if (a `release-type/*` label is NOT added AND the previous `dorny/paths-filter` action detects changes in the configured paths AND the repository is not using trunk-based development flow) + - **Steps** + - Checkout the repository in the specific pull request. + - An error message is shown explaining that this Pull Request will not trigger a Release without release labels. diff --git a/.gitignore b/.gitignore index e69de29..3bc2081 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,47 @@ +*target* +*node_modules* +*.jar +*.war +*.ear +*.class + +# yarn +yarn.lock + +# eclipse specific git ignore +*.pydevproject +.project +.metadata +bin/** +tmp/** +tmp/**/* +*.tmp +*.bak +*.swp +*~.nib +local.properties +.classpath +.settings/ +.loadpath +.factorypath + +# External tool builders +.externalToolBuilders/ + +# Locally stored "Eclipse launch configurations" +*.launch + +# STS (Spring Tool Suite) +.springBeans + +#Clover +.clover/ + +# IntelliJ +.idea +*.iml + +.yo-rc.json + +# macOS +.DS_Store \ No newline at end of file diff --git a/NOTICE b/NOTICE index 7a63d96..ba3f16c 100644 --- a/NOTICE +++ b/NOTICE @@ -1,4 +1,4 @@ -Copyright 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +Copyright 2026 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 9a6c153..142f468 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,632 @@ - +Spring Cloud Stream Outbox is a library that implements the [transactional outbox pattern](https://microservices.io/patterns/data/transactional-outbox.html) for [Spring Cloud Stream](https://spring.io/projects/spring-cloud-stream) applications. - -![GitHub License](https://img.shields.io/github/license/InditexTech/base-archetype) +It intercepts outbound messages produced via `StreamBridge`, stores them inside the current application transaction, and publishes them later through a scheduled task — guaranteeing both **at-least-once delivery** and **message ordering**. -# base-archetype +> [!NOTE] +> The library is tested with `StreamBridge`. Other Spring Cloud Stream programming models may work but have not been validated. -Short description of what this project does and why it exists. +## Prerequisites -> One or two sentences that explain its purpose in a clear, accessible way. +| Component | Minimum Version | +|--------------|-----------------| +| Java | 17 | +| Spring Boot | 4.0.4 | +| Spring Cloud | 2025.1.1 | - +## Quick Start + +### Choose your storage backend + +SCS-Outbox supports **JDBC** and **MongoDB** backends. Add the corresponding starter to your project: + +**JDBC** + +```xml + + dev.inditex.scsoutbox + scs-outbox-jdbc-starter + 1.0.0 + +``` + +> [!NOTE] +> JDBC should be compatible with any SQL database. SCS-Outbox has been tested with **MariaDB** and **PostgreSQL**. + +**MongoDB** + +```xml + + dev.inditex.scsoutbox + scs-outbox-mongodb-starter + 1.0.0 + +``` + +> [!CAUTION] +> MongoDB requires transaction support. See the [Spring Data MongoDB transactions guide](https://docs.spring.io/spring-data/mongodb/reference/mongodb/client-session-transactions.html#mongo.transactions). + +> [!NOTE] +> SCS-Outbox does not support reactive programming in any backend. + +### Prepare your database (JDBC only) + +Create the outbox table where messages are stored transactionally: + +
+PostgreSQL + +```sql +CREATE TABLE IF NOT EXISTS SCS_OUTBOX ( + ID varchar(36) NOT NULL, + BINDING_NAME varchar(256) NOT NULL, + CAPTURED_AT timestamp NOT NULL, + DESTINATION varchar(256) NOT NULL, + HEADERS text NOT NULL, + PAYLOAD bytea NOT NULL, + CONSTRAINT PK_OUTBOX PRIMARY KEY (ID) +); +``` + +
+ +
+MariaDB + +```sql +CREATE TABLE IF NOT EXISTS SCS_OUTBOX ( + ID varchar(36) NOT NULL, + BINDING_NAME varchar(256) NOT NULL, + CAPTURED_AT timestamp NOT NULL, + DESTINATION varchar(256) NOT NULL, + HEADERS text NOT NULL, + PAYLOAD blob NOT NULL, + CONSTRAINT PK_OUTBOX PRIMARY KEY (ID) +); +``` + +
+ +scs-outbox also requires a [ShedLock](https://github.com/lukas-krecan/ShedLock) table for distributed lock coordination: + +```sql +CREATE TABLE IF NOT EXISTS shedlock ( + name VARCHAR(64), + lock_until TIMESTAMP(3) NULL, + locked_at TIMESTAMP(3) NULL, + locked_by VARCHAR(255), + PRIMARY KEY (name) +); +``` + +> [!NOTE] +> For high-throughput scenarios, create a non-unique index on the `captured_at` column: +> +>
+> PostgreSQL +> +> ```sql +> CREATE INDEX scs_outbox_captured_at_idx ON scs_outbox USING btree (captured_at); +> ``` +> +>
+>
+> MariaDB +> +> ```sql +> CREATE INDEX scs_outbox_captured_at_idx ON scs_outbox(captured_at); +> ``` +> +>
+>
+> MongoDB +> +> ```javascript +> db.SCS_OUTBOX.createIndex({"capturedAt": 1}); +> ``` +> +>
+ +### Configure your application + +SCS-Outbox works with zero additional configuration, but requires: + +- **Scheduling enabled**: SCS-Outbox uses a `@Scheduled` task to publish messages. Enable scheduling in your application with [`@EnableScheduling`](https://docs.spring.io/spring-framework/reference/integration/scheduling.html#scheduling-enable-annotation-support). +- **JDBC**: a `DataSource` bean configured to access the database containing the outbox and ShedLock tables. +- **MongoDB**: a `MongoTemplate` and `MongoClient` bean available in the application context. + +### Verify it works + +Start your application and produce messages as usual. By default, SCS-Outbox is enabled for all bindings. + +> [!WARNING] +> - `StreamBridge.send(...)` will return `false` when outbox is enabled — this is expected. The message is captured for later publishing. +> - Sending messages **outside** an active transaction will fail with `IllegalTransactionStateException`. SCS-Outbox requires an open transaction to guarantee atomicity: +> ```java +> @Transactional +> public void doWork() { +> repository.save(entity); +> streamBridge.send("my-binding-out-0", message); +> } +> ``` +> - `ErrorMessage` instances are automatically filtered out and never captured. + +## Architecture + +### How it works + +scs-outbox operates in two phases: + +**1. Capture** — during your application transaction, a `GlobalChannelInterceptor` intercepts outbound messages and stores them in the database within the same transaction as your business data. + +**2. Publishing** — a scheduled task (or an after-commit trigger) fetches pending messages, groups them to preserve message ordering, and publishes them through `StreamBridge`. Successfully published messages are then deleted from the outbox. + +```mermaid +sequenceDiagram + participant App as Application + participant Int as OutboxChannelInterceptor + participant Cap as MessageCaptureTxService + participant DB as Database + participant Sch as OutboxScheduledService + participant Pub as OutboxPublishingTask + participant SB as StreamBridge + participant Broker as Message Broker + + rect rgb(230, 245, 255) + Note over App,DB: Capture Phase (inside application transaction) + App->>SB: streamBridge.send(binding, message) + SB->>Int: preSend(message, channel) + Int->>Cap: capture(message) + Cap->>DB: save(outboxMessage) + Int-->>SB: null (message not sent yet) + end + + rect rgb(230, 255, 230) + Note over Sch,Broker: Publishing Phase (scheduled / after-commit) + Sch->>Pub: run() + Pub->>DB: findPending(batchSize) + DB-->>Pub: List + Pub->>Pub: group by ordering strategy + loop Per group (parallel across groups) + Pub->>SB: send(message) + SB->>Broker: publish + Pub->>DB: delete(outboxMessage) + end + end +``` + +> [!IMPORTANT] +> To guarantee message ordering and delivery, configure your Spring Cloud Stream producers in **synchronous** mode. See [Synchronous Producers](#synchronous-producers). + +### Module overview + +```mermaid +graph TD + STARTER_JDBC[scs-outbox-jdbc-starter] --> JDBC[scs-outbox-jdbc] + STARTER_JDBC --> ARCHIVE_JDBC[scs-outbox-archive-jdbc] + STARTER_JDBC --> METRICS[scs-outbox-metrics] + + STARTER_MONGO[scs-outbox-mongodb-starter] --> MONGODB[scs-outbox-mongodb] + STARTER_MONGO --> ARCHIVE_MONGO[scs-outbox-archive-mongodb] + STARTER_MONGO --> METRICS + + JDBC --> CORE[scs-outbox-core] + JDBC --> SERIALIZATION[scs-outbox-serialization] + MONGODB --> CORE + MONGODB --> SERIALIZATION + + ARCHIVE_JDBC --> ARCHIVE[scs-outbox-archive] + ARCHIVE_JDBC --> JDBC + ARCHIVE_MONGO --> ARCHIVE + ARCHIVE_MONGO --> MONGODB + + ARCHIVE --> CORE + ARCHIVE --> SERIALIZATION + METRICS --> CORE + + style STARTER_JDBC fill:#4CAF50,color:#fff + style STARTER_MONGO fill:#4CAF50,color:#fff + style CORE fill:#2196F3,color:#fff +``` + +| Module | Purpose | +|--------|---------| +| `scs-outbox-core` | Core abstractions: message capture interceptor, scheduled publishing, ordering strategies, parallel publisher | +| `scs-outbox-serialization` | Message and header serialization/deserialization (Java, Avro, JSON headers) | +| `scs-outbox-jdbc` | JDBC-based `OutboxMessageRepository` with MariaDB and PostgreSQL support | +| `scs-outbox-mongodb` | MongoDB-based `OutboxMessageRepository` | +| `scs-outbox-archive` | Base archive functionality: interceptor, service, and JSON mapper | +| `scs-outbox-archive-jdbc` | JDBC archive repository implementation | +| `scs-outbox-archive-mongodb` | MongoDB archive repository implementation | +| `scs-outbox-metrics` | Micrometer metrics: pending count, capture time, publishing delay and time | +| `scs-outbox-jdbc-starter` | Starter: pulls JDBC + archive-jdbc + metrics | +| `scs-outbox-mongodb-starter` | Starter: pulls MongoDB + archive-mongodb + metrics | + +## Configuration Reference + +### Core properties + +> All properties in this section use the prefix **`scs-outbox`**. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `bindings.inclusions` | `List` | `[]` (all bindings) | Bindings to enable outbox for. Supports regex with `regex:` prefix (see [Regex binding inclusions/exclusions](#regex-binding-inclusionsexclusions)) | +| `bindings.exclusions` | `List` | `[]` | Bindings to exclude from outbox. Supports regex with `regex:` prefix (see [Regex binding inclusions/exclusions](#regex-binding-inclusionsexclusions)). **Exclusions take precedence** | + +### Publishing properties + +> All properties in this section use the prefix **`scs-outbox.publishing`**. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `batch-size` | `Integer` | `1000` | Max messages per publishing cycle. Supports `@RefreshScope` | +| `grouping-strategy` | `String` | `DESTINATION` | Message grouping strategy for ordering guarantees. See [Message grouping strategies](#message-grouping-strategies). Supports: `DESTINATION`, `KAFKA_MESSAGE_KEY`, `CUSTOM_GROUPING_KEY` | +| `paused` | `Boolean` | `false` | Globally pauses all message publishing. Supports `@RefreshScope` | +| `paused-destinations` | `Set` | `[]` | Destinations to pause. Supports `@RefreshScope` | +| `after-commit` | `Boolean` | `false` | Trigger publishing after transaction commit (requires `@EnableAsync`) | + +### Scheduler properties + +> All properties in this section use the prefix **`scs-outbox.publishing.scheduler`**. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `task-name` | `String` | `outboxPublishingTask` | Name for the scheduled task (used for distributed locking) | +| `fixed-rate` | `Long` (ms) | `5000` | Fixed rate in milliseconds between publishing cycles | +| `cron-expression` | `String` | — | Cron expression (overrides `fixed-rate`). Use `"-"` to disable scheduling | +| `initial-delay` | `Long` (ms) | — | Initial delay before first execution (only with `fixed-rate`) | +| `lock-at-most-for` | `String` | `5m` | Max lock duration for ShedLock (ISO 8601 duration) | + +### Archive properties + +> All properties in this section use the prefix **`scs-outbox.publishing.archive`**. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `enabled` | `Boolean` | `false` | Enable archiving of published messages | +| `json-payload-enabled` | `Boolean` | `false` | Store payload as JSON alongside raw bytes (for troubleshooting) | +| `jdbc.table-name` | `String` | `SCS_OUTBOX_ARCHIVE` | JDBC archive table name | +| `jdbc.schema` | `String` | `""` | JDBC archive table schema | +| `mongodb.collection-name` | `String` | `SCS_OUTBOX_ARCHIVE` | MongoDB archive collection name | + +### Metrics properties + +> All properties in this section use the prefix **`scs-outbox.metrics`**. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `enabled` | `Boolean` | `false` | Enable Micrometer metrics (requires `MeterRegistry` bean) | + +### JDBC properties + +> All properties in this section use the prefix **`scs-outbox.jdbc`**. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `table-name` | `String` | `SCS_OUTBOX` | Name of the outbox table | +| `schema` | `String` | `""` | Database schema for the outbox table | + +### MongoDB properties + +> All properties in this section use the prefix **`scs-outbox.mongodb`**. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `collection-name` | `String` | `SCS_OUTBOX` | Name of the outbox collection | ## Features -- 🔧 Key functionality or tools -- 📦 What problem it solves -- 🚀 Target audience or use case +### Serialization engine -## Getting Started +By default, scs-outbox uses **Java serialization** to serialize message payloads. Payloads that are already `byte[]` are stored as raw bytes without invoking the serialization engine. -### Installation +To use a different engine, create a Spring bean implementing `dev.inditex.scsoutbox.serialization.SerializationEngine`. -Explain how to install or run the project. +Built-in engines: +- `JavaSerialization` — default, requires payloads to implement `Serializable` -```bash -# Example for a CLI tool -npm install -g @inditextech/your-tool +### Message grouping strategies + +scs-outbox guarantees message order **within each group**. The grouping strategy determines what constitutes a group: + +> **Note on terminology:** The grouping strategy (configured via `grouping-strategy`) determines how messages are *grouped* before publishing. SCS-Outbox guarantees message order *within each group*, while allowing parallel publishing across groups. + +**Configuration:** Set the `scs-outbox.publishing.grouping-strategy` property. See [Publishing properties](#publishing-properties) in the Configuration Reference. + +```yaml +scs-outbox: + publishing: + grouping-strategy: KAFKA_MESSAGE_KEY # Group messages by Kafka message key +``` + +| Strategy | Property value | Behavior | +|----------|---------------|-----------| +| By destination | `DESTINATION` (default) | Assigns one thread per destination topic; preserves message order within each destination. | +| By Kafka key | `KAFKA_MESSAGE_KEY` | Assigns one thread per `kafka_messageKey` header, enabling higher parallelism. | +| Custom | `CUSTOM_GROUPING_KEY` | Uses a custom `GroupingKeyGenerator` bean to define grouping logic. | + +> [!WARNING] +> Changing the grouping strategy may alter message ordering. Do not modify this setting unless you understand the implications. + +**Custom grouping example:** + +```java +@Component +public class MyGroupingKeyGenerator implements GroupingKeyGenerator { + + @Override + public GroupingKey generate(GroupingValues values) { + final String header = (String) values.getMessageHeaders().get("tenant-id"); + return GroupingKey.of(values.getDestination() + "-" + header); + } +} +``` + +### Executor service + +The publishing task uses an `ExecutorService` to publish messages in parallel across groups. The default is `Executors.newCachedThreadPool()`. + +To provide a custom executor, define a Spring bean of type `ExecutorService` qualified with the name `outboxExecutorService`. + +**Recommendations:** +- `DESTINATION` grouping: set max threads >= number of destinations. +- `KAFKA_MESSAGE_KEY` grouping: use a `ThreadPoolExecutor` with queue size ~ batch size, and tune max threads based on message volume. +- `CUSTOM_GROUPING_KEY` grouping: set max threads based on the expected cardinality of your custom keys. + +### Archive messages + +scs-outbox can archive published messages to a separate table or collection for auditing and troubleshooting. + +Enable in configuration: + +```yaml +scs-outbox: + publishing: + archive: + enabled: true +``` + +For JDBC, create the archive table: + +
+PostgreSQL + +```sql +CREATE TABLE IF NOT EXISTS SCS_OUTBOX_ARCHIVE ( + ID varchar(36) NOT NULL, + ARCHIVED_AT timestamptz NOT NULL, + CAPTURED_AT timestamptz NOT NULL, + DESTINATION varchar(256) NOT NULL, + CONTENT_TYPE varchar(256) NOT NULL, + HEADERS text NOT NULL, + PAYLOAD bytea NOT NULL, + SERIALIZATION varchar(256) NOT NULL, + JSON_PAYLOAD jsonb, + CONSTRAINT PK_OUTBOX_ARCHIVE PRIMARY KEY (ID) +); +``` + +
+ +
+MariaDB + +```sql +CREATE TABLE IF NOT EXISTS SCS_OUTBOX_ARCHIVE ( + ID varchar(36) NOT NULL, + ARCHIVED_AT timestamp NOT NULL, + CAPTURED_AT timestamp NOT NULL, + DESTINATION varchar(256) NOT NULL, + CONTENT_TYPE varchar(256) NOT NULL, + HEADERS text NOT NULL, + PAYLOAD blob NOT NULL, + SERIALIZATION varchar(256) NOT NULL, + JSON_PAYLOAD text, + CONSTRAINT PK_OUTBOX_ARCHIVE PRIMARY KEY (ID) +); +``` + +
+ +#### JSON payload + +Set `json-payload-enabled: true` to store a human-readable JSON representation of the payload alongside the raw bytes. + +> [!IMPORTANT] +> Converting the payload to its JSON representation occurs during the **publishing phase** and adds CPU/memory overhead. + +> [!NOTE] +> The JSON representation is for troubleshooting only — it is not suitable for re-injection. If a payload cannot be serialized to JSON, the field will be `null`. + +To add custom JSON serialization for specific types, create a Spring bean implementing `dev.inditex.scsoutbox.publish.archive.json.JsonMapper`. + +### Metrics + +scs-outbox provides [Micrometer](https://docs.micrometer.io/micrometer/reference/overview.html) metrics when `scs-outbox.metrics.enabled=true` and a `MeterRegistry` bean is present. + +| Metric | Type | Description | +|--------|------|-------------| +| `outbox.capture.time` | Timer | Time taken to capture a message (via `@Timed`) | +| `outbox.publishing.time` | Timer | Time taken for the publishing task execution (via `@Timed`) | +| `outbox.publishing.delay` | Timer | Delay between message capture and publishing | +| `outbox.publishing.messages` | Counter | Number of messages published | +| `outbox.messages.pending` | Gauge | Estimated number of messages pending publishing | + +### Pause message publishing + +#### Global pause + +Stop all message publishing: + +```yaml +scs-outbox: + publishing: + paused: true +``` + +#### Per-destination pause + +Pause specific destinations while keeping others active: + +```yaml +scs-outbox: + publishing: + paused-destinations: + - destination1 + - destination2 ``` -### Usage +#### Dynamic updates -Show basic usage or link to examples. +Publishing properties (`batch-size`, `paused`, `paused-destinations`) support `@RefreshScope`. Update them at runtime via Spring Cloud Config: ```bash -your-tool init +curl -X POST http://localhost:8080/actuator/refresh ``` -## Contributing +> [!NOTE] +> Global pause takes precedence over per-destination pause. -We welcome contributions! +### Regex binding inclusions/exclusions -Please read our [CONTRIBUTING.md](./CONTRIBUTING.md) and follow the [Code of Conduct](./CODE_OF_CONDUCT.md). +The `inclusions` and `exclusions` properties (see [Core properties](#core-properties)) support both plain binding names and Java-style regular expressions prefixed with `regex:`. -## Roadmap +```yaml +scs-outbox: + bindings: + inclusions: + - "produce-book-created-out-0" + - "regex:produce-.*-out-\\d+" + exclusions: + - "my-test-binding-out-0" + - "regex:test-.*-out-\\d+" +``` -See [ROADMAP.md](./ROADMAP.md) for planned features and development goals. +**Rules:** +- Exclusions always take precedence over inclusions. +- Plain binding names are validated at startup against declared bindings (fail-fast if not found). +- Regex patterns are not validated against declared bindings (a warning is logged if no bindings match). +- Invalid regex patterns cause a fail-fast error on startup. - +### Dedicated database connection pool -## Acknowledgments +By default, scs-outbox shares the application's connection pool for both message capture and publishing. Under high load or broker connectivity issues, the publishing process may exhaust the shared pool. A dedicated pool isolates publishing from the application. - +> [!IMPORTANT] +> The dedicated connection pool **must** connect to the **same** database as the primary pool. Both capture and publishing must access the same outbox data. -## License +#### JDBC + +Define a `DataSource` bean named `outboxPublishingDataSource`: + +```java +@Configuration +public class OutboxDataSourceConfig { + + @Bean + @ConfigurationProperties("app.datasource.outbox-publishing") + public DataSourceProperties outboxPublishingDataSourceProperties() { + return new DataSourceProperties(); + } + + @Bean + public DataSource outboxPublishingDataSource() { + return this.outboxPublishingDataSourceProperties() + .initializeDataSourceBuilder() + .type(HikariDataSource.class) + .build(); + } +} +``` + +```yaml +app: + datasource: + outbox-publishing: + url: jdbc:postgresql://localhost:5432/mydb + username: db_user + password: ${DB_PASSWORD} + hikari: + maximum-pool-size: 5 + minimum-idle: 2 + connection-timeout: 30000 +``` + +#### MongoDB + +Define a `MongoTemplate` bean named `outboxPublishingMongoTemplate`: + +```java +@Configuration +public class OutboxMongoConfig { + + @Bean + public MongoClient outboxPublishingMongoClient() { + final ConnectionString cs = new ConnectionString("mongodb://db_user:${DB_PASSWORD}@localhost:27017/myDatabase"); + final MongoClientSettings settings = MongoClientSettings.builder() + .applyConnectionString(cs) + .applyToConnectionPoolSettings(b -> b.maxSize(5).minSize(2)) + .build(); + return MongoClients.create(settings); + } + + @Bean + public MongoTemplate outboxPublishingMongoTemplate( + @Qualifier("outboxPublishingMongoClient") MongoClient mongoClient) { + return new MongoTemplate(mongoClient, "myDatabase"); + } +} +``` + +#### Fallback + +If no dedicated bean is defined, scs-outbox uses the default application `DataSource` or `MongoTemplate` for both capture and publishing. + +### Lock provider + +scs-outbox uses [ShedLock](https://github.com/lukas-krecan/ShedLock) to coordinate the publishing process across instances. A default `LockProvider` is registered automatically. -This project is licensed under the [Apache-2.0 License](./LICENSE). +To override it, define a custom `LockProvider` Spring bean. + +## Recommendations + +### Synchronous producers + +To avoid message loss, configure your Spring Cloud Stream producers in synchronous mode: + +**Kafka:** + +```properties +spring.cloud.stream.kafka.bindings..producer.sync=true +``` + +See the [Kafka binder documentation](https://docs.spring.io/spring-cloud-stream/reference/kafka/kafka_overview.html#kafka-producer-properties). + +**RabbitMQ:** + +```properties +spring.cloud.stream.rabbit.bindings..producer.producerType=STREAM_SYNC +``` + +See the [RabbitMQ binder documentation](https://docs.spring.io/spring-cloud-stream/reference/rabbit/rabbit_overview/prod-props.html). + +### Kafka linger property + +scs-outbox sends messages individually. Set `linger.ms=0` to avoid unnecessary batching delays: + +```properties +spring.cloud.stream.kafka.binder.configuration.linger.ms=0 +``` + +## Known Issues + +- **Deserialization errors block publishing**: if a message cannot be deserialized (e.g., class changes or corrupted data), publishing stops at that message to preserve ordering. All messages preceding it in the current batch are published. An error is logged with the message ID and destination. **Resolution**: manually remove or fix the problematic message in the database. +- **Duplicated `spring.integration.send` metric count**: Spring Integration counts the message twice — once during capture and once during the publishing step. This is expected behavior. +- **ShedLock contention under high throughput**: ShedLock ensures only one instance publishes at a time. For extremely high message volumes, this can become a bottleneck. Evaluate whether the outbox pattern is the right fit for your throughput requirements. +- **PostgreSQL table names must be lowercase**: scs-outbox does not use quoted identifiers. If you customize table names, ensure they are lowercase to avoid case-sensitivity issues. +- **Integration test lock cleanup**: when running integration tests that trigger publishing, the publishing task may not finish before the test tears down, leaving the ShedLock lock unreleased. Restart the database between tests to avoid this. + +## License -© 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +This software is available as open source under the terms of the [Apache-2.0](LICENSE). \ No newline at end of file diff --git a/REUSE.toml b/REUSE.toml index 443a6dd..6a48966 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -1,14 +1,33 @@ version = 1 -SPDX-PackageName = "base-archetype" -SPDX-PackageSupplier = "2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.)" -SPDX-PackageDownloadLocation = "https://github.com/InditexTech/base-archetype" +SPDX-PackageName = "scs-outbox" +SPDX-PackageSupplier = "2026 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.)" +SPDX-PackageDownloadLocation = "https://github.com/InditexTech/scs-outbox" [[annotations]] path = [ "repolinter.json", "NOTICE", ".gitignore", + ".gitattributes", ".github/CODEOWNERS", ] -SPDX-FileCopyrightText = "2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.)" +SPDX-FileCopyrightText = "2026 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.)" SPDX-License-Identifier = "Apache-2.0" + +[[annotations]] +path = "code/**" +SPDX-FileCopyrightText = "2026 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.)" +SPDX-License-Identifier = "Apache-2.0" + +[[annotations]] +path = ".github/**" +SPDX-FileCopyrightText = "2026 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.)" +SPDX-License-Identifier = "Apache-2.0" + +[[annotations]] +path = [ + "docs/**", + "README.md" +] +SPDX-FileCopyrightText = "2026 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.)" +SPDX-License-Identifier = "Apache-2.0" \ No newline at end of file diff --git a/code/.mvn/extensions.xml b/code/.mvn/extensions.xml new file mode 100644 index 0000000..ff4783d --- /dev/null +++ b/code/.mvn/extensions.xml @@ -0,0 +1,3 @@ + + diff --git a/code/.mvn/jvm.config b/code/.mvn/jvm.config new file mode 100644 index 0000000..e69de29 diff --git a/code/.mvn/maven.config b/code/.mvn/maven.config new file mode 100644 index 0000000..06a52ca --- /dev/null +++ b/code/.mvn/maven.config @@ -0,0 +1 @@ +-U diff --git a/code/.tool-versions b/code/.tool-versions new file mode 100644 index 0000000..9a209bb --- /dev/null +++ b/code/.tool-versions @@ -0,0 +1,2 @@ +java temurin-17.0.19+10 +maven 3.9.15 diff --git a/code/CHANGELOG.md b/code/CHANGELOG.md new file mode 100644 index 0000000..2b20287 --- /dev/null +++ b/code/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + + diff --git a/code/CODING-CONVENTIONS.md b/code/CODING-CONVENTIONS.md new file mode 100644 index 0000000..31bf8c8 --- /dev/null +++ b/code/CODING-CONVENTIONS.md @@ -0,0 +1,107 @@ +# Coding Conventions for Java projects + +Here's a list of useful resources that everyone should check before contributing to this project. + +## Table of Contents + +1. [Java Styleguide](#java-styleguide) + * [Important Changes from Google Java Style](#important-changes-from-google-java-style) +2. [Maven Conventions](#maven-conventions) + * [POM Reference Structure](#pom-reference-structure) + * [Adding new dependencies or plugins](#adding-new-dependencies-or-plugins) +3. [Documentation Styleguide](#documentation-styleguide) + + +## Java Styleguide + +To maintain consistency in the format of our codebase, we use three open-source tools (see [ADR-0002](../docs/adr/0002-code-formatting-toolchain.md) for the full rationale and responsibility matrix): + +- **[Spotless](https://github.com/diffplug/spotless/tree/main/plugin-maven)** (`com.diffplug.spotless:spotless-maven-plugin`) — enforces code formatting using the Eclipse formatter config at `src/main/config/eclipse-java-google-style.xml` and the import order at `src/main/config/eclipse-java-google-style.importorder`. +- **[maven-checkstyle-plugin](https://maven.apache.org/plugins/maven-checkstyle-plugin/)** — enforces structural and naming style rules using the Checkstyle ruleset at `src/main/config/checkstyle-java-google-style-17.xml`. Does **not** duplicate rules already covered by Spotless (import ordering, Java line length). +- **[sortpom-maven-plugin](https://github.com/Ekryd/sortpom)** (`com.github.ekryd.sortpom:sortpom-maven-plugin`) — enforces POM element ordering using the convention defined in `src/main/config/pom-code-convention.xml`. + +All three run automatically during the `validate` Maven phase and are enforced in CI. + +### Developer Commands + +**Auto-format Java code before committing:** +```bash +mvn -f code/pom.xml spotless:apply +``` + +**Sort POM files before committing:** +```bash +mvn -f code/pom.xml com.github.ekryd.sortpom:sortpom-maven-plugin:sort +``` + +**Check formatting, style and POM order (what CI does):** +```bash +mvn -f code/pom.xml validate +``` + +### IDE Setup + +There are formatter configuration files for the main IDEs used: + - Eclipse Java Formatter configuration file located at `src/main/config/eclipse-java-google-style.xml` & Eclipse Organize Imports configuration located at `src/main/config/eclipse-java-google-style.importorder`. See how to configure them in the [Coding Formatting in Eclipse guide](https://help.eclipse.org/2020-06/index.jsp?topic=%2Forg.eclipse.jdt.doc.user%2Freference%2Fpreferences%2Fjava%2Fcodestyle%2Fref-preferences-formatter.htm). + - IntelliJ Code Formatting configuration file located at `src/main/config/intellij-java-google-style.xml`. See how to [configure Code Formatting in IntelliJ](https://www.jetbrains.com/help/rider/Enforcing_Code_Formatting_Rules.html#using-comments-to-configure-formatter). + +However, keep in mind that **the formatters are just an aid when writing code**, and can introduce errors. _The developer is ultimately responsible for the code he/she publishes_. + +### Important Changes from Google Java Style + +Checkstyle ruleset is based on the [Google Java Style](https://google.github.io/styleguide/javaguide.html), with the following modifications adapted to the current conventions used in the company: + +#### [Source file structure](https://google.github.io/styleguide/javaguide.html#s3-source-file-structure) + +**The structure** to be followed in Java files would be the following (unlike Google Style): + +1. **Package statement** +2. **Import statements** +3. **Exactly one top-level class** + +_Exactly **one blank line** separates each section that is present._ + +*It is NOT ALLOWED to use license or copyright headers in code files. If you need to add the license / copyright, it is recommended to add them as a separate file for the entire project, not class by class. You can take a look to [the GitHub documentantion on licensing a repository](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/licensing-a-repository)*. + +#### [Imports ordering](https://google.github.io/styleguide/javaguide.html#s3.3.3-import-ordering-and-spacing) + +Imports should be ordered as follows: + 1. All static imports divided in 3 blocks: *java.\*, dev.inditex.\* and any other import*, **sorted alphabetically** . + 2. All non-static imports divided in 3 blocks: *java.\*, dev.inditex.\* and any other import*, **sorted alphabetically**. + +_Exactly **one blank line** separates each block that is present._ + +#### [Column limit](https://google.github.io/styleguide/javaguide.html#s4.4-column-limit) + +The column limit is increased to **140** characters, in the Inditex Checkstyle ruleset. + +## Maven Conventions + +The tool to build, package, test and verify the project is [Apache Maven](https://maven.apache.org/index.html). A Maven Project is defined by one or more [POM xml files](https://maven.apache.org/pom.html#What_is_the_POM). + +### POM Reference Structure + +- POM files must be ordered according to the criteria defined in the [Sortpom Maven Plugin](https://github.com/Ekryd/sortpom) configuration, which is located at `src/main/config/pom-code-convention.xml`. + - This convention is based on the [POM Code Convention](https://maven.apache.org/developers/conventions/code.html#pom-code-convention), the standard convention adopted by the Maven community. Take a look at [the following Answerhub article on using Sortpom](https://inditex.cloud.answerhub.com/articles/1525/sortpom-maven-plugin-guide.html). + +- The project code layout follows the [Maven Standard Directory Layout](https://maven.apache.org/guides/introduction/introduction-to-the-standard-directory-layout.html) + +- The dependencies version (including local modules' version) should be placed in the `` parent POM section. Take a look to this guide about [Consolidating dependencies on Dependency Management](https://maven.apache.org/pom.html#Dependency_Management). + - *This criterion also applies to `plugins` and `pluginManagement` sections* +- [Properties](https://maven.apache.org/pom.html#properties) should be defined in the parent POM, whenever possible, and default properties should be separated from project specific properties by a **blank line**. + +### Adding new dependencies or plugins + +Adding new features or plugins to our codebase sometimes imply adding also new dependencies in the Maven configuration. When this is the case always remember that within `` blocks we group them as follows: +1. Local project modules +2. Framework, internal Inditex dependencies +3. 3rd party, external dependencies +4. All test scope dependencies + +Each group **separated by one blank line**. + +Apply the same caution when a new plugin is needed for the Maven build execution. Include them always following alphabetical order by groupId and artifactId. + +## Documentation Styleguide + +Check out Github's [Mastering Markdown](https://guides.github.com/features/mastering-markdown/) to learn about the basic syntax and _GitHub Flavored Markdown_. diff --git a/code/jacoco-report-aggregate/pom.xml b/code/jacoco-report-aggregate/pom.xml new file mode 100644 index 0000000..5a56588 --- /dev/null +++ b/code/jacoco-report-aggregate/pom.xml @@ -0,0 +1,86 @@ + + + 4.0.0 + + dev.inditex.scsoutbox + scs-outbox + 1.0.0-SNAPSHOT + + + jacoco-report-aggregate + pom + + + + + + dev.inditex.scsoutbox + scs-outbox-core + + + dev.inditex.scsoutbox + scs-outbox-metrics + + + dev.inditex.scsoutbox + scs-outbox-serialization + + + dev.inditex.scsoutbox + scs-outbox-jdbc + + + dev.inditex.scsoutbox + scs-outbox-mongodb + + + dev.inditex.scsoutbox + scs-outbox-archive + + + dev.inditex.scsoutbox + scs-outbox-archive-jdbc + + + dev.inditex.scsoutbox + scs-outbox-archive-mongodb + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + report-aggregate + test + + report-aggregate + + + + **/**/coverage-reports/jacoco.exec + + ${project.reporting.outputDirectory}/jacoco-aggregate + + + + report-aggregate-it + test + + report-aggregate + + + + **/**/coverage-reports/jacoco-it.exec + + ${project.reporting.outputDirectory}/jacoco-aggregate-it + + + + + + + diff --git a/code/lombok.config b/code/lombok.config new file mode 100755 index 0000000..8f7e8aa --- /dev/null +++ b/code/lombok.config @@ -0,0 +1 @@ +lombok.addLombokGeneratedAnnotation = true \ No newline at end of file diff --git a/code/oscos-curation.yml b/code/oscos-curation.yml new file mode 100644 index 0000000..e8e4c83 --- /dev/null +++ b/code/oscos-curation.yml @@ -0,0 +1,6 @@ +artifact: + context: + users: "corporate" + distribution: + - "binaryDistribution" + sourceModification: false diff --git a/code/pom.xml b/code/pom.xml new file mode 100644 index 0000000..ea8bcfd --- /dev/null +++ b/code/pom.xml @@ -0,0 +1,553 @@ + + + 4.0.0 + + dev.inditex.scsoutbox + scs-outbox + 1.0.0-SNAPSHOT + pom + + ${project.groupId}:${project.artifactId} + Spring Cloud Stream Outbox pattern library providing reliable event publishing for JDBC and MongoDB backends with support for serialization, archiving, and metrics. + https://github.com/InditexTech/scs-outbox/blob/develop/README.md + 2026 + + Industria de Diseño Textil, S.A. + https://inditex.com + + + + Apache-2.0 + https://github.com/InditexTech/scs-outbox/blob/develop/LICENSES/Apache-2.0.txt + + + + + + Inditex Open Source Office + oso@inditex.com + Industria de Diseño Textil, S.A. + https://inditex.com + + + + + scs-outbox-starters + scs-outbox-libs + jacoco-report-aggregate + scs-outbox-it + + + + ${scm-connection} + ${scm-developer-connection} + ${scm-url} + ${scm-tag} + + + + + central + https://central.sonatype.com/repository/maven-snapshots/ + + + + + + 17 + ${java.version} + UTF-8 + UTF-8 + + + scm:git:https://github.com/InditexTech/scs-outbox.git + scm:git:https://github.com/InditexTech/scs-outbox.git + https://github.com/InditexTech/scs-outbox + HEAD + inditextech-scm-github + + + false + develop + true + kind/internal + + + 3.6.0 + 3.13.0 + 3.5.0 + 3.5.5 + 3.2.5 + 3.10.0 + 3.3.1 + 3.3.1 + 3.3.1 + 3.5.5 + 0.5.0 + 2.7.1 + 0.8.14 + 3.4.0 + 4.0.0 + + 12.3.1 + + + + 1.18.30 + + + 4.0.4 + 2025.1.1 + + + 7.7.0 + + + 1.11.4 + + + 42.7.5 + 3.5.5 + 4.24.0 + + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot-dependencies.version} + pom + import + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + net.javacrumbs.shedlock + shedlock-spring + ${shedlock.version} + + + net.javacrumbs.shedlock + shedlock-provider-jdbc-template + ${shedlock.version} + + + net.javacrumbs.shedlock + shedlock-provider-mongo + ${shedlock.version} + + + org.apache.avro + avro + ${avro.version} + + + + + dev.inditex.scsoutbox + scs-outbox-test-support + ${project.version} + + + dev.inditex.scsoutbox + scs-outbox-core + ${project.version} + + + dev.inditex.scsoutbox + scs-outbox-metrics + ${project.version} + + + dev.inditex.scsoutbox + scs-outbox-serialization + ${project.version} + + + dev.inditex.scsoutbox + scs-outbox-jdbc + ${project.version} + + + dev.inditex.scsoutbox + scs-outbox-mongodb + ${project.version} + + + dev.inditex.scsoutbox + scs-outbox-archive + ${project.version} + + + dev.inditex.scsoutbox + scs-outbox-archive-jdbc + ${project.version} + + + dev.inditex.scsoutbox + scs-outbox-archive-mongodb + ${project.version} + + + dev.inditex.scsoutbox + scs-outbox-jdbc-starter + ${project.version} + + + dev.inditex.scsoutbox + scs-outbox-mongodb-starter + ${project.version} + + + + + org.postgresql + postgresql + ${postgresql.version} + test + + + org.mariadb.jdbc + mariadb-java-client + ${mariadb.version} + test + + + de.flapdoodle.embed + de.flapdoodle.embed.mongo.spring4x + ${de.flapdoodle.embed.mongo.spring4x.version} + test + + + + + + + + never + + + false + + central + Central Repository + https://repo.maven.apache.org/maven2 + + + + + + never + + + false + + central + Central Repository + https://repo.maven.apache.org/maven2 + + + + + ${project.artifactId}-${project.version} + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + true + + + + org.apache.maven.plugins + maven-javadoc-plugin + ${maven-javadoc-plugin.version} + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-release-plugin + ${maven-release-plugin.version} + + + org.apache.maven.release + maven-release-semver-policy + ${maven-release-semver-policy.version} + + + + -DskipEnforceSnapshots -DskipITs -DskipTests -DskipUTs + install gpg:sign org.sonatype.central:central-publishing-maven-plugin:publish + SemVerVersionPolicy + @{prefix} Prepare release @{releaseLabel} + @{prefix} Prepare for next development iteration + @{project.version} + + + + org.apache.maven.plugins + maven-source-plugin + ${maven-source-plugin.version} + + + attach-sources + + jar-no-fork + + + + + + org.sonatype.central + central-publishing-maven-plugin + ${central-publishing-maven-plugin.version} + true + + central + true + + + + org.apache.maven.plugins + maven-gpg-plugin + ${maven-gpg-plugin.version} + + + sign-artifacts + deploy + + sign + + + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + ${maven-enforcer-plugin.version} + + + enforce-versions + validate + + enforce + + + + + [3.9.4,3.10.0) + + + [17,18),[21,22) + + + + + + enforce-snapshots + validate + + enforce + + + ${skipEnforceSnapshots} + + + false + Final versions not allowed + + + + + + + + com.diffplug.spotless + spotless-maven-plugin + ${spotless-maven-plugin.version} + + + + **/package-info.java + **/archive/json/Product.java + **/archive/json/BookCreatedMessage.java + **/archive/json/Cycle.java + + + ${maven.multiModuleProjectDirectory}/src/main/config/eclipse-java-google-style.xml + + + ${maven.multiModuleProjectDirectory}/src/main/config/eclipse-java-google-style.importorder + + + + + + validate + + check + + true + + + + + com.github.ekryd.sortpom + sortpom-maven-plugin + ${sortpom-maven-plugin.version} + + false + false + true + 2 + custom_1 + ${maven.multiModuleProjectDirectory}/src/main/config/pom-code-convention.xml + stop + + + + validate + + verify + + true + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${maven-checkstyle-plugin.version} + + + com.puppycrawl.tools + checkstyle + ${checkstyle.version} + + + + + checkstyle-validate + validate + + check + + true + + ${maven.multiModuleProjectDirectory}/src/main/config/checkstyle-java-google-style-17.xml + ${maven.multiModuleProjectDirectory}/src/main/config/checkstyle-suppressions.xml + true + true + true + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + pre-unit-test + + prepare-agent + + + + ${project.build.directory}/coverage-reports/jacoco.exec + surefireArgLine + + + + pre-integration-test + pre-integration-test + + prepare-agent-integration + + + + ${project.build.directory}/coverage-reports/jacoco-it.exec + + failsafeArgLine + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + ${surefireArgLine} + + + **/IT*.java + + + + + org.apache.maven.plugins + maven-failsafe-plugin + ${maven-failsafe-plugin.version} + + + integration-tests + + integration-test + verify + + + ${failsafeArgLine} + + + + + + org.codehaus.mojo + license-maven-plugin + ${license-maven-plugin.version} + false + + ${project.basedir} + THIRD-PARTY.txt + test + true + dev\.inditex.* + + Apache License, Version 2.0|Apache-2.0|Apache 2.0|Apache 2|Apache License 2.0|The Apache License, Version 2.0|The Apache Software License, Version 2.0|Apache Software License - Version 2.0|Apache License Version 2.0|Apache License v2.0 + MIT License|MIT|The MIT License|MIT license|MIT-0 + Eclipse Public License v2.0|Eclipse Public License - v 2.0|EPL-2.0|EPL 2.0|EPL-2.0 GPL-2.0-with-classpath-exception + Eclipse Public License v1.0|Eclipse Public License - v 1.0|EPL-1.0 + GNU Lesser General Public License v2.1|LGPL-2.1|GNU Library General Public License 2.1|GNU Lesser General Public License + BSD 2-Clause License|BSD-2-Clause|BSD 2-Clause|The BSD License + BSD 3-Clause License|BSD-3-Clause|BSD 3-Clause|New BSD License|Go License|3-Clause BSD License + Public Domain|Public Domain, per Creative Commons CC0|CC0 1.0 Universal|CC0 + + + + + + diff --git a/code/scs-outbox-it/pom.xml b/code/scs-outbox-it/pom.xml new file mode 100644 index 0000000..e93b3d1 --- /dev/null +++ b/code/scs-outbox-it/pom.xml @@ -0,0 +1,17 @@ + + + 4.0.0 + + dev.inditex.scsoutbox + scs-outbox + 1.0.0-SNAPSHOT + + + scs-outbox-it + pom + + scs-outbox-mongodb-it + scs-outbox-jdbc-it + + + \ No newline at end of file diff --git a/code/scs-outbox-it/scs-outbox-jdbc-it/pom.xml b/code/scs-outbox-it/scs-outbox-jdbc-it/pom.xml new file mode 100644 index 0000000..dad7d8f --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-jdbc-it/pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + + dev.inditex.scsoutbox + scs-outbox-it + 1.0.0-SNAPSHOT + + + scs-outbox-jdbc-it + + + + + + org.springframework.boot + spring-boot-starter-data-jdbc + + + org.springframework.boot + spring-boot-docker-compose + + + org.springframework.boot + spring-boot-starter + + + org.springframework.cloud + spring-cloud-stream-binder-kafka + + + org.awaitility + awaitility + + + org.postgresql + postgresql + runtime + + + dev.inditex.scsoutbox + scs-outbox-jdbc-starter + + + org.springframework.boot + spring-boot-starter-test + test + + + ch.qos.logback + logback-classic + + + + + org.springframework.boot + spring-boot-jdbc + test + + + org.springframework.boot + spring-boot-starter-actuator + test + + + org.springframework.boot + spring-boot-starter-webmvc + test + + + org.springframework.cloud + spring-cloud-context + test + + + + diff --git a/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/AfterCommitTriggerIT.java b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/AfterCommitTriggerIT.java new file mode 100644 index 0000000..32d3923 --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/AfterCommitTriggerIT.java @@ -0,0 +1,57 @@ +package dev.inditex.scsoutbox.it.jdbc; + +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; + +import java.util.concurrent.TimeUnit; + +import dev.inditex.scsoutbox.scheduler.AfterCommitTrigger; +import dev.inditex.scsoutbox.scheduler.AfterCommitTrigger.MessageCaptured; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.TransactionTemplate; + +@SpringBootTest( + classes = {AfterCommitTriggerIT.class}, + properties = { + "spring.docker.compose.enabled=true", + "spring.docker.compose.skip.in-tests=false", + "scs-outbox.publishing.after-commit=true", + }) +@EnableAsync +@EnableAutoConfiguration +@EnableScheduling +@EnableTransactionManagement +@DirtiesContext(classMode = ClassMode.AFTER_CLASS) +class AfterCommitTriggerIT { + + @Autowired + private StreamBridge streamBridge; + + @MockitoSpyBean + private AfterCommitTrigger afterCommitTrigger; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Test + void when_message_is_sent_then_publish_messages_is_captured_and_after_commit_is_executed() { + this.transactionTemplate.execute(status -> this.streamBridge.send("output", "aftercommit")); + + verify(this.afterCommitTrigger).publishMessageCapturedEvent(); + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> verify(this.afterCommitTrigger).afterCommit(any(MessageCaptured.class))); + } + +} diff --git a/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/BatchSizeHotRefreshIT.java b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/BatchSizeHotRefreshIT.java new file mode 100644 index 0000000..72466eb --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/BatchSizeHotRefreshIT.java @@ -0,0 +1,153 @@ +package dev.inditex.scsoutbox.it.jdbc; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Properties; +import java.util.function.Consumer; + +import dev.inditex.scsoutbox.OutboxMessageRepository; +import dev.inditex.scsoutbox.publish.OutboxPublishingTask; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.context.refresh.ContextRefresher; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Integration test to verify that {@code scs-outbox.publishing.batch-size} is hot-refreshable. + * + *

This test verifies that changing {@code batch-size} at runtime via Spring Cloud Config refresh immediately limits the number of + * messages published per publishing cycle, without requiring an application restart. + * + *

The scheduler is disabled ({@code app.scheduling.enable=false}) so that publishing cycles are driven manually via + * {@link OutboxPublishingTask#run()}, giving full control over when each cycle executes. + */ +@SpringBootTest( + classes = {BatchSizeHotRefreshIT.TestConfig.class}, + properties = { + "spring.docker.compose.enabled=true", + "spring.docker.compose.skip.in-tests=false", + "scs-outbox.publishing.batch-size=3", + "app.scheduling.enable=false", + "management.endpoints.web.exposure.include=refresh", + "management.endpoint.refresh.enabled=true", + "spring.cloud.refresh.enabled=true", + "spring.cloud.function.definition=myConsumer", + "scs-outbox.bindings.inclusions=output" + }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DirtiesContext(classMode = ClassMode.AFTER_CLASS) +class BatchSizeHotRefreshIT { + + @Configuration + @EnableAutoConfiguration + @EnableScheduling + @EnableTransactionManagement + static class TestConfig { + + @Bean + public Consumer myConsumer() { + return message -> { + // Mock bean will handle the verification + }; + } + } + + @Autowired + private StreamBridge streamBridge; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private ConfigurableEnvironment environment; + + @Autowired + private ContextRefresher contextRefresher; + + @Autowired + private OutboxPublishingTask outboxPublishingTask; + + @Autowired + private OutboxMessageRepository outboxMessageRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @MockitoBean + private Consumer myConsumer; + + @BeforeEach + void setUp() { + this.jdbcTemplate.execute("DELETE FROM scs_outbox"); + // Remove any custom property source so each test starts from the @SpringBootTest defaults + this.environment.getPropertySources().remove("test-batch-size-refresh-props"); + this.contextRefresher.refresh(); + } + + @Test + void shouldReducePublishedMessagesPerCycleAfterBatchSizeHotRefresh() { + // 1. Insert 5 messages – initial batch-size is 3, so a single cycle publishes at most 3 + for (int i = 1; i <= 5; i++) { + this.sendMessage("msg-reduce-" + i); + } + assertThat(this.outboxMessageRepository.count()).isEqualTo(5); + + // 2. Run one publishing cycle with batch-size=3: expect exactly 3 messages published, 2 remaining + this.outboxPublishingTask.run(); + assertThat(this.outboxMessageRepository.count()).isEqualTo(2); + + // 3. Hot-refresh batch-size to 1 + this.updateBatchSize(1); + this.contextRefresher.refresh(); + + // 4. Run another cycle with the new batch-size=1: only 1 additional message should be published, 1 remaining + this.outboxPublishingTask.run(); + assertThat(this.outboxMessageRepository.count()).isEqualTo(1); + } + + @Test + void shouldIncreasePublishedMessagesPerCycleAfterBatchSizeHotRefresh() { + // 1. Insert 5 messages – initial batch-size is 3 + for (int i = 1; i <= 5; i++) { + this.sendMessage("msg-increase-" + i); + } + assertThat(this.outboxMessageRepository.count()).isEqualTo(5); + + // 2. Hot-refresh batch-size to 5 so all messages fit in a single cycle + this.updateBatchSize(5); + this.contextRefresher.refresh(); + + // 3. Run one publishing cycle with the new batch-size=5: all 5 messages should be published + this.outboxPublishingTask.run(); + assertThat(this.outboxMessageRepository.count()).isZero(); + } + + private void sendMessage(final String payload) { + this.transactionTemplate.execute(status -> this.streamBridge.send("output", payload)); + } + + private void updateBatchSize(final int batchSize) { + final MutablePropertySources propertySources = this.environment.getPropertySources(); + final Properties props = new Properties(); + props.setProperty("scs-outbox.publishing.batch-size", String.valueOf(batchSize)); + propertySources.remove("test-batch-size-refresh-props"); + propertySources.addFirst(new PropertiesPropertySource("test-batch-size-refresh-props", props)); + } + +} diff --git a/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/DedicatedPublishingDataSourceIT.java b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/DedicatedPublishingDataSourceIT.java new file mode 100644 index 0000000..369996c --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/DedicatedPublishingDataSourceIT.java @@ -0,0 +1,173 @@ +package dev.inditex.scsoutbox.it.jdbc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.Mockito.verify; + +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import javax.sql.DataSource; + +import dev.inditex.scsoutbox.jdbc.OutboxDataSourceProvider; + +import com.zaxxer.hikari.HikariDataSource; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Integration test validating the dedicated DataSource functionality for outbox publishing. This test configures a separate DataSource + * specifically for publishing operations to ensure isolation from the main application DataSource. + */ +@SpringBootTest( + classes = {DedicatedPublishingDataSourceIT.TestConfig.class}, + properties = { + "spring.docker.compose.enabled=true", + "spring.docker.compose.skip.in-tests=false", + "scs-outbox.publishing.batch-size=1", + "scs-outbox.publishing.after-commit=false", + "scs-outbox.publishing.scheduler.fixed-rate=1000", + }) +@DirtiesContext(classMode = ClassMode.AFTER_CLASS) +class DedicatedPublishingDataSourceIT { + + public static final String OUTBOX_PUBLISHING_DATA_SOURCE = "outboxPublishingDataSource"; + + @Autowired + private OutboxDataSourceProvider dataSourceProvider; + + @Autowired + private DataSource primaryDataSource; + + @Autowired + @Qualifier(OUTBOX_PUBLISHING_DATA_SOURCE) + private DataSource publishingDataSource; + + @Autowired + private StreamBridge streamBridge; + + @MockitoBean + private Consumer myConsumer; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Test + void provider_uses_dedicated_datasource_for_publishing() { + assertThat(this.dataSourceProvider.getPrimary()) + .describedAs("Provider should expose different instances when a dedicated publishing DataSource is configured") + .isNotSameAs(this.dataSourceProvider.getDedicatedForPublishing()); + } + + @Test + void capture_uses_primary_datasource() { + assertThat(this.dataSourceProvider.getPrimary()) + .describedAs("Capture operations should use the primary DataSource") + .isSameAs(this.primaryDataSource); + } + + @Test + void publishing_uses_dedicated_datasource() { + assertThat(this.dataSourceProvider.getDedicatedForPublishing()) + .describedAs("Publishing operations should use the dedicated DataSource") + .isSameAs(this.publishingDataSource); + } + + @Test + void datasources_are_different_instances() { + assertThat(this.dataSourceProvider.getPrimary()) + .describedAs("Capture and publishing should use different DataSource instances for isolation") + .isNotSameAs(this.dataSourceProvider.getDedicatedForPublishing()); + } + + @Test + void primary_datasource_exists_and_is_hikari() { + assertThat(this.primaryDataSource).isInstanceOf(HikariDataSource.class); + final HikariDataSource hikariDs = (HikariDataSource) this.primaryDataSource; + assertThat(hikariDs.getPoolName()) + .describedAs("Primary DataSource should be HikariCP with a pool name") + .isEqualTo("PrimaryPool"); + } + + @Test + void publication_datasource_has_correct_pool_name() { + assertThat(this.publishingDataSource).isInstanceOf(HikariDataSource.class); + final HikariDataSource hikariDs = (HikariDataSource) this.publishingDataSource; + assertThat(hikariDs.getPoolName()) + .describedAs("Publishing DataSource should have the configured pool name") + .isEqualTo("PublicationPool"); + } + + @Test + void messages_are_captured_and_published_with_dedicated_datasource() throws Exception { + // Send message in transaction (uses primary/capture DataSource) + this.transactionTemplate.executeWithoutResult(status -> { + this.streamBridge.send("myConsumer-in-0", "test-message-dedicated-pool"); + }); + + // Wait for message to be published (uses dedicated/publishing DataSource) + await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> verify(this.myConsumer).accept("test-message-dedicated-pool")); + } + + @Configuration + @EnableAutoConfiguration + @EnableScheduling + @EnableTransactionManagement + @Slf4j + static class TestConfig { + + @Bean + public Consumer myConsumer() { + return value -> log.info("Message received: {}", value); + } + + /** + * Primary DataSource configuration for application transactions (capture). Uses JdbcConnectionDetails from Spring Boot's service + * connection (docker-compose). + */ + @Bean + @Primary + public HikariDataSource dataSource( + final org.springframework.boot.jdbc.autoconfigure.JdbcConnectionDetails connectionDetails) { + final HikariDataSource primary = new HikariDataSource(); + primary.setJdbcUrl(connectionDetails.getJdbcUrl()); + primary.setUsername(connectionDetails.getUsername()); + primary.setPassword(connectionDetails.getPassword()); + primary.setPoolName("PrimaryPool"); + primary.setMaximumPoolSize(10); + primary.setMinimumIdle(2); + return primary; + } + + /** + * Dedicated DataSource configuration for outbox publishing. Uses the same connection details but creates a separate connection pool. + */ + @Bean(name = OUTBOX_PUBLISHING_DATA_SOURCE) + public HikariDataSource outboxPublishingDataSource( + final org.springframework.boot.jdbc.autoconfigure.JdbcConnectionDetails connectionDetails) { + final HikariDataSource publication = new HikariDataSource(); + publication.setJdbcUrl(connectionDetails.getJdbcUrl()); + publication.setUsername(connectionDetails.getUsername()); + publication.setPassword(connectionDetails.getPassword()); + publication.setPoolName("PublicationPool"); + publication.setMaximumPoolSize(5); + publication.setMinimumIdle(1); + return publication; + } + } +} diff --git a/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/ErrorMessageFilteringIT.java b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/ErrorMessageFilteringIT.java new file mode 100644 index 0000000..a7eff4d --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/ErrorMessageFilteringIT.java @@ -0,0 +1,99 @@ +package dev.inditex.scsoutbox.it.jdbc; + +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import dev.inditex.scsoutbox.MessageCaptureTxService; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Integration test to verify that ErrorMessages generated by Spring Cloud Stream are NOT captured by the outbox pattern. + * + *

This test validates the filtering logic in {@link dev.inditex.scsoutbox.interceptor.OutboxChannelInterceptor} that prevents + * system-level error handling messages from being stored in the transactional outbox. + */ +@SpringBootTest( + classes = {ErrorMessageFilteringIT.TestConfig.class}, + properties = { + "spring.docker.compose.enabled=true", + "spring.docker.compose.skip.in-tests=false", + "spring.cloud.function.definition=myConsumer", + "spring.cloud.stream.bindings.myConsumer-in-0.destination=outbox", + "spring.cloud.stream.bindings.myConsumer-in-0.group=outbox", + "scs-outbox.bindings.inclusions=", + "scs-outbox.bindings.exclusions=", + "scs-outbox.publishing.batch-size=10", + "scs-outbox.publishing.after-commit=false", + "scs-outbox.publishing.scheduler.fixed-rate=1000", + }) +@DirtiesContext(classMode = ClassMode.AFTER_CLASS) +class ErrorMessageFilteringIT { + + @Configuration + @EnableAutoConfiguration + @EnableScheduling + @EnableTransactionManagement + @Slf4j + static class TestConfig { + + /** + * Simple consumer that throws an exception when processing a message. + */ + @Bean + public Consumer myConsumer() { + return message -> { + throw new RuntimeException("throw exception in consumer"); + }; + } + } + + @Autowired + private StreamBridge streamBridge; + + @MockitoSpyBean + private MessageCaptureTxService messageCaptureTxService; + + @Autowired + private TransactionTemplate transactionTemplate; + + /** + * Validates that when a consumer fails processing a message, the resulting ErrorMessage is NOT captured by the outbox pattern. + * + *

Test scenario:

  1. Send a message to an output binding (captured in outbox)
  2. The scheduled publisher publishes the + * message to the broker
  3. The consumer receives it and throws an exception
  4. Spring Cloud Stream generates an ErrorMessage + * for error handling
  5. Verify that only the original message was captured (capture called exactly once)
+ */ + @Test + void when_consumer_fails_then_error_message_is_not_captured_in_outbox() { + // 1. Send a message within a transaction (captured synchronously in outbox via the interceptor) + this.transactionTemplate.executeWithoutResult(status -> this.streamBridge.send("output", "hello-world")); + + // 2. Verify message was captured once (synchronous call during send) + verify(this.messageCaptureTxService, times(1)).capture(any(), any()); + + // 3. Wait for the scheduled publisher to pick up the message, deliver it to the consumer + // (which throws), and for any error handling to complete. Verify that capture() is never + // called again — no ErrorMessage was captured. + await().during(5, TimeUnit.SECONDS).atMost(6, TimeUnit.SECONDS) + .untilAsserted(() -> verify(this.messageCaptureTxService, times(1)).capture(any(), any())); + } +} diff --git a/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/GlobalPauseHotRefreshIT.java b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/GlobalPauseHotRefreshIT.java new file mode 100644 index 0000000..3ed6d32 --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/GlobalPauseHotRefreshIT.java @@ -0,0 +1,172 @@ +package dev.inditex.scsoutbox.it.jdbc; + +import static org.awaitility.Awaitility.await; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.util.Properties; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.context.refresh.ContextRefresher; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Integration test to verify the functionality of globally pausing publishing on the fly. + * + *

This test verifies that it is possible to pause ALL message publishing at runtime using Spring Cloud Config refresh, without needing + * to restart the application. + */ +@SpringBootTest(classes = {GlobalPauseHotRefreshIT.TestConfig.class}, properties = { + "spring.docker.compose.enabled=true", + "spring.docker.compose.skip.in-tests=false", + "scs-outbox.publishing.batch-size=1", + "scs-outbox.publishing.scheduler.fixed-rate=1000", + "scs-outbox.publishing.after-commit=false", + "management.endpoints.web.exposure.include=refresh", + "management.endpoint.refresh.enabled=true", + "spring.cloud.refresh.enabled=true", + "spring.cloud.function.definition=myConsumer", + "scs-outbox.bindings.inclusions=output" +}, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DirtiesContext(classMode = ClassMode.AFTER_CLASS) +class GlobalPauseHotRefreshIT { + + @Configuration + @EnableAutoConfiguration + @EnableScheduling + @EnableTransactionManagement + static class TestConfig { + + @Bean + public Consumer myConsumer() { + return message -> { + // Mock bean will handle the verification + }; + } + } + + @Autowired + private StreamBridge streamBridge; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private ConfigurableEnvironment environment; + + @Autowired + private ContextRefresher contextRefresher; + + @MockitoBean + private Consumer myConsumer; + + @Test + void shouldPauseAllPublishingGlobally() { + // 1. Check initial state - publishing enabled + this.sendMessageToDestination("output", "message1"); + this.verifyMessagePublished(this.myConsumer, "message1"); + + // 2. Pause all publishing globally + this.updateConfiguration("scs-outbox.publishing.paused", "true"); + this.refreshConfiguration(); + + // 3. Send new message after global pause - should not be published + this.sendMessageToDestination("output", "message2"); + this.verifyMessageNotPublished(this.myConsumer, "message2"); + + // 4. Resume publishing globally + this.updateConfiguration("scs-outbox.publishing.paused", "false"); + this.refreshConfiguration(); + + // 5. Verify that publishing works again + this.sendMessageToDestination("output", "message3"); + this.verifyMessagePublished(this.myConsumer, "message3"); + } + + @Test + void globalPauseTakesPrecedenceOverPausedDestinations() { + // 1. Set specific destinations as paused but publishing enabled + this.updateConfiguration("scs-outbox.publishing.paused-destinations", "nonExistentDestination"); + this.refreshConfiguration(); + + // 2. Verify that messages are still published (destination not in paused list) + this.sendMessageToDestination("output", "message1"); + this.verifyMessagePublished(this.myConsumer, "message1"); + + // 3. Enable global pause (should take precedence over paused destinations) + this.updateConfiguration("scs-outbox.publishing.paused", "true"); + this.refreshConfiguration(); + + // 4. Verify that NO messages are published, despite destination not being in + // paused list + this.sendMessageToDestination("output", "message2"); + this.verifyMessageNotPublished(this.myConsumer, "message2"); + + // 5. Disable global pause + this.updateConfiguration("scs-outbox.publishing.paused", "false"); + this.refreshConfiguration(); + + // 6. Verify that publishing works again + this.sendMessageToDestination("output", "message3"); + this.verifyMessagePublished(this.myConsumer, "message3"); + } + + private void sendMessageToDestination(String destination, String message) { + this.transactionTemplate.execute(status -> this.streamBridge.send(destination, message)); + } + + private void verifyMessagePublished(Consumer consumer, String expectedMessage) { + await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> verify(consumer).accept(expectedMessage)); + } + + private void verifyMessageNotPublished(Consumer consumer, String message) { + // Wait a reasonable time and verify that it was NOT called + await() + .pollDelay(3, TimeUnit.SECONDS) + .atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> verify(consumer, never()).accept(message)); + } + + private void updateConfiguration(String property, String value) { + // Update the Spring environment with the new configuration + final MutablePropertySources propertySources = this.environment.getPropertySources(); + final Properties props = new Properties(); + props.setProperty(property, value); + + // Remove the previous property if it exists to avoid conflicts + propertySources.remove("test-global-pause-refresh-props"); + + // Add the new configuration with high priority + propertySources.addFirst(new PropertiesPropertySource("test-global-pause-refresh-props", props)); + } + + private void refreshConfiguration() { + // Use ContextRefresher to trigger the refresh programmatically + // This simulates what a call to /actuator/refresh would do + this.contextRefresher.refresh(); + + // Give a short time for the refresh to be processed + await() + .pollDelay(500, TimeUnit.MILLISECONDS) + .atMost(2, TimeUnit.SECONDS) + .until(() -> true); // Just wait + } +} diff --git a/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/PausedDestinationsHotRefreshIT.java b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/PausedDestinationsHotRefreshIT.java new file mode 100644 index 0000000..4590004 --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/PausedDestinationsHotRefreshIT.java @@ -0,0 +1,182 @@ +package dev.inditex.scsoutbox.it.jdbc; + +import static org.awaitility.Awaitility.await; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Properties; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.context.refresh.ContextRefresher; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Integration test to verify the functionality of pausing destinations on the fly. + * + *

This test verifies that it is possible to pause message publishing for specific destinations at runtime using Spring Cloud Config + * refresh, without needing to restart the application. + */ +@SpringBootTest( + classes = {PausedDestinationsHotRefreshIT.TestConfig.class}, + properties = { + "spring.docker.compose.enabled=true", + "spring.docker.compose.skip.in-tests=false", + "scs-outbox.publishing.batch-size=1", + "scs-outbox.publishing.scheduler.fixed-rate=1000", + "scs-outbox.publishing.after-commit=false", + "management.endpoints.web.exposure.include=refresh", + "management.endpoint.refresh.enabled=true", + "spring.cloud.refresh.enabled=true", + "spring.cloud.function.definition=myConsumer", + "scs-outbox.bindings.inclusions=output" + }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DirtiesContext(classMode = ClassMode.AFTER_CLASS) +class PausedDestinationsHotRefreshIT { + + @Configuration + @EnableAutoConfiguration + @EnableScheduling + @EnableTransactionManagement + static class TestConfig { + + @Bean + public Consumer myConsumer() { + return message -> { + // Mock bean will handle the verification + }; + } + } + + @Autowired + private StreamBridge streamBridge; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private ConfigurableEnvironment environment; + + @Autowired + private ContextRefresher contextRefresher; + + @MockitoBean + private Consumer myConsumer; + + @Test + void shouldPauseDestinationAtRuntime() { + // 1. Check initial state - destination enabled + this.sendMessageToDestination("output", "message1"); + this.verifyMessagePublished(this.myConsumer, "message1"); + + // 2. Pause destination dynamically + this.updateConfiguration("scs-outbox.publishing.paused-destinations", List.of("outbox")); + this.refreshConfiguration(); + + // 3. Send new message after refresh - should not be published + this.sendMessageToDestination("output", "message2"); + this.verifyMessageNotPublished(this.myConsumer, "message2"); + + // 4. Resume destination dynamically + this.updateConfiguration("scs-outbox.publishing.paused-destinations", List.of()); + this.refreshConfiguration(); + + // 5. Verify that the destination works again + this.sendMessageToDestination("output", "message3"); + this.verifyMessagePublished(this.myConsumer, "message3"); + } + + @Test + void shouldHandleMultiplePauseDestinations() { + // For this test, we will simulate multiple destinations using different messages + // and verify that the filtering works correctly + + // 1. Initial state - destination enabled + this.sendMessageToDestination("output", "initial"); + this.verifyMessagePublished(this.myConsumer, "initial"); + + // 2. Pause multiple destinations (including some that do not exist) + this.updateConfiguration("scs-outbox.publishing.paused-destinations", + List.of("outbox", "nonExistentDestination", "anotherDestination")); + this.refreshConfiguration(); + + // 3. Verify that messages are not published + this.sendMessageToDestination("output", "paused"); + this.verifyMessageNotPublished(this.myConsumer, "paused"); + + // 4. Pause only destinations that do not exist (exclude 'outbox') + this.updateConfiguration("scs-outbox.publishing.paused-destinations", + List.of("nonExistentDestination", "anotherDestination")); + this.refreshConfiguration(); + + // 5. Verify that messages are now published + this.sendMessageToDestination("output", "enabled"); + this.verifyMessagePublished(this.myConsumer, "enabled"); + } + + private void sendMessageToDestination(String destination, String message) { + this.transactionTemplate.execute(status -> this.streamBridge.send(destination, message)); + } + + private void verifyMessagePublished(Consumer consumer, String expectedMessage) { + await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> verify(consumer).accept(expectedMessage)); + } + + private void verifyMessageNotPublished(Consumer consumer, String message) { + // Wait a reasonable time and verify that it was NOT called + await() + .pollDelay(3, TimeUnit.SECONDS) + .atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> verify(consumer, never()).accept(message)); + } + + private void updateConfiguration(String property, List values) { + // Update the Spring environment with the new configuration + final MutablePropertySources propertySources = this.environment.getPropertySources(); + final Properties props = new Properties(); + + if (values.isEmpty()) { + // For an empty list, set an empty property + props.setProperty(property, ""); + } else { + props.setProperty(property, String.join(",", values)); + } + + // Remove the previous property if it exists to avoid conflicts + propertySources.remove("test-refresh-props"); + + // Add the new configuration with high priority + propertySources.addFirst(new PropertiesPropertySource("test-refresh-props", props)); + } + + private void refreshConfiguration() { + // Use ContextRefresher to trigger the refresh programmatically + // This simulates what a call to /actuator/refresh would do + this.contextRefresher.refresh(); + + // Give a short time for the refresh to be processed + await() + .pollDelay(500, TimeUnit.MILLISECONDS) + .atMost(2, TimeUnit.SECONDS) + .until(() -> true); // Just wait + } +} diff --git a/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/PayloadSerializationCombinationsIT.java b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/PayloadSerializationCombinationsIT.java new file mode 100644 index 0000000..3688cc9 --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/PayloadSerializationCombinationsIT.java @@ -0,0 +1,281 @@ +package dev.inditex.scsoutbox.it.jdbc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.springframework.test.context.NestedTestConfiguration.EnclosingConfiguration.OVERRIDE; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.NestedTestConfiguration; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Integration tests that verify the outbox works correctly with combinations of + * {@code spring.cloud.stream.default.producer.useNativeEncoding}. + * + *

The two combinations are: + * + *
useNativeEncodingNested class
false{@link DefaultSerializationDefaultEncoding}
true{@link DefaultSerializationNativeEncoding}
+ * + *

When {@code useNativeEncoding=false} (default), SCS applies its {@code CompositeMessageConverter} pipeline on the producer side, + * converting the payload to {@code byte[]} before the outbox interceptor captures it. The raw bytes are stored as-is. + * + *

When {@code useNativeEncoding=true}, SCS does not apply its converter pipeline. The outbox interceptor captures the payload as the + * original application-level Object (e.g. {@code String}). On publishing, the deserialized payload is sent via the default + * {@code StreamBridge} path, and a Kafka-native {@code StringSerializer} handles the wire format. + */ +class PayloadSerializationCombinationsIT { + + @Configuration + @EnableAutoConfiguration + @EnableScheduling + @EnableTransactionManagement + @Slf4j + static class TestConfig { + + @Bean + public MessageCollector messageCollector() { + return new MessageCollector(); + } + + @Bean + public Consumer> myConsumer(final MessageCollector collector) { + return message -> { + final String decoded = new String(message.getPayload(), StandardCharsets.UTF_8); + log.info("Consumer received message: payload=[{}], contentType=[{}]", + decoded, message.getHeaders().get("contentType")); + collector.add(decoded, message.getHeaders()); + }; + } + } + + @Getter + static class MessageCollector { + + private final CopyOnWriteArrayList messages = new CopyOnWriteArrayList<>(); + + void add(final String payload, final MessageHeaders headers) { + this.messages.add(new ReceivedMessage(payload, headers)); + } + + void clear() { + this.messages.clear(); + } + } + + record ReceivedMessage(String payload, MessageHeaders headers) { + + } + + // ────────────────────────────────────────────────────────────────────────── + // Combo: useNativeEncoding=false (default) + // ────────────────────────────────────────────────────────────────────────── + + /** + * Default SCS behaviour. SCS converts the payload to {@code byte[]} before the interceptor captures it. Since the payload is already + * {@code byte[]}, it is stored as raw bytes (no serialization engine). On publishing, the raw {@code byte[]} payload is sent via the + * {@code sendRaw} path using {@code OutboxMessageConverter}, which extracts the bytes and copies the original captured headers. + */ + @Nested + @NestedTestConfiguration(OVERRIDE) + @SpringBootTest( + classes = PayloadSerializationCombinationsIT.TestConfig.class, + properties = { + "spring.docker.compose.enabled=true", + "spring.docker.compose.skip.in-tests=false", + "scs-outbox.publishing.batch-size=10", + "scs-outbox.publishing.after-commit=false", + "scs-outbox.publishing.scheduler.fixed-rate=1000", + }) + @DirtiesContext(classMode = ClassMode.AFTER_CLASS) + class DefaultSerializationDefaultEncoding { + + @Autowired + private StreamBridge streamBridge; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private MessageCollector messageCollector; + + @BeforeEach + void beforeEach() { + this.messageCollector.clear(); + } + + /** + * A message sent within a transaction is captured as SCS-converted {@code byte[]}, stored as raw bytes, published via the + * {@code sendRaw} path, and the consumer receives the correct payload. + */ + @Test + void when_default_settings_consumer_receives_correct_payload() { + final String payload = "default-serialization-default-encoding-test"; + + this.transactionTemplate.execute(status -> this.streamBridge.send("output", payload)); + + await() + .atMost(15, TimeUnit.SECONDS) + .untilAsserted(() -> { + assertThat(this.messageCollector.getMessages()).hasSizeGreaterThanOrEqualTo(1); + final ReceivedMessage received = this.messageCollector.getMessages().stream() + .filter(m -> m.payload().equals(payload)) + .findFirst() + .orElseThrow(() -> new AssertionError("Expected message not found")); + assertThat(received.payload()).isEqualTo(payload); + }); + } + + /** + * Validates that multiple messages sent with default settings are all received by the consumer. + */ + @Test + void when_default_settings_multiple_messages_are_all_received() { + final int messageCount = 5; + for (int i = 0; i < messageCount; i++) { + final int index = i; + this.transactionTemplate.execute(status -> this.streamBridge.send("output", "default-batch-" + index)); + } + + await() + .atMost(30, TimeUnit.SECONDS) + .untilAsserted(() -> { + final long batchMessages = this.messageCollector.getMessages().stream() + .filter(m -> m.payload().startsWith("default-batch-")) + .count(); + assertThat(batchMessages).isEqualTo(messageCount); + }); + } + + /** + * Validates that the consumer receives the original binding content type. This combo uses the {@code sendRaw} path where + * {@code OutboxMessageConverter} copies the captured headers — this assertion proves that the original {@code contentType} is preserved + * and not replaced by internal MIME types like {@code application/scs-outbox-message} or {@code application/octet-stream}. + */ + @Test + void when_default_settings_consumer_receives_original_content_type() { + final String payload = "content-type-verification"; + + this.transactionTemplate.execute(status -> this.streamBridge.send("output", payload)); + + await() + .atMost(15, TimeUnit.SECONDS) + .untilAsserted(() -> { + assertThat(this.messageCollector.getMessages()).isNotEmpty(); + final ReceivedMessage received = this.messageCollector.getMessages().stream() + .filter(m -> m.payload().equals(payload)) + .findFirst() + .orElseThrow(() -> new AssertionError("Expected message not found")); + final Object contentType = received.headers().get("contentType"); + assertThat(contentType).isNotNull(); + assertThat(contentType.toString()).doesNotContain("octet-stream"); + assertThat(contentType.toString()).doesNotContain("scs-outbox-message"); + }); + } + } + + // ────────────────────────────────────────────────────────────────────────── + // Combo: useNativeEncoding=true + // ────────────────────────────────────────────────────────────────────────── + + /** + * With native encoding, SCS does not convert the payload to {@code byte[]} before the interceptor captures it — the payload stays as the + * original Object ({@code String}). Since the payload is not {@code byte[]}, the default {@code SerializationEngine} + * ({@code JavaSerialization}) serializes it. On publishing, the deserialized payload ({@code String}) is sent via the default + * {@code StreamBridge} path, and the Kafka {@code StringSerializer} handles the wire format. + */ + @Nested + @NestedTestConfiguration(OVERRIDE) + @SpringBootTest( + classes = PayloadSerializationCombinationsIT.TestConfig.class, + properties = { + "spring.docker.compose.enabled=true", + "spring.docker.compose.skip.in-tests=false", + "spring.cloud.stream.default.producer.useNativeEncoding=true", + "spring.cloud.stream.kafka.binder.producer-properties[value.serializer]=" + + "org.apache.kafka.common.serialization.StringSerializer", + "scs-outbox.publishing.batch-size=10", + "scs-outbox.publishing.after-commit=false", + "scs-outbox.publishing.scheduler.fixed-rate=1000", + }) + @DirtiesContext(classMode = ClassMode.AFTER_CLASS) + class DefaultSerializationNativeEncoding { + + @Autowired + private StreamBridge streamBridge; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private MessageCollector messageCollector; + + @BeforeEach + void beforeEach() { + this.messageCollector.clear(); + } + + /** + * A message sent within a transaction is captured, stored with {@code JavaSerialization}, published through the default + * {@code StreamBridge} path, and the consumer receives the correct payload via Kafka's {@code StringSerializer}. + */ + @Test + void when_native_encoding_enabled_consumer_receives_correct_payload() { + final String payload = "default-serialization-native-encoding-test"; + + this.transactionTemplate.execute(status -> this.streamBridge.send("output", payload)); + + await() + .atMost(15, TimeUnit.SECONDS) + .untilAsserted(() -> { + assertThat(this.messageCollector.getMessages()).hasSizeGreaterThanOrEqualTo(1); + final ReceivedMessage received = this.messageCollector.getMessages().stream() + .filter(m -> m.payload().equals(payload)) + .findFirst() + .orElseThrow(() -> new AssertionError("Expected message not found")); + assertThat(received.payload()).isEqualTo(payload); + }); + } + + /** + * Validates that multiple messages sent with this combination are all received by the consumer. + */ + @Test + void when_native_encoding_enabled_multiple_messages_are_all_received() { + final int messageCount = 5; + for (int i = 0; i < messageCount; i++) { + final int index = i; + this.transactionTemplate.execute(status -> this.streamBridge.send("output", "native-batch-" + index)); + } + + await() + .atMost(30, TimeUnit.SECONDS) + .untilAsserted(() -> { + final long batchMessages = this.messageCollector.getMessages().stream() + .filter(m -> m.payload().startsWith("native-batch-")) + .count(); + assertThat(batchMessages).isEqualTo(messageCount); + }); + } + } +} diff --git a/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/ScsOutboxJdbcIT.java b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/ScsOutboxJdbcIT.java new file mode 100644 index 0000000..99faa2c --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/ScsOutboxJdbcIT.java @@ -0,0 +1,112 @@ +package dev.inditex.scsoutbox.it.jdbc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; + +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.MessageDeliveryException; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.TransactionTemplate; + +@SpringBootTest( + classes = {ScsOutboxJdbcIT.TestConfig.class}, + properties = { + "spring.docker.compose.enabled=true", + "spring.docker.compose.skip.in-tests=false", + "scs-outbox.metrics.enabled=true", + "scs-outbox.publishing.batch-size=1", + "scs-outbox.publishing.after-commit=false", + "scs-outbox.publishing.scheduler.fixed-rate=1000", + }) +@DirtiesContext(classMode = ClassMode.AFTER_CLASS) +class ScsOutboxJdbcIT { + + @Configuration + @EnableAutoConfiguration + @EnableScheduling + @EnableTransactionManagement + @Slf4j + static class TestConfig { + @Bean + public Consumer myConsumer() { + return value -> log.info("message received: {}", value); + } + + @Bean + public MeterRegistry meterRegistry() { + return new SimpleMeterRegistry(); + } + } + + @Autowired + private StreamBridge streamBridge; + + @MockitoBean + private Consumer myConsumer; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private MeterRegistry meterRegistry; + + @Test + void sending_a_message_outside_a_transaction() { + final MessageDeliveryException messageDeliveryException = assertThrows( + MessageDeliveryException.class, + () -> this.streamBridge.send("output", "key")); + + assertInstanceOf(IllegalTransactionStateException.class, messageDeliveryException.getCause()); + + } + + @Test + void sending_a_message_within_a_transaction() { + + final Boolean sent = this.transactionTemplate.execute(status -> { + return this.streamBridge.send("output", "transaction"); + }); + + assertFalse(sent); + await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted( + () -> verify(this.myConsumer).accept("transaction")); + } + + @Test + void messages_pending_metric() { + final int maxNumOfMessages = 10; + for (int i = 0; i < maxNumOfMessages; i++) { + this.transactionTemplate.execute(status -> { + return this.streamBridge.send("output", "key"); + }); + } + await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted( + () -> assertThat(this.meterRegistry.get("outbox.messages.pending").gauge().value()) + .isGreaterThan(0).isLessThan(maxNumOfMessages)); + } +} diff --git a/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/TransactionRollbackIT.java b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/TransactionRollbackIT.java new file mode 100644 index 0000000..25ba669 --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/java/dev/inditex/scsoutbox/it/jdbc/TransactionRollbackIT.java @@ -0,0 +1,224 @@ +package dev.inditex.scsoutbox.it.jdbc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.inditex.scsoutbox.OutboxMessageRepository; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Integration test to validate transaction rollback behavior in the outbox pattern. + * + *

This test ensures that: 1. Messages are not persisted when transactions roll back 2. The system maintains transactional integrity 3. + * The database remains in a consistent state after rollback + */ +@SpringBootTest( + classes = {TransactionRollbackIT.class}, + properties = { + "spring.docker.compose.enabled=true", + "spring.docker.compose.skip.in-tests=false", + "scs-outbox.publishing.after-commit=false", + "scs-outbox.publishing.scheduler.cron-expression=-", + "app.scheduling.enable=false" + }) +@EnableAsync +@EnableAutoConfiguration +@EnableScheduling +@EnableTransactionManagement +@DirtiesContext(classMode = ClassMode.AFTER_CLASS) +class TransactionRollbackIT { + + @Autowired + private StreamBridge streamBridge; + + @Autowired + private OutboxMessageRepository outboxMessageRepository; + + @Autowired + private PlatformTransactionManager transactionManager; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private TransactionTemplate transactionTemplate; + + @BeforeEach + void setUp() { + this.transactionTemplate = new TransactionTemplate(this.transactionManager); + + // Clean up any existing messages to ensure clean state + try { + this.jdbcTemplate.execute("DELETE FROM scs_outbox"); + } catch (final Exception e) { + // Table might not exist yet, ignore + } + + // Verify clean state + assertThat(this.outboxMessageRepository.count()).isZero(); + } + + @Test + void shouldNotPersistMessagesWhenTransactionRollsBack() { + // Given: Clean database state + assertThat(this.outboxMessageRepository.count()).isZero(); + + // When: Executing operations within a transaction that will be rolled back + assertThatThrownBy( + () -> { + this.transactionTemplate.execute( + status -> { + // Capture multiple messages within the transaction + this.streamBridge.send("output", "message1"); + this.streamBridge.send("output", "message2"); + this.streamBridge.send("output", "message3"); + + // Force rollback by throwing an exception + throw new RuntimeException("Forced rollback for test"); + }); + }) + .isInstanceOf(RuntimeException.class) + .hasMessage("Forced rollback for test"); + + // Then: No messages should be persisted in the database + assertThat(this.outboxMessageRepository.count()).isZero(); + } + + @Test + void shouldHandlePartialRollbackWithSavepoints() { + // Given: Clean state + assertThat(this.outboxMessageRepository.count()).isZero(); + + // When: Using manual transaction management with savepoints + final DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + final TransactionStatus status = this.transactionManager.getTransaction(def); + + try { + // First message before savepoint + this.streamBridge.send("output", "before-savepoint"); + + // Create savepoint + final Object savepoint = status.createSavepoint(); + + try { + // Messages after savepoint + this.streamBridge.send("output", "after-savepoint-1"); + this.streamBridge.send("output", "after-savepoint-2"); + + // Rollback to savepoint + status.rollbackToSavepoint(savepoint); + + // Message after rollback to savepoint + this.streamBridge.send("output", "after-rollback-to-savepoint"); + + // Commit the transaction + this.transactionManager.commit(status); + + } catch (final Exception e) { + status.rollbackToSavepoint(savepoint); + this.transactionManager.commit(status); + } + + } catch (final Exception e) { + this.transactionManager.rollback(status); + } + + // Then: Only messages before savepoint and after rollback should exist + final long finalCount = this.outboxMessageRepository.count(); + assertThat(finalCount).isEqualTo(2); // before-savepoint and after-rollback-to-savepoint + } + + @Test + void shouldMaintainTransactionalIntegrityAcrossMultipleRollbacks() { + // Given: Clean state + assertThat(this.outboxMessageRepository.count()).isZero(); + + // When: Executing multiple transactions with rollbacks + for (int i = 0; i < 5; i++) { + final int messageId = i; + + if (i % 2 == 0) { + // Even iterations: successful transactions + this.transactionTemplate.execute( + status -> { + this.streamBridge.send("output", "success-message-" + messageId); + return null; + }); + } else { + // Odd iterations: failed transactions (rollback) + assertThatThrownBy( + () -> { + this.transactionTemplate.execute( + status -> { + this.streamBridge.send("output", "failed-message-" + messageId); + throw new RuntimeException("Forced rollback " + messageId); + }); + }) + .isInstanceOf(RuntimeException.class); + } + } + + // Then: Only successful transactions should have persisted messages + final long successfulMessages = this.outboxMessageRepository.count(); + assertThat(successfulMessages).isEqualTo(3); // Messages 0, 2, 4 + } + + @Test + void shouldRecoverConsistentStateAfterRollback() { + // Given: Some initial messages in successful transaction + this.transactionTemplate.execute( + status -> { + this.streamBridge.send("output", "initial-message-1"); + this.streamBridge.send("output", "initial-message-2"); + return null; + }); + + final long initialCount = this.outboxMessageRepository.count(); + assertThat(initialCount).isEqualTo(2); + + // When: A transaction fails and rolls back + assertThatThrownBy( + () -> { + this.transactionTemplate.execute( + status -> { + this.streamBridge.send("output", "failing-message-1"); + this.streamBridge.send("output", "failing-message-2"); + this.streamBridge.send("output", "failing-message-3"); + + // Simulate database constraint violation or business logic failure + throw new RuntimeException("Business logic failure"); + }); + }) + .isInstanceOf(RuntimeException.class); + + // Then: State should be exactly as before the failed transaction + final long finalCount = this.outboxMessageRepository.count(); + assertThat(finalCount).isEqualTo(initialCount); + + // And: System should still be functional for new transactions + this.transactionTemplate.execute( + status -> { + this.streamBridge.send("output", "recovery-message"); + return null; + }); + + assertThat(this.outboxMessageRepository.count()).isEqualTo(initialCount + 1); + } +} diff --git a/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/resources/application.yml b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/resources/application.yml new file mode 100644 index 0000000..c246b25 --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/resources/application.yml @@ -0,0 +1,51 @@ +logging: + level: + dev.inditex.scsoutbox: DEBUG + root: WARN +spring: + application: + name: scs-outbox-jdbc-it + docker: + compose: + enabled: false + file: classpath:compose/docker-compose.yml + start: + command: up + log-level: debug + stop: + command: down + skip: + in-tests: true + datasource: + username: postgres + password: postgres-pwd + + + cloud: + function: + definition: myConsumer + stream: + output-bindings: output + bindings: + myConsumer-in-0: + destination: outbox + group: outbox + output: + destination: outbox + contentType: text/*;charset=UTF-8 + kafka: + binder: + brokers: localhost:30810 +scs-outbox: + bindings: + inclusions: "output" + exclusions: "myConsumer-in-0" + publishing: + after-commit: false + scheduler: + fixed-rate: 1000 + archive: + enabled: false + json-payload-enabled: false + metrics: + enabled: true \ No newline at end of file diff --git a/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/resources/compose/docker-compose.yml b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/resources/compose/docker-compose.yml new file mode 100644 index 0000000..2e3a276 --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/resources/compose/docker-compose.yml @@ -0,0 +1,44 @@ +name: scs-outbox-jdbc-it +services: + postgres: + container_name: postgres + image: postgres:16 + ports: + - "5432" + environment: + - "POSTGRES_DB=postgres" + - "POSTGRES_USER=postgres" + - "POSTGRES_PASSWORD=postgres-pwd" + volumes: + - "./postgres/data:/docker-entrypoint-initdb.d" + healthcheck: + test: "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\"" + start_period: 10s + interval: 10s + retries: 10 + timeout: 10s + labels: + org.springframework.boot.readiness-check.tcp.disable: true + # Enable service connection + org.springframework.boot.service-connection: postgres + kafka: + image: apache/kafka:3.8.1 + ports: + - "30810:9092" + environment: + - "KAFKA_NODE_ID=1" + - "KAFKA_PROCESS_ROLES=broker,controller" + - "KAFKA_LISTENERS=OUTSIDE://0.0.0.0:9092,PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:9093" + - "KAFKA_ADVERTISED_LISTENERS=OUTSIDE://localhost:30810,PLAINTEXT://kafka:29092" + - "KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=OUTSIDE:PLAINTEXT,PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT" + - "KAFKA_INTER_BROKER_LISTENER_NAME=PLAINTEXT" + - "KAFKA_CONTROLLER_QUORUM_VOTERS=1@kafka:9093" + - "KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER" + - "KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1" + - "KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS=0" + - "KAFKA_TRANSACTION_STATE_LOG_MIN_ISR=1" + - "KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1" + volumes: + - "./kafka:/tmp/data" + labels: + org.springframework.boot.readiness-check.tcp.disable: true \ No newline at end of file diff --git a/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/resources/compose/postgres/data/01-schemas.sql b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/resources/compose/postgres/data/01-schemas.sql new file mode 100755 index 0000000..f87378e --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/resources/compose/postgres/data/01-schemas.sql @@ -0,0 +1 @@ +-- SCHEMAS diff --git a/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/resources/compose/postgres/data/02-tables.sql b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/resources/compose/postgres/data/02-tables.sql new file mode 100755 index 0000000..0b29736 --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-jdbc-it/src/test/resources/compose/postgres/data/02-tables.sql @@ -0,0 +1,34 @@ +-- TABLES +CREATE TABLE IF NOT EXISTS + SCS_OUTBOX + ( + ID varchar(36) not null, + BINDING_NAME varchar(256) not null , + CAPTURED_AT timestamp not null, + DESTINATION varchar(256) not null, + HEADERS text not null, + PAYLOAD bytea not null, + constraint PK_OUTBOX primary key (ID) +); +CREATE TABLE IF NOT EXISTS + shedlock ( + name VARCHAR(64), + lock_until TIMESTAMP(3) NULL, + locked_at TIMESTAMP(3) NULL, + locked_by VARCHAR(255), + PRIMARY KEY (name) +); +CREATE TABLE IF NOT EXISTS + SCS_OUTBOX_ARCHIVE + ( + ID varchar(36) not null, + ARCHIVED_AT timestamptz not null, + CAPTURED_AT timestamptz not null, + DESTINATION varchar(256) not null, + CONTENT_TYPE varchar(256) not null, + HEADERS text not null, + PAYLOAD bytea not null, + SERIALIZATION varchar(256) not null, + JSON_PAYLOAD jsonb, + constraint PK_OUTBOX_ARCHIVE primary key (ID) +); \ No newline at end of file diff --git a/code/scs-outbox-it/scs-outbox-mongodb-it/pom.xml b/code/scs-outbox-it/scs-outbox-mongodb-it/pom.xml new file mode 100644 index 0000000..e567b25 --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-mongodb-it/pom.xml @@ -0,0 +1,89 @@ + + + 4.0.0 + + dev.inditex.scsoutbox + scs-outbox-it + 1.0.0-SNAPSHOT + + + scs-outbox-mongodb-it + + + + + + + org.springframework.boot + spring-boot-docker-compose + + + org.springframework.boot + spring-boot-starter + + + org.springframework.cloud + spring-cloud-stream-binder-kafka + + + org.awaitility + awaitility + + + dev.inditex.scsoutbox + scs-outbox-mongodb-starter + + + dev.inditex.scsoutbox + scs-outbox-test-support + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + ch.qos.logback + logback-classic + + + + + org.springframework.boot + spring-boot-mongodb + test + + + org.springframework.boot + spring-boot-data-mongodb + test + + + org.junit.jupiter + junit-jupiter + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + org.testcontainers + testcontainers-mongodb + test + + + org.testcontainers + testcontainers-kafka + test + + + org.springframework.boot + spring-boot-testcontainers + test + + + diff --git a/code/scs-outbox-it/scs-outbox-mongodb-it/src/test/java/dev/inditex/scsoutbox/it/mongo/DedicatedPublishingMongoTemplateIT.java b/code/scs-outbox-it/scs-outbox-mongodb-it/src/test/java/dev/inditex/scsoutbox/it/mongo/DedicatedPublishingMongoTemplateIT.java new file mode 100644 index 0000000..21db757 --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-mongodb-it/src/test/java/dev/inditex/scsoutbox/it/mongo/DedicatedPublishingMongoTemplateIT.java @@ -0,0 +1,150 @@ +package dev.inditex.scsoutbox.it.mongo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.Mockito.verify; + +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import dev.inditex.scsoutbox.mongodb.OutboxMongoTemplateProvider; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.mongodb.autoconfigure.MongoConnectionDetails; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.mongodb.MongoDatabaseFactory; +import org.springframework.data.mongodb.MongoTransactionManager; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Integration test validating the dedicated MongoTemplate functionality for outbox publishing. This test configures a separate + * MongoTemplate specifically for publishing operations to ensure isolation from the main application MongoTemplate. + */ +@SpringBootTest( + classes = {DedicatedPublishingMongoTemplateIT.TestConfig.class}, + properties = { + "spring.docker.compose.enabled=true", + "spring.docker.compose.skip.in-tests=false", + "scs-outbox.publishing.batch-size=1", + "scs-outbox.publishing.after-commit=false", + "scs-outbox.publishing.scheduler.fixed-rate=1000", + }) +@DirtiesContext(classMode = ClassMode.AFTER_CLASS) +class DedicatedPublishingMongoTemplateIT { + + public static final String OUTBOX_PUBLISHING_MONGO_TEMPLATE = "outboxPublishingMongoTemplate"; + + @Autowired + private OutboxMongoTemplateProvider templateProvider; + + @Autowired + private MongoTemplate primaryMongoTemplate; + + @Autowired + @Qualifier(OUTBOX_PUBLISHING_MONGO_TEMPLATE) + private MongoTemplate publishingMongoTemplate; + + @Autowired + private StreamBridge streamBridge; + + @MockitoBean + private Consumer myConsumer; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Test + void provider_uses_dedicated_template_for_publishing() { + assertThat(this.templateProvider.getPrimary()) + .describedAs("Provider should expose different instances when a dedicated publishing MongoTemplate is configured") + .isNotSameAs(this.templateProvider.getDedicatedForPublishing()); + } + + @Test + void capture_uses_primary_template() { + assertThat(this.templateProvider.getPrimary()) + .describedAs("Capture operations should use the primary MongoTemplate") + .isSameAs(this.primaryMongoTemplate); + } + + @Test + void publishing_uses_dedicated_template() { + assertThat(this.templateProvider.getDedicatedForPublishing()) + .describedAs("Publishing operations should use the dedicated MongoTemplate") + .isSameAs(this.publishingMongoTemplate); + } + + @Test + void templates_are_different_instances() { + assertThat(this.templateProvider.getPrimary()) + .describedAs("Capture and publishing should use different MongoTemplate instances for isolation") + .isNotSameAs(this.templateProvider.getDedicatedForPublishing()); + } + + @Test + void messages_are_captured_and_published_with_dedicated_template() { + // Send message in transaction (uses primary/capture MongoTemplate) + this.transactionTemplate.executeWithoutResult(status -> this.streamBridge.send("myConsumer-in-0", "test-message-dedicated-template")); + + // Wait for message to be published (uses dedicated/publishing MongoTemplate) + await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> verify(this.myConsumer).accept("test-message-dedicated-template")); + } + + @Configuration + @EnableAutoConfiguration + @EnableScheduling + @EnableTransactionManagement + @Slf4j + static class TestConfig { + + @Bean + public Consumer myConsumer() { + return value -> log.info("Message received: {}", value); + } + + @Bean + MongoTransactionManager transactionManager(final MongoDatabaseFactory dbFactory) { + return new MongoTransactionManager(dbFactory); + } + + /** + * Primary MongoTemplate configuration for application transactions (capture). Uses MongoConnectionDetails from Spring Boot's service + * connection (docker-compose). + */ + @Bean + @Primary + public MongoTemplate mongoTemplate(final MongoConnectionDetails connectionDetails) { + final MongoDatabaseFactory factory = new SimpleMongoClientDatabaseFactory( + connectionDetails.getConnectionString()); + return new MongoTemplate(factory); + } + + /** + * Dedicated MongoTemplate configuration for outbox publishing. Uses the same connection details but creates a separate MongoTemplate + * instance. + */ + @Bean(name = OUTBOX_PUBLISHING_MONGO_TEMPLATE) + public MongoTemplate outboxPublishingMongoTemplate(final MongoConnectionDetails connectionDetails) { + final MongoDatabaseFactory factory = new SimpleMongoClientDatabaseFactory( + connectionDetails.getConnectionString()); + return new MongoTemplate(factory); + } + } +} diff --git a/code/scs-outbox-it/scs-outbox-mongodb-it/src/test/java/dev/inditex/scsoutbox/it/mongo/ScsOutboxMongodbIT.java b/code/scs-outbox-it/scs-outbox-mongodb-it/src/test/java/dev/inditex/scsoutbox/it/mongo/ScsOutboxMongodbIT.java new file mode 100644 index 0000000..cde31a1 --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-mongodb-it/src/test/java/dev/inditex/scsoutbox/it/mongo/ScsOutboxMongodbIT.java @@ -0,0 +1,89 @@ +package dev.inditex.scsoutbox.it.mongo; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; + +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.MongoDatabaseFactory; +import org.springframework.data.mongodb.MongoTransactionManager; +import org.springframework.messaging.MessageDeliveryException; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.TransactionTemplate; + +@SpringBootTest( + classes = {ScsOutboxMongodbIT.TestConfig.class}, + properties = { + "spring.docker.compose.enabled=true", + "spring.docker.compose.skip.in-tests=false", + "scs-outbox.publishing.batch-size=1", + "scs-outbox.publishing.after-commit=false", + "scs-outbox.publishing.scheduler.fixed-rate=2000", + }) +class ScsOutboxMongodbIT { + + @Autowired + private StreamBridge streamBridge; + + @MockitoBean + private Consumer myConsumer; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Test + void sending_a_message_outside_a_transaction() { + final MessageDeliveryException messageDeliveryException = assertThrows( + MessageDeliveryException.class, + () -> this.streamBridge.send("output", "key")); + + assertInstanceOf(IllegalTransactionStateException.class, messageDeliveryException.getCause()); + + } + + @Test + void sending_a_message_within_a_transaction() { + + final Boolean sent = this.transactionTemplate.execute(status -> { + return this.streamBridge.send("output", "key"); + }); + + assertFalse(sent); + await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted( + () -> verify(this.myConsumer).accept("key")); + } + + @Configuration + @EnableAutoConfiguration + @EnableScheduling + @EnableTransactionManagement + @Slf4j + static class TestConfig { + @Bean + public Consumer myConsumer() { + return value -> log.info("message received: " + value); + } + + @Bean + MongoTransactionManager transactionManager(final MongoDatabaseFactory dbFactory) { + return new MongoTransactionManager(dbFactory); + } + } +} diff --git a/code/scs-outbox-it/scs-outbox-mongodb-it/src/test/resources/application.yml b/code/scs-outbox-it/scs-outbox-mongodb-it/src/test/resources/application.yml new file mode 100644 index 0000000..7dc2fe6 --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-mongodb-it/src/test/resources/application.yml @@ -0,0 +1,46 @@ +logging: + level: + root: INFO +spring: + application: + name: scs-outbox-mongodb-it + docker: + compose: + enabled: false + file: classpath:compose/docker-compose.yml + start: + command: up + log-level: debug + stop: + command: down + skip: + in-tests: true + data: + mongodb: + database: docker-db + cloud: + function: + definition: myConsumer + stream: + output-bindings: output + bindings: + myConsumer-in-0: + destination: outbox + group: outbox + output: + destination: outbox + contentType: text/*;charset=UTF-8 + kafka: + binder: + brokers: localhost:30810 +scs-outbox: + bindings: + inclusions: "output" + exclusions: "myConsumer-in-0" + publishing: + after-commit: true + scheduler: + fixed-rate: 1000 + archive: + enabled: true + json-payload-enabled: false \ No newline at end of file diff --git a/code/scs-outbox-it/scs-outbox-mongodb-it/src/test/resources/compose/docker-compose.yml b/code/scs-outbox-it/scs-outbox-mongodb-it/src/test/resources/compose/docker-compose.yml new file mode 100644 index 0000000..9055bb3 --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-mongodb-it/src/test/resources/compose/docker-compose.yml @@ -0,0 +1,50 @@ +name: scs-outbox-mongodb-it +services: + mongodb: + container_name: mongodb + image: mongo:7.0 + ports: + - '27017' + command: ["--replSet", "rs0", "--bind_ip_all"] + healthcheck: + test: | + mongosh --quiet --eval " + try { + var s = rs.status(); + quit(s.ok == 1 ? 0 : 1); + } catch(e) { + rs.initiate({_id:'rs0', members:[{_id:0, host:'localhost:27017'}]}); + quit(1); + } + " + start_period: 10s + interval: 5s + timeout: 15s + retries: 10 + labels: + org.springframework.boot.readiness-check.tcp.disable: false + # Enable service connection + org.springframework.boot.service-connection: mongo + volumes: + - "./mongodb:/tmp/data" + kafka: + image: apache/kafka:3.8.1 + ports: + - "30810:9092" + environment: + - "KAFKA_NODE_ID=1" + - "KAFKA_PROCESS_ROLES=broker,controller" + - "KAFKA_LISTENERS=OUTSIDE://0.0.0.0:9092,PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:9093" + - "KAFKA_ADVERTISED_LISTENERS=OUTSIDE://localhost:30810,PLAINTEXT://kafka:29092" + - "KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=OUTSIDE:PLAINTEXT,PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT" + - "KAFKA_INTER_BROKER_LISTENER_NAME=PLAINTEXT" + - "KAFKA_CONTROLLER_QUORUM_VOTERS=1@kafka:9093" + - "KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER" + - "KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1" + - "KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS=0" + - "KAFKA_TRANSACTION_STATE_LOG_MIN_ISR=1" + - "KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1" + volumes: + - "./kafka:/tmp/data" + labels: + org.springframework.boot.readiness-check.tcp.disable: true \ No newline at end of file diff --git a/code/scs-outbox-it/scs-outbox-mongodb-it/src/test/resources/compose/mongodb/mongo.properties b/code/scs-outbox-it/scs-outbox-mongodb-it/src/test/resources/compose/mongodb/mongo.properties new file mode 100644 index 0000000..59ea829 --- /dev/null +++ b/code/scs-outbox-it/scs-outbox-mongodb-it/src/test/resources/compose/mongodb/mongo.properties @@ -0,0 +1 @@ +replicaSetName=rs0 \ No newline at end of file diff --git a/code/scs-outbox-libs/pom.xml b/code/scs-outbox-libs/pom.xml new file mode 100644 index 0000000..f368466 --- /dev/null +++ b/code/scs-outbox-libs/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + dev.inditex.scsoutbox + scs-outbox + 1.0.0-SNAPSHOT + + + scs-outbox-libs + pom + + scs-outbox-test-support + scs-outbox-core + scs-outbox-jdbc + scs-outbox-mongodb + scs-outbox-serialization + scs-outbox-archive + scs-outbox-archive-jdbc + scs-outbox-archive-mongodb + scs-outbox-metrics + + + + + diff --git a/code/scs-outbox-libs/scs-outbox-archive-jdbc/pom.xml b/code/scs-outbox-libs/scs-outbox-archive-jdbc/pom.xml new file mode 100644 index 0000000..f262c19 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-jdbc/pom.xml @@ -0,0 +1,89 @@ + + + 4.0.0 + + dev.inditex.scsoutbox + scs-outbox-libs + 1.0.0-SNAPSHOT + + + scs-outbox-archive-jdbc + + + + + + dev.inditex.scsoutbox + scs-outbox-archive + + + dev.inditex.scsoutbox + scs-outbox-jdbc + + + dev.inditex.scsoutbox + scs-outbox-serialization + + + org.springframework + spring-jdbc + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + dev.inditex.scsoutbox + scs-outbox-test-support + test + + + + org.junit.jupiter + junit-jupiter + test + + + com.h2database + h2 + test + + + org.springframework.boot + spring-boot-starter-test + test + + + org.testcontainers + testcontainers + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + org.testcontainers + testcontainers-mariadb + test + + + org.mariadb.jdbc + mariadb-java-client + test + + + org.testcontainers + testcontainers-postgresql + test + + + org.postgresql + postgresql + test + + + diff --git a/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/main/java/dev/inditex/scsoutbox/publish/archive/jdbc/JdbcArchivedMessageRepository.java b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/main/java/dev/inditex/scsoutbox/publish/archive/jdbc/JdbcArchivedMessageRepository.java new file mode 100755 index 0000000..9bb8b38 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/main/java/dev/inditex/scsoutbox/publish/archive/jdbc/JdbcArchivedMessageRepository.java @@ -0,0 +1,76 @@ +package dev.inditex.scsoutbox.publish.archive.jdbc; + +import java.sql.Timestamp; + +import dev.inditex.scsoutbox.jdbc.Table; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessage; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessageRepository; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessageSerializer; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessageSerializer.SerializedArchivedMessage; + +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; + +@Slf4j +public class JdbcArchivedMessageRepository implements ArchivedMessageRepository { + + private final JdbcTemplate jdbcTemplate; + + private final ArchivedMessageSerializer serializer; + + private final String jsonPayloadColumnType; + + private final Table table; + + @SneakyThrows + public JdbcArchivedMessageRepository(final JdbcTemplate jdbcTemplate, final ArchivedMessageSerializer serializer, final Table table) { + this.jdbcTemplate = jdbcTemplate; + this.serializer = serializer; + this.table = table; + log.info("Using table: {}", table.getQualifiedTableName()); + this.jsonPayloadColumnType = this.getColumnDataType("JSON_PAYLOAD"); + } + + private String getColumnDataType(final String columnName) { + try { + // Build the basic SQL query + final String sql = "SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE UPPER(TABLE_NAME) = UPPER('" + this.table.tableName().value() + "') " + + "AND UPPER(COLUMN_NAME) = UPPER('" + columnName + "') " + + "AND UPPER(TABLE_SCHEMA) = UPPER('" + this.table.schemaName().value() + "')"; + + return this.jdbcTemplate.queryForObject(sql, String.class); + } catch (final EmptyResultDataAccessException e) { + return "text"; + } + } + + @Override + public void save(final ArchivedMessage archivedMessage) { + final SerializedArchivedMessage serialized = this.serializer.serialize(archivedMessage); + this.jdbcTemplate.update( + "INSERT INTO " + + this.table.getQualifiedTableName() + " " + + "(ID, ARCHIVED_AT, CAPTURED_AT, DESTINATION, CONTENT_TYPE, HEADERS, PAYLOAD, SERIALIZATION, JSON_PAYLOAD) " + + "VALUES ( ?, ?, ?, ?, ?, ?, ?, ?," + this.getSQLJsonPayloadValue() + ")", + serialized.getId(), + Timestamp.from(serialized.getArchivedAt()), + Timestamp.from(serialized.getCapturedAt()), + serialized.getDestination(), + serialized.getContentType(), + serialized.getHeaders(), + serialized.getPayload(), + serialized.getSerialization(), + serialized.getJsonPayload()); + } + + private String getSQLJsonPayloadValue() { + if (this.jsonPayloadColumnType.startsWith("json")) { + return "cast(? as json)"; + } + return "?"; + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/main/java/dev/inditex/scsoutbox/publish/archive/jdbc/config/JdbcArchiveAutoConfiguration.java b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/main/java/dev/inditex/scsoutbox/publish/archive/jdbc/config/JdbcArchiveAutoConfiguration.java new file mode 100755 index 0000000..a4ceda1 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/main/java/dev/inditex/scsoutbox/publish/archive/jdbc/config/JdbcArchiveAutoConfiguration.java @@ -0,0 +1,63 @@ +package dev.inditex.scsoutbox.publish.archive.jdbc.config; + +import javax.sql.DataSource; + +import dev.inditex.scsoutbox.config.OutboxAutoConfiguration; +import dev.inditex.scsoutbox.jdbc.DataSourceMetadata; +import dev.inditex.scsoutbox.jdbc.DbSchemaResolver; +import dev.inditex.scsoutbox.jdbc.OutboxDataSourceProvider; +import dev.inditex.scsoutbox.jdbc.SchemaName; +import dev.inditex.scsoutbox.jdbc.Table; +import dev.inditex.scsoutbox.jdbc.TableName; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessageRepository; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessageSerializer; +import dev.inditex.scsoutbox.publish.archive.config.ArchiveAutoConfiguration; +import dev.inditex.scsoutbox.publish.archive.jdbc.JdbcArchivedMessageRepository; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Auto-configuration for JDBC-based archived message repository. The archive repository uses the same DataSource as publishing operations + * since archiving only happens during publishing phase. + */ +@Slf4j +@ConditionalOnBean(ArchiveAutoConfiguration.class) +@AutoConfiguration(after = OutboxAutoConfiguration.class) +@EnableConfigurationProperties(JdbcArchiveProperties.class) +public class JdbcArchiveAutoConfiguration { + + /** + * Creates the archived message repository using the publishing DataSource from the provider. Archive operations always use the publishing + * DataSource since archiving happens during publishing tasks. + * + * @param dataSourceProvider the provider that coordinates DataSource usage + * @param archivedMessageSerializer the serializer for archived messages + * @param properties archive configuration properties + * @return the archived message repository instance + */ + @Bean + public ArchivedMessageRepository archivedMessageRepository( + final OutboxDataSourceProvider dataSourceProvider, + final ArchivedMessageSerializer archivedMessageSerializer, + final JdbcArchiveProperties properties) { + + final DataSource dataSource = dataSourceProvider.getDedicatedForPublishing(); + final DataSourceMetadata metadata = dataSourceProvider.getDedicatedForPublishingDataSourceMetadata(); + final DbSchemaResolver dbSchemaResolver = new DbSchemaResolver(metadata); + final String schema = dbSchemaResolver.resolve(properties.getSchema()); + final Table table = new Table(new SchemaName(schema), new TableName(properties.getTableName())); + + log.info("Creating archived message repository for table: {}", table.getQualifiedTableName()); + + return new JdbcArchivedMessageRepository( + new JdbcTemplate(dataSource), + archivedMessageSerializer, + table); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/main/java/dev/inditex/scsoutbox/publish/archive/jdbc/config/JdbcArchiveProperties.java b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/main/java/dev/inditex/scsoutbox/publish/archive/jdbc/config/JdbcArchiveProperties.java new file mode 100644 index 0000000..3704215 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/main/java/dev/inditex/scsoutbox/publish/archive/jdbc/config/JdbcArchiveProperties.java @@ -0,0 +1,21 @@ +package dev.inditex.scsoutbox.publish.archive.jdbc.config; + +import dev.inditex.scsoutbox.jdbc.config.AbstractJdbcProperties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +@ConfigurationProperties("scs-outbox.publishing.archive.jdbc") +public class JdbcArchiveProperties extends AbstractJdbcProperties { + + private static final String DEFAULT_TABLE_NAME = "SCS_OUTBOX_ARCHIVE"; + + public JdbcArchiveProperties() { + super(DEFAULT_TABLE_NAME, DEFAULT_SCHEMA_VALUE, DEFAULT_TABLE_NAME); + } + + @ConstructorBinding + public JdbcArchiveProperties(final String tableName, final String schema) { + super(tableName, schema, DEFAULT_TABLE_NAME); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..e906e09 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +dev.inditex.scsoutbox.publish.archive.jdbc.config.JdbcArchiveAutoConfiguration \ No newline at end of file diff --git a/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/AbstractJdbcArchivedMessageRepositoryTest.java b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/AbstractJdbcArchivedMessageRepositoryTest.java new file mode 100644 index 0000000..e128114 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/AbstractJdbcArchivedMessageRepositoryTest.java @@ -0,0 +1,51 @@ +package dev.inditex.scsoutbox.publish.archive.jdbc; + +import static dev.inditex.scsoutbox.publish.archive.jdbc.ArchivedMessageMother.anArchivedMessage; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import javax.sql.DataSource; + +import dev.inditex.scsoutbox.jdbc.DataSourceMetadata; +import dev.inditex.scsoutbox.jdbc.DbSchemaResolver; +import dev.inditex.scsoutbox.jdbc.SchemaName; +import dev.inditex.scsoutbox.jdbc.Table; +import dev.inditex.scsoutbox.jdbc.TableName; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessage; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessageSerializer; +import dev.inditex.scsoutbox.serialization.JavaSerialization; +import dev.inditex.scsoutbox.serialization.JsonHeadersMapper; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; + +abstract class AbstractJdbcArchivedMessageRepositoryTest { + + private JdbcArchivedMessageRepository repository; + + @BeforeEach + public void setUp() { + final JdbcTemplate jdbcTemplate = new JdbcTemplate(this.getDataSource()); + final DbSchemaResolver dbSchemaResolver = new DbSchemaResolver(new DataSourceMetadata(this.getDataSource())); + final String schema = dbSchemaResolver.resolve(null); + jdbcTemplate.execute("DELETE FROM SCS_OUTBOX_ARCHIVE"); + final ArchivedMessageSerializer serializer = new ArchivedMessageSerializer(new JavaSerialization(), new JsonHeadersMapper()); + this.repository = new JdbcArchivedMessageRepository( + jdbcTemplate, + serializer, + new Table(new SchemaName(schema), new TableName("SCS_OUTBOX_ARCHIVE"))); + } + + public abstract DataSource getDataSource(); + + @Test + void save_message_with_same_id() { + final ArchivedMessage archivedMessage = anArchivedMessage(); + this.repository.save(archivedMessage); + + assertThrows(DuplicateKeyException.class, () -> this.repository.save(archivedMessage)); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/ArchivedMessageMother.java b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/ArchivedMessageMother.java new file mode 100755 index 0000000..cbbfbce --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/ArchivedMessageMother.java @@ -0,0 +1,25 @@ +package dev.inditex.scsoutbox.publish.archive.jdbc; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import java.util.UUID; + +import dev.inditex.scsoutbox.publish.archive.ArchivedMessage; + +public abstract class ArchivedMessageMother { + + public static ArchivedMessage anArchivedMessage() { + return ArchivedMessage.builder() + .id(UUID.randomUUID()) + .archivedAt(Instant.now()) + .capturedAt(Instant.now().minus(1, ChronoUnit.MINUTES)) + .destination("destination") + .contentType("application/json") + .payload("payload") + .headers(Map.of()) + .jsonPayload("{ \"key\": \"value\" }") + .build(); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/JdbcArchivedMessageRepositoryTest.java b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/JdbcArchivedMessageRepositoryTest.java new file mode 100644 index 0000000..b7addcc --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/JdbcArchivedMessageRepositoryTest.java @@ -0,0 +1,17 @@ +package dev.inditex.scsoutbox.publish.archive.jdbc; + +import javax.sql.DataSource; + +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; + +class JdbcArchivedMessageRepositoryTest extends AbstractJdbcArchivedMessageRepositoryTest { + + @Override + public DataSource getDataSource() { + return new EmbeddedDatabaseBuilder().setType(EmbeddedDatabaseType.H2) + .addScript("classpath:scripts/mariadb-archive-table.sql") + .build(); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/MariaDbJdbcArchivedMessageRepositoryIT.java b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/MariaDbJdbcArchivedMessageRepositoryIT.java new file mode 100644 index 0000000..ff53c09 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/MariaDbJdbcArchivedMessageRepositoryIT.java @@ -0,0 +1,43 @@ +package dev.inditex.scsoutbox.publish.archive.jdbc; + +import javax.sql.DataSource; + +import dev.inditex.scsoutbox.test.ContainerImages; + +import lombok.SneakyThrows; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.mariadb.jdbc.MariaDbDataSource; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.mariadb.MariaDBContainer; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +class MariaDbJdbcArchivedMessageRepositoryIT extends AbstractJdbcArchivedMessageRepositoryTest { + + @Container + public static MariaDBContainer mariaDBContainer = + new MariaDBContainer(DockerImageName.parse(ContainerImages.MARIADB)) + .withInitScript("scripts/mariadb-archive-table.sql"); + + @BeforeAll + static void startContainer() { + mariaDBContainer.start(); + } + + @AfterAll + static void stopContainer() { + mariaDBContainer.stop(); + } + + @Override + @SneakyThrows + public DataSource getDataSource() { + final MariaDbDataSource dataSource = new MariaDbDataSource(); + dataSource.setUrl(mariaDBContainer.getJdbcUrl()); + dataSource.setUser(mariaDBContainer.getUsername()); + dataSource.setPassword(mariaDBContainer.getPassword()); + return dataSource; + } +} diff --git a/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/PostgreSqlJdbcArchivedMessageRepositoryIT.java b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/PostgreSqlJdbcArchivedMessageRepositoryIT.java new file mode 100644 index 0000000..8942a53 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/PostgreSqlJdbcArchivedMessageRepositoryIT.java @@ -0,0 +1,41 @@ +package dev.inditex.scsoutbox.publish.archive.jdbc; + +import javax.sql.DataSource; + +import dev.inditex.scsoutbox.test.ContainerImages; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.postgresql.ds.PGSimpleDataSource; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +class PostgreSqlJdbcArchivedMessageRepositoryIT extends AbstractJdbcArchivedMessageRepositoryTest { + + @Container + public static PostgreSQLContainer postgreSqlContainer = + new PostgreSQLContainer(DockerImageName.parse(ContainerImages.POSTGRESQL)) + .withInitScript("scripts/postgresql-archive-table.sql"); + + @BeforeAll + static void startContainer() { + postgreSqlContainer.start(); + } + + @AfterAll + static void stopContainer() { + postgreSqlContainer.stop(); + } + + @Override + public DataSource getDataSource() { + final PGSimpleDataSource dataSource = new PGSimpleDataSource(); + dataSource.setUrl(postgreSqlContainer.getJdbcUrl()); + dataSource.setUser(postgreSqlContainer.getUsername()); + dataSource.setPassword(postgreSqlContainer.getPassword()); + return dataSource; + } +} diff --git a/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/config/JdbcArchiveAutoConfigurationTest.java b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/config/JdbcArchiveAutoConfigurationTest.java new file mode 100644 index 0000000..275783a --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/config/JdbcArchiveAutoConfigurationTest.java @@ -0,0 +1,103 @@ +package dev.inditex.scsoutbox.publish.archive.jdbc.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import javax.sql.DataSource; + +import dev.inditex.scsoutbox.jdbc.DataSourceMetadata; +import dev.inditex.scsoutbox.jdbc.OutboxDataSourceProvider; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessageSerializer; +import dev.inditex.scsoutbox.publish.archive.config.ArchiveAutoConfiguration; +import dev.inditex.scsoutbox.publish.archive.jdbc.JdbcArchivedMessageRepository; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; + +class JdbcArchiveAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(JdbcArchiveAutoConfiguration.class)) + .withPropertyValues("scs-outbox.publishing.archive.jdbc.schema=PUBLIC"); + + private ApplicationContextRunner contextRunnerWithDependencies() { + final OutboxDataSourceProvider provider = mock(OutboxDataSourceProvider.class); + when(provider.getDedicatedForPublishing()).thenReturn(createTestDataSource()); + when(provider.getDedicatedForPublishingDataSourceMetadata()).thenReturn(mock(DataSourceMetadata.class)); + return this.contextRunner + .withUserConfiguration(ArchiveAutoConfigurationPresence.class) + .withBean(OutboxDataSourceProvider.class, () -> provider) + .withBean(ArchivedMessageSerializer.class, () -> mock(ArchivedMessageSerializer.class)); + } + + @Nested + class ArchivedMessageRepository { + + @Test + void when_archive_auto_configuration_present_expect_archived_message_repository_created() { + JdbcArchiveAutoConfigurationTest.this.contextRunnerWithDependencies() + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(dev.inditex.scsoutbox.publish.archive.ArchivedMessageRepository.class); + assertThat(context.getBean(dev.inditex.scsoutbox.publish.archive.ArchivedMessageRepository.class)) + .isInstanceOf(JdbcArchivedMessageRepository.class); + }); + } + + @Test + void when_archive_auto_configuration_absent_expect_auto_configuration_skipped() { + JdbcArchiveAutoConfigurationTest.this.contextRunner + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(dev.inditex.scsoutbox.publish.archive.ArchivedMessageRepository.class); + }); + } + + @Test + void when_datasource_provider_missing_expect_context_fails() { + JdbcArchiveAutoConfigurationTest.this.contextRunner + .withUserConfiguration(ArchiveAutoConfigurationPresence.class) + .withBean(ArchivedMessageSerializer.class, () -> mock(ArchivedMessageSerializer.class)) + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void when_archived_message_serializer_missing_expect_context_fails() { + final OutboxDataSourceProvider provider = mock(OutboxDataSourceProvider.class); + when(provider.getDedicatedForPublishing()).thenReturn(createTestDataSource()); + when(provider.getDedicatedForPublishingDataSourceMetadata()).thenReturn(mock(DataSourceMetadata.class)); + JdbcArchiveAutoConfigurationTest.this.contextRunner + .withUserConfiguration(ArchiveAutoConfigurationPresence.class) + .withBean(OutboxDataSourceProvider.class, () -> provider) + .run(context -> assertThat(context).hasFailed()); + } + } + + /** + * Helper configuration that satisfies {@code @ConditionalOnBean(ArchiveAutoConfiguration.class)} without activating + * {@link ArchiveAutoConfiguration}'s own {@code @Bean} methods. Spring skips reprocessing factory-method beans as configuration + * candidates ({@code factoryMethodName != null}), so no transitive dependencies are required. + */ + @Configuration(proxyBeanMethods = false) + static class ArchiveAutoConfigurationPresence { + + @Bean + ArchiveAutoConfiguration archiveAutoConfiguration() { + return mock(ArchiveAutoConfiguration.class); + } + } + + private static DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setType(EmbeddedDatabaseType.H2) + .generateUniqueName(true) + .build(); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/config/JdbcArchivePropertiesTest.java b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/config/JdbcArchivePropertiesTest.java new file mode 100644 index 0000000..51df8f3 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/java/dev/inditex/scsoutbox/publish/archive/jdbc/config/JdbcArchivePropertiesTest.java @@ -0,0 +1,117 @@ +package dev.inditex.scsoutbox.publish.archive.jdbc.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; + +class JdbcArchivePropertiesTest { + + public static final String DEFAULT_TABLE_NAME = "SCS_OUTBOX_ARCHIVE"; + + public static final String DEFAULT_SCHEMA = ""; + + @Test + void default_values() { + assertEquals(DEFAULT_TABLE_NAME, new JdbcArchiveProperties().getTableName()); + assertEquals(DEFAULT_SCHEMA, new JdbcArchiveProperties().getSchema()); + assertEquals(DEFAULT_TABLE_NAME, new JdbcArchiveProperties(null, null).getTableName()); + assertEquals(DEFAULT_SCHEMA, new JdbcArchiveProperties(null, null).getSchema()); + assertEquals(DEFAULT_TABLE_NAME, new JdbcArchiveProperties("", "").getTableName()); + assertEquals(DEFAULT_SCHEMA, new JdbcArchiveProperties("", "").getSchema()); + } + + @Test + void with_invalid_table_name() { + assertThrows(IllegalArgumentException.class, + () -> new JdbcArchiveProperties("VALUE WITH SPACES", null)); + } + + @Test + void with_invalid_schema_name() { + assertThrows(IllegalArgumentException.class, + () -> new JdbcArchiveProperties(null, "SCHEMA WITH SPACES")); + } + + @Test + void with_table_name() { + assertEquals("SCS_OUTBOX_TEST", new JdbcArchiveProperties("SCS_OUTBOX_TEST", null).getTableName()); + } + + @Test + void with_schema_name() { + assertEquals("TEST_SCHEMA", new JdbcArchiveProperties(null, "TEST_SCHEMA").getSchema()); + } + + @Test + void with_full_table_name() { + final JdbcArchiveProperties props = new JdbcArchiveProperties("SCS_OUTBOX_TEST", "TEST_SCHEMA"); + assertEquals("SCS_OUTBOX_TEST", props.getTableName()); + assertEquals("TEST_SCHEMA", props.getSchema()); + } + + @Test + void with_full_table_name_no_schema() { + JdbcArchiveProperties props = new JdbcArchiveProperties("SCS_OUTBOX_TEST", ""); + assertEquals("SCS_OUTBOX_TEST", props.getTableName()); + assertEquals("", props.getSchema()); + + props = new JdbcArchiveProperties("SCS_OUTBOX_TEST", null); + assertEquals("SCS_OUTBOX_TEST", props.getTableName()); + assertEquals("", props.getSchema()); + } + + @Nested + @SpringBootTest(classes = {JdbcArchivePropertiesTest.class}) + @EnableConfigurationProperties(JdbcArchiveProperties.class) + class SpringBootTestWithoutProperties { + @Autowired + private JdbcArchiveProperties properties; + + @Test + void default_values() { + assertEquals(DEFAULT_TABLE_NAME, this.properties.getTableName()); + assertEquals(DEFAULT_SCHEMA, this.properties.getSchema()); + } + } + + @Nested + @SpringBootTest(classes = {JdbcArchivePropertiesTest.class}, + properties = { + "scs-outbox.publishing.archive.jdbc.table-name=SCS_OUTBOX_ARCHIVE_TEST", + "scs-outbox.publishing.archive.jdbc.schema=TEST_SCHEMA" + }) + @EnableConfigurationProperties(JdbcArchiveProperties.class) + class SpringBootTestWithProperties { + @Autowired + private JdbcArchiveProperties properties; + + @Test + void property_values() { + assertEquals("SCS_OUTBOX_ARCHIVE_TEST", this.properties.getTableName()); + assertEquals("TEST_SCHEMA", this.properties.getSchema()); + } + } + + @Nested + @SpringBootTest(classes = {JdbcArchivePropertiesTest.class}, + properties = { + "scs-outbox.publishing.archive.jdbc.table-name=SCS_OUTBOX_ARCHIVE_TEST" + }) + @EnableConfigurationProperties(JdbcArchiveProperties.class) + class SpringBootTestWithTableNameOnly { + @Autowired + private JdbcArchiveProperties properties; + + @Test + void property_values() { + assertEquals("SCS_OUTBOX_ARCHIVE_TEST", this.properties.getTableName()); + assertEquals(DEFAULT_SCHEMA, this.properties.getSchema()); + } + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/resources/scripts/mariadb-archive-table.sql b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/resources/scripts/mariadb-archive-table.sql new file mode 100644 index 0000000..2f895f5 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/resources/scripts/mariadb-archive-table.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS + SCS_OUTBOX_ARCHIVE + ( + ID varchar(36) not null, + ARCHIVED_AT timestamp not null, + CAPTURED_AT timestamp not null, + DESTINATION varchar(256) not null, + CONTENT_TYPE varchar(256) not null, + HEADERS text not null, + PAYLOAD blob not null, + SERIALIZATION varchar(256) not null, + JSON_PAYLOAD text, + constraint PK_OUTBOX_ARCHIVE primary key (ID) +); \ No newline at end of file diff --git a/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/resources/scripts/postgresql-archive-table.sql b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/resources/scripts/postgresql-archive-table.sql new file mode 100644 index 0000000..240bbdc --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-jdbc/src/test/resources/scripts/postgresql-archive-table.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS + SCS_OUTBOX_ARCHIVE + ( + ID varchar(36) not null, + ARCHIVED_AT timestamptz not null, + CAPTURED_AT timestamptz not null, + DESTINATION varchar(256) not null, + CONTENT_TYPE varchar(256) not null, + HEADERS text not null, + PAYLOAD bytea not null, + SERIALIZATION varchar(256) not null, + JSON_PAYLOAD jsonb, + constraint PK_OUTBOX_ARCHIVE primary key (ID) +); \ No newline at end of file diff --git a/code/scs-outbox-libs/scs-outbox-archive-mongodb/pom.xml b/code/scs-outbox-libs/scs-outbox-archive-mongodb/pom.xml new file mode 100644 index 0000000..d937d24 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-mongodb/pom.xml @@ -0,0 +1,80 @@ + + + 4.0.0 + + dev.inditex.scsoutbox + scs-outbox + 1.0.0-SNAPSHOT + ../../pom.xml + + + scs-outbox-archive-mongodb + + + + + + dev.inditex.scsoutbox + scs-outbox-archive + + + dev.inditex.scsoutbox + scs-outbox-mongodb + + + dev.inditex.scsoutbox + scs-outbox-serialization + + + org.springframework.data + spring-data-mongodb + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + dev.inditex.scsoutbox + scs-outbox-test-support + test + + + + org.junit.jupiter + junit-jupiter + test + + + org.testcontainers + testcontainers + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + org.testcontainers + testcontainers-mongodb + test + + + org.mongodb + mongodb-driver-sync + test + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-data-mongodb + test + + + diff --git a/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/java/dev/inditex/scsoutbox/publish/archive/mongodb/ArchivedMessageDocument.java b/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/java/dev/inditex/scsoutbox/publish/archive/mongodb/ArchivedMessageDocument.java new file mode 100644 index 0000000..60f4600 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/java/dev/inditex/scsoutbox/publish/archive/mongodb/ArchivedMessageDocument.java @@ -0,0 +1,50 @@ +package dev.inditex.scsoutbox.publish.archive.mongodb; + +import java.time.Instant; + +import com.mongodb.DBObject; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +@Document("OUTBOX") +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Builder +@ToString +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class ArchivedMessageDocument { + + @EqualsAndHashCode.Include + @Id + @NonNull + private final String id; + + @NonNull + private final String destination; + + @NonNull + private final String contentType; + + private final byte @NonNull [] payload; + + @NonNull + private final String headers; + + @NonNull + private final Instant capturedAt; + + @NonNull + private final Instant archivedAt; + + @NonNull + private final String serialization; + + private final DBObject jsonPayload; +} diff --git a/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/java/dev/inditex/scsoutbox/publish/archive/mongodb/MongoDbArchivedMessageRepository.java b/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/java/dev/inditex/scsoutbox/publish/archive/mongodb/MongoDbArchivedMessageRepository.java new file mode 100644 index 0000000..510a436 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/java/dev/inditex/scsoutbox/publish/archive/mongodb/MongoDbArchivedMessageRepository.java @@ -0,0 +1,42 @@ +package dev.inditex.scsoutbox.publish.archive.mongodb; + +import dev.inditex.scsoutbox.publish.archive.ArchivedMessage; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessageRepository; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessageSerializer; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessageSerializer.SerializedArchivedMessage; +import dev.inditex.scsoutbox.publish.archive.mongodb.config.MongoDbArchiveProperties; + +import com.mongodb.BasicDBObject; +import lombok.RequiredArgsConstructor; +import org.springframework.data.mongodb.core.MongoTemplate; + +@RequiredArgsConstructor +public class MongoDbArchivedMessageRepository implements ArchivedMessageRepository { + + private final MongoTemplate mongoTemplate; + + private final ArchivedMessageSerializer serializer; + + private final MongoDbArchiveProperties mongoDbProperties; + + @Override + public void save(final ArchivedMessage archivedMessage) { + this.mongoTemplate.insert(this.map(archivedMessage), this.mongoDbProperties.getCollectionName()); + } + + private ArchivedMessageDocument map(final ArchivedMessage archivedMessage) { + final SerializedArchivedMessage serialized = this.serializer.serialize(archivedMessage); + return ArchivedMessageDocument.builder() + .id(serialized.getId().toString()) + .archivedAt(serialized.getArchivedAt()) + .capturedAt(serialized.getCapturedAt()) + .destination(serialized.getDestination()) + .contentType(serialized.getContentType()) + .headers(serialized.getHeaders()) + .payload(serialized.getPayload()) + .serialization(serialized.getSerialization()) + .jsonPayload( + serialized.getJsonPayload() != null ? BasicDBObject.parse(serialized.getJsonPayload()) : null) + .build(); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/java/dev/inditex/scsoutbox/publish/archive/mongodb/config/MongoDbArchiveAutoConfiguration.java b/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/java/dev/inditex/scsoutbox/publish/archive/mongodb/config/MongoDbArchiveAutoConfiguration.java new file mode 100644 index 0000000..7f56eb2 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/java/dev/inditex/scsoutbox/publish/archive/mongodb/config/MongoDbArchiveAutoConfiguration.java @@ -0,0 +1,47 @@ +package dev.inditex.scsoutbox.publish.archive.mongodb.config; + +import dev.inditex.scsoutbox.mongodb.OutboxMongoTemplateProvider; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessageRepository; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessageSerializer; +import dev.inditex.scsoutbox.publish.archive.config.ArchiveAutoConfiguration; +import dev.inditex.scsoutbox.publish.archive.mongodb.MongoDbArchivedMessageRepository; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.data.mongodb.core.MongoTemplate; + +/** + * Auto-configuration for MongoDB-based archived message repository. Archive operations always use the publishing MongoTemplate since + * archiving happens during publishing tasks. + */ +@Slf4j +@ConditionalOnBean(ArchiveAutoConfiguration.class) +@AutoConfiguration +@EnableConfigurationProperties(MongoDbArchiveProperties.class) +public class MongoDbArchiveAutoConfiguration { + + /** + * Creates the archived message repository using the publishing MongoTemplate from the provider. Archive operations always use the + * publishing MongoTemplate since archiving happens during publishing tasks. + * + * @param templateProvider the provider that coordinates MongoTemplate usage + * @param archivedMessageSerializer the serializer for archived messages + * @param properties archive configuration properties + * @return the archived message repository instance + */ + @Bean + public ArchivedMessageRepository archivedMessageRepository( + final OutboxMongoTemplateProvider templateProvider, + final ArchivedMessageSerializer archivedMessageSerializer, + final MongoDbArchiveProperties properties) { + + final MongoTemplate mongoTemplate = templateProvider.getDedicatedForPublishing(); + + log.info("Creating archived message repository"); + + return new MongoDbArchivedMessageRepository(mongoTemplate, archivedMessageSerializer, properties); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/java/dev/inditex/scsoutbox/publish/archive/mongodb/config/MongoDbArchiveProperties.java b/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/java/dev/inditex/scsoutbox/publish/archive/mongodb/config/MongoDbArchiveProperties.java new file mode 100644 index 0000000..8b42838 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/java/dev/inditex/scsoutbox/publish/archive/mongodb/config/MongoDbArchiveProperties.java @@ -0,0 +1,30 @@ +package dev.inditex.scsoutbox.publish.archive.mongodb.config; + +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +@ConfigurationProperties("scs-outbox.publishing.archive.mongodb") +public class MongoDbArchiveProperties { + + private static final String DEFAULT_COLLECTION_NAME = "SCS_OUTBOX_ARCHIVE"; + + @Getter + private final String collectionName; + + public MongoDbArchiveProperties() { + this.collectionName = DEFAULT_COLLECTION_NAME; + } + + @ConstructorBinding + public MongoDbArchiveProperties(final String collectionName) { + if (collectionName == null || collectionName.isEmpty()) { + this.collectionName = DEFAULT_COLLECTION_NAME; + } else if (collectionName.contains(" ")) { + throw new IllegalArgumentException("Collection name cannot contain spaces"); + } else { + this.collectionName = collectionName; + } + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..a46dd3c --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +dev.inditex.scsoutbox.publish.archive.mongodb.config.MongoDbArchiveAutoConfiguration \ No newline at end of file diff --git a/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/test/java/dev/inditex/scsoutbox/publish/archive/mongodb/MongoDbArchivedMessageRepositoryIT.java b/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/test/java/dev/inditex/scsoutbox/publish/archive/mongodb/MongoDbArchivedMessageRepositoryIT.java new file mode 100644 index 0000000..51e477c --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/test/java/dev/inditex/scsoutbox/publish/archive/mongodb/MongoDbArchivedMessageRepositoryIT.java @@ -0,0 +1,98 @@ +package dev.inditex.scsoutbox.publish.archive.mongodb; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import java.util.UUID; + +import dev.inditex.scsoutbox.publish.archive.ArchivedMessage; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessageSerializer; +import dev.inditex.scsoutbox.publish.archive.mongodb.config.MongoDbArchiveProperties; +import dev.inditex.scsoutbox.serialization.JavaSerialization; +import dev.inditex.scsoutbox.serialization.JsonHeadersMapper; +import dev.inditex.scsoutbox.test.ContainerImages; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import org.bson.UuidRepresentation; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.mongodb.MongoDatabaseFactory; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.mongodb.MongoDBContainer; + +@Testcontainers +class MongoDbArchivedMessageRepositoryIT { + @Container + public MongoDBContainer mongoDBContainer = + new MongoDBContainer(ContainerImages.MONGO); + + private MongoDbArchivedMessageRepository repository; + + private MongoTemplate mongoTemplate; + + private MongoDbArchiveProperties mongoDbProperties; + + private MongoClient mongoClient; + + @BeforeEach + void setup() { + this.mongoDBContainer.start(); + final String databaseName = "default"; + final String uri = this.mongoDBContainer.getConnectionString() + "/" + databaseName; + final ConnectionString connectionString = new ConnectionString(uri); + final MongoClientSettings settings = MongoClientSettings.builder() + .uuidRepresentation(UuidRepresentation.STANDARD) + .applyConnectionString(connectionString) + .build(); + this.mongoClient = MongoClients.create(settings); + final MongoDatabaseFactory factory = new SimpleMongoClientDatabaseFactory( + this.mongoClient, connectionString.getDatabase()); + this.mongoTemplate = new MongoTemplate(factory); + this.mongoDbProperties = new MongoDbArchiveProperties(null); + this.repository = new MongoDbArchivedMessageRepository( + this.mongoTemplate, new ArchivedMessageSerializer(new JavaSerialization(), new JsonHeadersMapper()), this.mongoDbProperties); + } + + @AfterEach + void stopContainer() { + this.mongoDBContainer.stop(); + } + + @AfterEach + void closeMongoClient() { + this.mongoClient.close(); + } + + @Test + void save() { + final ArchivedMessage archivedMessage = anArchivedMessage(); + this.repository.save(archivedMessage); + final ArchivedMessageDocument found = + this.mongoTemplate.findById( + archivedMessage.getId().toString(), + ArchivedMessageDocument.class, + this.mongoDbProperties.getCollectionName()); + assertNotNull(found); + } + + public static ArchivedMessage anArchivedMessage() { + return ArchivedMessage.builder() + .id(UUID.randomUUID()) + .archivedAt(Instant.now()) + .capturedAt(Instant.now().minus(1, ChronoUnit.MINUTES)) + .destination("destination") + .contentType("application/json") + .payload("payload") + .headers(Map.of()) + .build(); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/test/java/dev/inditex/scsoutbox/publish/archive/mongodb/config/MongoDbArchiveAutoConfigurationIT.java b/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/test/java/dev/inditex/scsoutbox/publish/archive/mongodb/config/MongoDbArchiveAutoConfigurationIT.java new file mode 100644 index 0000000..e5a06ce --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/test/java/dev/inditex/scsoutbox/publish/archive/mongodb/config/MongoDbArchiveAutoConfigurationIT.java @@ -0,0 +1,79 @@ +package dev.inditex.scsoutbox.publish.archive.mongodb.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import dev.inditex.scsoutbox.mongodb.OutboxMongoTemplateProvider; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessageRepository; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessageSerializer; +import dev.inditex.scsoutbox.publish.archive.config.ArchiveAutoConfiguration; +import dev.inditex.scsoutbox.publish.archive.mongodb.MongoDbArchivedMessageRepository; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.core.MongoTemplate; + +class MongoDbArchiveAutoConfigurationIT { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(MongoDbArchiveAutoConfiguration.class)); + + private ApplicationContextRunner contextRunnerWithDependencies() { + final OutboxMongoTemplateProvider templateProvider = mock(OutboxMongoTemplateProvider.class); + when(templateProvider.getDedicatedForPublishing()).thenReturn(mock(MongoTemplate.class)); + return this.contextRunner + .withUserConfiguration(ArchiveAutoConfigurationPresence.class) + .withBean(OutboxMongoTemplateProvider.class, () -> templateProvider) + .withBean(ArchivedMessageSerializer.class, () -> mock(ArchivedMessageSerializer.class)); + } + + @Nested + class ArchivedMessageRepositoryBean { + + @Test + void when_archive_auto_configuration_present_expect_mongo_repository_created() { + MongoDbArchiveAutoConfigurationIT.this.contextRunnerWithDependencies() + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(ArchivedMessageRepository.class); + assertThat(context.getBean(ArchivedMessageRepository.class)) + .isInstanceOf(MongoDbArchivedMessageRepository.class); + }); + } + + @Test + void when_archive_auto_configuration_absent_expect_autoconfiguration_skipped() { + MongoDbArchiveAutoConfigurationIT.this.contextRunner + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(ArchivedMessageRepository.class); + }); + } + + @Test + void when_template_provider_missing_expect_context_fails() { + MongoDbArchiveAutoConfigurationIT.this.contextRunner + .withUserConfiguration(ArchiveAutoConfigurationPresence.class) + .withBean(ArchivedMessageSerializer.class, () -> mock(ArchivedMessageSerializer.class)) + .run(context -> assertThat(context).hasFailed()); + } + } + + /** + * Helper configuration that satisfies {@code @ConditionalOnBean(ArchiveAutoConfiguration.class)} without activating + * {@link ArchiveAutoConfiguration}'s own {@code @Bean} methods. + */ + @Configuration(proxyBeanMethods = false) + static class ArchiveAutoConfigurationPresence { + + @Bean + ArchiveAutoConfiguration archiveAutoConfiguration() { + return mock(ArchiveAutoConfiguration.class); + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/test/java/dev/inditex/scsoutbox/publish/archive/mongodb/config/MongoDbArchivePropertiesTest.java b/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/test/java/dev/inditex/scsoutbox/publish/archive/mongodb/config/MongoDbArchivePropertiesTest.java new file mode 100644 index 0000000..97c9cf0 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive-mongodb/src/test/java/dev/inditex/scsoutbox/publish/archive/mongodb/config/MongoDbArchivePropertiesTest.java @@ -0,0 +1,64 @@ +package dev.inditex.scsoutbox.publish.archive.mongodb.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; + +class MongoDbArchivePropertiesTest { + + public static final String DEFAULT_COLLECTION_NAME = "SCS_OUTBOX_ARCHIVE"; + + @Test + void default_values() { + assertEquals(DEFAULT_COLLECTION_NAME, new MongoDbArchiveProperties().getCollectionName()); + assertEquals(DEFAULT_COLLECTION_NAME, new MongoDbArchiveProperties(null).getCollectionName()); + assertEquals(DEFAULT_COLLECTION_NAME, new MongoDbArchiveProperties("").getCollectionName()); + } + + @Test + void with_invalid_collection_name() { + assertThrows(IllegalArgumentException.class, + () -> new MongoDbArchiveProperties("VALUE WITH SPACES")); + } + + @Test + void with_collection_name() { + assertEquals("SCS_OUTBOX_ARCHIVE_TEST", + new MongoDbArchiveProperties("SCS_OUTBOX_ARCHIVE_TEST").getCollectionName()); + } + + @Nested + @SpringBootTest(classes = {MongoDbArchivePropertiesTest.class}) + @EnableConfigurationProperties(MongoDbArchiveProperties.class) + class SpringBootTestWithoutProperties { + @Autowired + private MongoDbArchiveProperties properties; + + @Test + void default_values() { + assertEquals(DEFAULT_COLLECTION_NAME, this.properties.getCollectionName()); + } + } + + @Nested + @SpringBootTest(classes = {MongoDbArchivePropertiesTest.class}, + properties = { + "scs-outbox.publishing.archive.mongodb.collection-name=SCS_OUTBOX_ARCHIVE_TEST", + }) + @EnableConfigurationProperties(MongoDbArchiveProperties.class) + class SpringBootTestWithProperties { + @Autowired + private MongoDbArchiveProperties properties; + + @Test + void property_values() { + assertEquals("SCS_OUTBOX_ARCHIVE_TEST", this.properties.getCollectionName()); + } + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/pom.xml b/code/scs-outbox-libs/scs-outbox-archive/pom.xml new file mode 100644 index 0000000..234df1f --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + dev.inditex.scsoutbox + scs-outbox-libs + 1.0.0-SNAPSHOT + + + scs-outbox-archive + + + + + + dev.inditex.scsoutbox + scs-outbox-core + + + dev.inditex.scsoutbox + scs-outbox-serialization + + + org.apache.avro + avro + + + tools.jackson.core + jackson-databind + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + ch.qos.logback + logback-classic + + + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchiveOutboxMessagePublisherInterceptor.java b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchiveOutboxMessagePublisherInterceptor.java new file mode 100644 index 0000000..55304ae --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchiveOutboxMessagePublisherInterceptor.java @@ -0,0 +1,17 @@ +package dev.inditex.scsoutbox.publish.archive; + +import dev.inditex.scsoutbox.OutboxMessage; +import dev.inditex.scsoutbox.publish.OutboxMessagePublisherInterceptor; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class ArchiveOutboxMessagePublisherInterceptor implements OutboxMessagePublisherInterceptor { + + private final ArchiveService archiveService; + + @Override + public void postSend(final OutboxMessage outboxMessage) { + this.archiveService.archive(outboxMessage); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchiveService.java b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchiveService.java new file mode 100644 index 0000000..d893865 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchiveService.java @@ -0,0 +1,68 @@ +package dev.inditex.scsoutbox.publish.archive; + +import java.time.Instant; +import java.util.Objects; + +import dev.inditex.scsoutbox.OutboxMessage; +import dev.inditex.scsoutbox.publish.archive.config.ArchiveProperties; +import dev.inditex.scsoutbox.publish.archive.json.JsonMapper; +import dev.inditex.scsoutbox.serialization.OutboxMessageReconverter; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.messaging.MessageHeaders; + +@Slf4j +@RequiredArgsConstructor +public class ArchiveService { + + private final ArchivedMessageRepository repository; + + private final BindingServiceProperties bindingServiceProperties; + + @SuppressWarnings("rawtypes") + private final JsonMapper jsonMapper; + + private final ArchiveProperties properties; + + private final OutboxMessageReconverter reconverter; + + public void archive(final OutboxMessage outboxMessage) { + final ArchivedMessage archivedMessage = ArchivedMessage.builder() + .id(outboxMessage.getId()) + .archivedAt(Instant.now()) + .capturedAt(outboxMessage.getCapturedAt()) + .destination(outboxMessage.getDestination()) + .contentType(this.resolveContentType(outboxMessage)) + .headers(outboxMessage.getHeaders()) + .payload(outboxMessage.getPayload()) + .jsonPayload(this.generateJsonPayload(outboxMessage)) + .build(); + this.repository.save(archivedMessage); + } + + private String resolveContentType(OutboxMessage outboxMessage) { + return Objects.requireNonNullElse( + outboxMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE), + this.bindingServiceProperties.getBindingProperties(outboxMessage.getBindingName()).getContentType()).toString(); + } + + @SuppressWarnings("unchecked") + private String generateJsonPayload(final OutboxMessage outboxMessage) { + String jsonPayload = null; + Object payload = outboxMessage.getPayload(); + if (this.properties.isJsonPayloadEnabled()) { + if (payload instanceof byte[]) { + // need reconvert payload because payload has been passed by converter chain + payload = this.reconverter.reconvertPayload(outboxMessage); + } + try { + jsonPayload = this.jsonMapper.writeValueAsString(payload); + } catch (final Exception e) { + log.warn("Unexpected error mapping payload to json format", e); + } + } + return jsonPayload; + } +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchivedMessage.java b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchivedMessage.java new file mode 100755 index 0000000..4898b2d --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchivedMessage.java @@ -0,0 +1,43 @@ +package dev.inditex.scsoutbox.publish.archive; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NonNull; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class ArchivedMessage { + + @EqualsAndHashCode.Include + @NonNull + private final UUID id; + + @NonNull + private final String destination; + + @NonNull + private final Object payload; + + @NonNull + private final Map headers; + + @NonNull + private final Instant capturedAt; + + @NonNull + private final String contentType; + + @NonNull + private Instant archivedAt; + + private String jsonPayload; +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchivedMessageRepository.java b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchivedMessageRepository.java new file mode 100644 index 0000000..bc776c0 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchivedMessageRepository.java @@ -0,0 +1,7 @@ +package dev.inditex.scsoutbox.publish.archive; + +public interface ArchivedMessageRepository { + + void save(final ArchivedMessage archivedMessage); + +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchivedMessageSerializer.java b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchivedMessageSerializer.java new file mode 100644 index 0000000..09b7cf9 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/ArchivedMessageSerializer.java @@ -0,0 +1,88 @@ +package dev.inditex.scsoutbox.publish.archive; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import dev.inditex.scsoutbox.serialization.HeadersMapper; +import dev.inditex.scsoutbox.serialization.SerializationEngine; + +import lombok.Builder; +import lombok.RequiredArgsConstructor; +import lombok.Value; + +@RequiredArgsConstructor +public class ArchivedMessageSerializer { + + public static final String NONE = "none"; + + private final SerializationEngine serializationEngine; + + private final HeadersMapper headersMapper; + + public SerializedArchivedMessage serialize(final ArchivedMessage archivedMessage) { + final boolean isRaw = archivedMessage.getPayload() instanceof byte[]; + return SerializedArchivedMessage.builder() + .id(archivedMessage.getId()) + .destination(archivedMessage.getDestination()) + .contentType(archivedMessage.getContentType()) + .payload(this.resolvePayloadForStorage(archivedMessage.getPayload())) + .headers(this.headersMapper.write(archivedMessage.getHeaders())) + .capturedAt(archivedMessage.getCapturedAt()) + .archivedAt(archivedMessage.getArchivedAt()) + .jsonPayload(archivedMessage.getJsonPayload()) + .serialization(isRaw ? NONE : this.serializationEngine.getClass().getName()) + .build(); + } + + public ArchivedMessage deserialize(final SerializedArchivedMessage serialized) { + final Map headers = this.headersMapper.read(serialized.getHeaders()); + final Object payload = NONE.equals(serialized.getSerialization()) + ? serialized.getPayload() + : this.serializationEngine.deserialize(serialized.getPayload()); + return ArchivedMessage.builder() + .id(serialized.getId()) + .destination(serialized.getDestination()) + .contentType(serialized.getContentType()) + .payload(payload) + .headers(headers) + .capturedAt(serialized.getCapturedAt()) + .archivedAt(serialized.getArchivedAt()) + .jsonPayload(serialized.getJsonPayload()) + .build(); + } + + /** + * Resolves the payload for storage. If the payload is already raw bytes (from raw passthrough mode), it is stored directly. Otherwise, + * the serialization engine is used to serialize the payload. + */ + private byte[] resolvePayloadForStorage(final Object payload) { + if (payload instanceof byte[] raw) { + return raw; + } + return this.serializationEngine.serialize(payload); + } + + @Builder + @Value + public static class SerializedArchivedMessage { + + UUID id; + + String destination; + + String contentType; + + byte[] payload; + + String headers; + + Instant capturedAt; + + Instant archivedAt; + + String jsonPayload; + + String serialization; + } +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/config/ArchiveAutoConfiguration.java b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/config/ArchiveAutoConfiguration.java new file mode 100755 index 0000000..65662d5 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/config/ArchiveAutoConfiguration.java @@ -0,0 +1,66 @@ +package dev.inditex.scsoutbox.publish.archive.config; + +import java.util.List; + +import dev.inditex.scsoutbox.config.OutboxAutoConfiguration; +import dev.inditex.scsoutbox.publish.archive.ArchiveOutboxMessagePublisherInterceptor; +import dev.inditex.scsoutbox.publish.archive.ArchiveService; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessageRepository; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessageSerializer; +import dev.inditex.scsoutbox.publish.archive.json.AvroToJsonMapper; +import dev.inditex.scsoutbox.publish.archive.json.CompositeJsonMapper; +import dev.inditex.scsoutbox.publish.archive.json.DefaultJsonMapper; +import dev.inditex.scsoutbox.publish.archive.json.JsonMapper; +import dev.inditex.scsoutbox.serialization.HeadersMapper; +import dev.inditex.scsoutbox.serialization.OutboxMessageReconverter; +import dev.inditex.scsoutbox.serialization.SerializationEngine; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.context.annotation.Bean; + +@ConditionalOnProperty(value = "scs-outbox.publishing.archive.enabled", havingValue = "true", matchIfMissing = false) +@AutoConfiguration(after = {OutboxAutoConfiguration.class}) +@EnableConfigurationProperties(ArchiveProperties.class) +public class ArchiveAutoConfiguration { + + @Bean + public ArchivedMessageSerializer archivedMessageSerializer( + final SerializationEngine serializationEngine, + final HeadersMapper headersMapper) { + return new ArchivedMessageSerializer(serializationEngine, headersMapper); + } + + @Bean + private ArchiveOutboxMessagePublisherInterceptor archiveOutboxMessagePublisherInterceptor( + final ArchiveService archiveService) { + return new ArchiveOutboxMessagePublisherInterceptor(archiveService); + } + + @Bean + public ArchiveService archiveService( + final ArchivedMessageRepository archivedMessageRepository, + final BindingServiceProperties bindingServiceProperties, + final CompositeJsonMapper compositeJsonMapper, + final ArchiveProperties properties, + final OutboxMessageReconverter outboxMessageReconverter) { + return new ArchiveService( + archivedMessageRepository, bindingServiceProperties, compositeJsonMapper, properties, outboxMessageReconverter); + } + + @SuppressWarnings("rawtypes") + @Bean + public JsonMapper avroToJsonMapper() { + return new AvroToJsonMapper(); + } + + @Bean + CompositeJsonMapper compositeJsonMapper(final List> mappers) { + return new CompositeJsonMapper( + new DefaultJsonMapper(tools.jackson.databind.json.JsonMapper.builder().build()), + mappers); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/config/ArchiveProperties.java b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/config/ArchiveProperties.java new file mode 100644 index 0000000..5e7e374 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/config/ArchiveProperties.java @@ -0,0 +1,16 @@ +package dev.inditex.scsoutbox.publish.archive.config; + +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("scs-outbox.publishing.archive") +public class ArchiveProperties { + + @Getter + private final boolean jsonPayloadEnabled; + + public ArchiveProperties(final Boolean jsonPayloadEnabled) { + this.jsonPayloadEnabled = jsonPayloadEnabled != null && jsonPayloadEnabled; + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/json/AvroToJsonMapper.java b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/json/AvroToJsonMapper.java new file mode 100644 index 0000000..c15ecad --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/json/AvroToJsonMapper.java @@ -0,0 +1,30 @@ +package dev.inditex.scsoutbox.publish.archive.json; + +import java.io.ByteArrayOutputStream; + +import lombok.SneakyThrows; +import org.apache.avro.io.DatumWriter; +import org.apache.avro.io.EncoderFactory; +import org.apache.avro.io.JsonEncoder; +import org.apache.avro.specific.SpecificDatumWriter; +import org.apache.avro.specific.SpecificRecordBase; + +public class AvroToJsonMapper implements JsonMapper { + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + @SneakyThrows + public String writeValueAsString(final SpecificRecordBase avroMessage) { + try (final ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + final JsonEncoder jsonEncoder = EncoderFactory.get().jsonEncoder( + avroMessage.getSchema(), baos); + final DatumWriter writer = new SpecificDatumWriter<>(avroMessage.getClass()); + + writer.write(avroMessage, jsonEncoder); + jsonEncoder.flush(); + + return baos.toString(); + } + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/json/CompositeJsonMapper.java b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/json/CompositeJsonMapper.java new file mode 100644 index 0000000..b56670f --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/json/CompositeJsonMapper.java @@ -0,0 +1,22 @@ +package dev.inditex.scsoutbox.publish.archive.json; + +import java.util.List; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class CompositeJsonMapper implements JsonMapper { + + private final JsonMapper defaultJsonMapper; + + private final List> mappers; + + @SuppressWarnings({"rawtypes", "unchecked"}) + public String writeValueAsString(final Object value) { + final JsonMapper mapper = this.mappers.stream() + .filter(jsonMapper -> jsonMapper.getValueType().isInstance(value)) + .findFirst() + .orElse(this.defaultJsonMapper); + return mapper.writeValueAsString(mapper.getValueType().cast(value)); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/json/DefaultJsonMapper.java b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/json/DefaultJsonMapper.java new file mode 100644 index 0000000..8a30549 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/json/DefaultJsonMapper.java @@ -0,0 +1,15 @@ +package dev.inditex.scsoutbox.publish.archive.json; + +import lombok.RequiredArgsConstructor; +import tools.jackson.databind.ObjectMapper; + +@RequiredArgsConstructor +public class DefaultJsonMapper implements JsonMapper { + + private final ObjectMapper mapper; + + public String writeValueAsString(final Object value) { + return this.mapper.writeValueAsString(value); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/json/JsonMapper.java b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/json/JsonMapper.java new file mode 100644 index 0000000..92887be --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/main/java/dev/inditex/scsoutbox/publish/archive/json/JsonMapper.java @@ -0,0 +1,14 @@ +package dev.inditex.scsoutbox.publish.archive.json; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; + +public interface JsonMapper { + + String writeValueAsString(T value); + + default Class getValueType() { + final Type[] typeArguments = ((ParameterizedType) this.getClass().getGenericInterfaces()[0]).getActualTypeArguments(); + return (Class) typeArguments[typeArguments.length - 1]; + } +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/code/scs-outbox-libs/scs-outbox-archive/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..229cca4 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +dev.inditex.scsoutbox.publish.archive.config.ArchiveAutoConfiguration \ No newline at end of file diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/ArchiveOutboxMessagePublisherInterceptorTest.java b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/ArchiveOutboxMessagePublisherInterceptorTest.java new file mode 100644 index 0000000..13b20ba --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/ArchiveOutboxMessagePublisherInterceptorTest.java @@ -0,0 +1,35 @@ +package dev.inditex.scsoutbox.publish.archive; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import java.util.UUID; + +import dev.inditex.scsoutbox.OutboxMessage; + +import org.junit.jupiter.api.Test; + +class ArchiveOutboxMessagePublisherInterceptorTest { + + @Test + void delegate_to_archive_service() { + final ArchiveService service = mock(ArchiveService.class); + + final ArchiveOutboxMessagePublisherInterceptor interceptor = + new ArchiveOutboxMessagePublisherInterceptor(service); + final OutboxMessage outboxMessage = OutboxMessage.builder() + .id(UUID.randomUUID()) + .capturedAt(Instant.now().minus(1, ChronoUnit.MINUTES)) + .bindingName("bindingName") + .destination("destination") + .payload("payload") + .headers(Map.of()) + .build(); + interceptor.postSend(outboxMessage); + + verify(service).archive(outboxMessage); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/ArchiveServiceTest.java b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/ArchiveServiceTest.java new file mode 100644 index 0000000..977910e --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/ArchiveServiceTest.java @@ -0,0 +1,192 @@ +package dev.inditex.scsoutbox.publish.archive; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import dev.inditex.scsoutbox.OutboxMessage; +import dev.inditex.scsoutbox.publish.archive.config.ArchiveProperties; +import dev.inditex.scsoutbox.publish.archive.json.JsonMapper; +import dev.inditex.scsoutbox.serialization.OutboxMessageReconverter; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cloud.stream.config.BindingProperties; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.messaging.MessageHeaders; + +@SuppressWarnings({"rawtypes", "unchecked"}) +@ExtendWith(MockitoExtension.class) +class ArchiveServiceTest { + + private ArchiveService archiveService; + + @Mock + private ArchivedMessageRepository repository; + + @Mock + private BindingServiceProperties bindingServiceProperties; + + @Mock + private BindingProperties defaultBindingProperties; + + @Mock + private JsonMapper jsonMapper; + + @Mock + private ArchiveProperties properties; + + @Mock + private OutboxMessageReconverter reconverter; + + @Captor + private ArgumentCaptor archivedMessageCaptor; + + @BeforeEach + void beforeEach() { + when(this.defaultBindingProperties.getContentType()).thenReturn("application/x-binding-content-type"); + when(this.bindingServiceProperties.getBindingProperties(any())).thenReturn(this.defaultBindingProperties); + this.archiveService = new ArchiveService( + this.repository, this.bindingServiceProperties, this.jsonMapper, this.properties, this.reconverter); + } + + @Nested + class Archive { + + @Test + void when_message_is_archived_expect_all_fields_are_mapped() { + final Instant capturedAt = Instant.parse("2026-01-15T10:00:00Z"); + final UUID id = UUID.fromString("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + final Object payload = "test-payload"; + final Map headers = Map.of(MessageHeaders.CONTENT_TYPE, "application/json"); + final OutboxMessage outboxMessage = OutboxMessage.builder() + .id(id) + .capturedAt(capturedAt) + .destination("my-destination") + .bindingName("my-binding") + .payload(payload) + .headers(headers) + .build(); + + ArchiveServiceTest.this.archiveService.archive(outboxMessage); + + verify(ArchiveServiceTest.this.repository).save(ArchiveServiceTest.this.archivedMessageCaptor.capture()); + final ArchivedMessage saved = ArchiveServiceTest.this.archivedMessageCaptor.getValue(); + assertThat(saved.getId()).isEqualTo(id); + assertThat(saved.getCapturedAt()).isEqualTo(capturedAt); + assertThat(saved.getDestination()).isEqualTo("my-destination"); + assertThat(saved.getPayload()).isEqualTo(payload); + assertThat(saved.getHeaders()).isEqualTo(headers); + assertThat(saved.getArchivedAt()).isNotNull(); + assertThat(saved.getJsonPayload()).isNull(); + } + + @Test + void when_content_type_in_headers_expect_header_content_type_used() { + final OutboxMessage outboxMessage = anOutboxMessageBuilder() + .headers(Map.of(MessageHeaders.CONTENT_TYPE, "text/plain")) + .build(); + + ArchiveServiceTest.this.archiveService.archive(outboxMessage); + + verify(ArchiveServiceTest.this.repository).save(ArchiveServiceTest.this.archivedMessageCaptor.capture()); + assertThat(ArchiveServiceTest.this.archivedMessageCaptor.getValue().getContentType()).isEqualTo("text/plain"); + } + + @Test + void when_no_content_type_in_headers_expect_binding_content_type_used() { + final OutboxMessage outboxMessage = anOutboxMessageBuilder().headers(Map.of()).build(); + + ArchiveServiceTest.this.archiveService.archive(outboxMessage); + + verify(ArchiveServiceTest.this.repository).save(ArchiveServiceTest.this.archivedMessageCaptor.capture()); + assertThat(ArchiveServiceTest.this.archivedMessageCaptor.getValue().getContentType()) + .isEqualTo("application/x-binding-content-type"); + } + + @Test + void when_json_payload_disabled_expect_null_json_payload() { + final OutboxMessage outboxMessage = anOutboxMessageBuilder() + .headers(Map.of(MessageHeaders.CONTENT_TYPE, "application/json")) + .build(); + + ArchiveServiceTest.this.archiveService.archive(outboxMessage); + + verify(ArchiveServiceTest.this.repository).save(ArchiveServiceTest.this.archivedMessageCaptor.capture()); + assertThat(ArchiveServiceTest.this.archivedMessageCaptor.getValue().getJsonPayload()).isNull(); + verify(ArchiveServiceTest.this.reconverter, never()).reconvertPayload(any()); + verify(ArchiveServiceTest.this.jsonMapper, never()).writeValueAsString(any()); + } + + @Test + void when_json_payload_enabled_and_object_payload_expect_json_payload_generated() { + when(ArchiveServiceTest.this.properties.isJsonPayloadEnabled()).thenReturn(true); + when(ArchiveServiceTest.this.jsonMapper.writeValueAsString("payload")).thenReturn("{\"value\":\"payload\"}"); + final OutboxMessage outboxMessage = anOutboxMessageBuilder() + .headers(Map.of(MessageHeaders.CONTENT_TYPE, "application/json")) + .build(); + + ArchiveServiceTest.this.archiveService.archive(outboxMessage); + + verify(ArchiveServiceTest.this.repository).save(ArchiveServiceTest.this.archivedMessageCaptor.capture()); + assertThat(ArchiveServiceTest.this.archivedMessageCaptor.getValue().getJsonPayload()).isEqualTo("{\"value\":\"payload\"}"); + verify(ArchiveServiceTest.this.reconverter, never()).reconvertPayload(any()); + } + + @Test + void when_json_payload_enabled_and_bytes_payload_expect_reconverter_invoked_and_result_serialized() { + final byte[] bytes = new byte[]{1, 2, 3}; + final String reconverted = "reconverted-object"; + when(ArchiveServiceTest.this.properties.isJsonPayloadEnabled()).thenReturn(true); + when(ArchiveServiceTest.this.reconverter.reconvertPayload(any())).thenReturn(reconverted); + when(ArchiveServiceTest.this.jsonMapper.writeValueAsString(reconverted)).thenReturn("{\"reconverted\":true}"); + final OutboxMessage outboxMessage = anOutboxMessageBuilder() + .payload(bytes) + .headers(Map.of(MessageHeaders.CONTENT_TYPE, "application/json")) + .build(); + + ArchiveServiceTest.this.archiveService.archive(outboxMessage); + + verify(ArchiveServiceTest.this.reconverter).reconvertPayload(outboxMessage); + verify(ArchiveServiceTest.this.repository).save(ArchiveServiceTest.this.archivedMessageCaptor.capture()); + assertThat(ArchiveServiceTest.this.archivedMessageCaptor.getValue().getJsonPayload()).isEqualTo("{\"reconverted\":true}"); + } + + @Test + void when_json_payload_enabled_and_mapper_throws_expect_null_json_payload() { + when(ArchiveServiceTest.this.properties.isJsonPayloadEnabled()).thenReturn(true); + when(ArchiveServiceTest.this.jsonMapper.writeValueAsString(any())).thenThrow(new RuntimeException("mapping error")); + final OutboxMessage outboxMessage = anOutboxMessageBuilder() + .headers(Map.of(MessageHeaders.CONTENT_TYPE, "application/json")) + .build(); + + ArchiveServiceTest.this.archiveService.archive(outboxMessage); + + verify(ArchiveServiceTest.this.repository).save(ArchiveServiceTest.this.archivedMessageCaptor.capture()); + assertThat(ArchiveServiceTest.this.archivedMessageCaptor.getValue().getJsonPayload()).isNull(); + } + } + + private static OutboxMessage.OutboxMessageBuilder anOutboxMessageBuilder() { + return OutboxMessage.builder() + .id(UUID.fromString("a1b2c3d4-e5f6-7890-abcd-ef1234567890")) + .capturedAt(Instant.parse("2026-01-15T10:00:00Z")) + .destination("destination") + .bindingName("bindingName") + .payload("payload") + .headers(Map.of()); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/ArchivedMessageSerializerTest.java b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/ArchivedMessageSerializerTest.java new file mode 100644 index 0000000..9d08dc7 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/ArchivedMessageSerializerTest.java @@ -0,0 +1,142 @@ +package dev.inditex.scsoutbox.publish.archive; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import dev.inditex.scsoutbox.publish.archive.ArchivedMessageSerializer.SerializedArchivedMessage; +import dev.inditex.scsoutbox.serialization.JavaSerialization; +import dev.inditex.scsoutbox.serialization.JsonHeadersMapper; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ArchivedMessageSerializerTest { + + private final ArchivedMessageSerializer serializer = + new ArchivedMessageSerializer(new JavaSerialization(), new JsonHeadersMapper()); + + @Nested + class Serialize { + + @Test + void when_payload_is_not_bytes_expect_serialization_engine_used() { + final ArchivedMessage message = anArchivedMessage("hello"); + + final SerializedArchivedMessage serialized = ArchivedMessageSerializerTest.this.serializer.serialize(message); + + assertThat(serialized.getPayload()).isNotNull(); + assertThat(serialized.getSerialization()).isEqualTo(JavaSerialization.class.getName()); + } + + @Test + void when_payload_is_byte_array_expect_raw_bytes_stored() { + final byte[] raw = {1, 2, 3}; + final ArchivedMessage message = anArchivedMessage(raw); + + final SerializedArchivedMessage serialized = ArchivedMessageSerializerTest.this.serializer.serialize(message); + + assertThat(serialized.getPayload()).isEqualTo(raw); + assertThat(serialized.getSerialization()).isEqualTo(ArchivedMessageSerializer.NONE); + } + + @Test + void when_serialized_expect_headers_written_as_string() { + final ArchivedMessage message = anArchivedMessage("payload"); + + final SerializedArchivedMessage serialized = ArchivedMessageSerializerTest.this.serializer.serialize(message); + + assertThat(serialized.getHeaders()).isNotBlank(); + } + + @Test + void when_json_payload_present_expect_preserved() { + final ArchivedMessage message = anArchivedMessage("payload"); + + final SerializedArchivedMessage serialized = ArchivedMessageSerializerTest.this.serializer.serialize(message); + + assertThat(serialized.getJsonPayload()).isEqualTo("{\"key\":\"value\"}"); + } + + @Test + void when_json_payload_absent_expect_null_in_serialized_message() { + final ArchivedMessage message = anArchivedMessageWithoutJsonPayload("payload"); + + final SerializedArchivedMessage serialized = ArchivedMessageSerializerTest.this.serializer.serialize(message); + + assertThat(serialized.getJsonPayload()).isNull(); + } + + @Test + void when_serialized_expect_all_metadata_fields_preserved() { + final ArchivedMessage message = anArchivedMessage("payload"); + + final SerializedArchivedMessage serialized = ArchivedMessageSerializerTest.this.serializer.serialize(message); + + assertThat(serialized.getId()).isEqualTo(message.getId()); + assertThat(serialized.getDestination()).isEqualTo(message.getDestination()); + assertThat(serialized.getContentType()).isEqualTo(message.getContentType()); + assertThat(serialized.getCapturedAt()).isEqualTo(message.getCapturedAt()); + assertThat(serialized.getArchivedAt()).isEqualTo(message.getArchivedAt()); + } + } + + @Nested + class Deserialize { + + @Test + void when_round_trip_with_non_bytes_payload_expect_all_fields_preserved() { + final ArchivedMessage message = anArchivedMessage("payload"); + + final SerializedArchivedMessage serialized = ArchivedMessageSerializerTest.this.serializer.serialize(message); + final ArchivedMessage deserialized = ArchivedMessageSerializerTest.this.serializer.deserialize(serialized); + + assertThat(deserialized.getId()).isEqualTo(message.getId()); + assertThat(deserialized.getDestination()).isEqualTo(message.getDestination()); + assertThat(deserialized.getContentType()).isEqualTo(message.getContentType()); + assertThat(deserialized.getPayload()).isEqualTo(message.getPayload()); + assertThat(deserialized.getHeaders()).isEqualTo(message.getHeaders()); + assertThat(deserialized.getCapturedAt()).isEqualTo(message.getCapturedAt()); + assertThat(deserialized.getArchivedAt()).isEqualTo(message.getArchivedAt()); + assertThat(deserialized.getJsonPayload()).isEqualTo(message.getJsonPayload()); + } + + @Test + void when_round_trip_with_byte_array_payload_expect_raw_bytes_preserved() { + final byte[] raw = {10, 20, 30}; + final ArchivedMessage message = anArchivedMessage(raw); + + final SerializedArchivedMessage serialized = ArchivedMessageSerializerTest.this.serializer.serialize(message); + final ArchivedMessage deserialized = ArchivedMessageSerializerTest.this.serializer.deserialize(serialized); + + assertThat((byte[]) deserialized.getPayload()).isEqualTo(raw); + } + } + + private static ArchivedMessage anArchivedMessage(final Object payload) { + return ArchivedMessage.builder() + .id(UUID.randomUUID()) + .destination("test-destination") + .contentType("application/json") + .payload(payload) + .headers(Map.of("header-key", "header-value")) + .capturedAt(Instant.parse("2026-03-23T12:00:00Z")) + .archivedAt(Instant.parse("2026-03-23T12:01:00Z")) + .jsonPayload("{\"key\":\"value\"}") + .build(); + } + + private static ArchivedMessage anArchivedMessageWithoutJsonPayload(final Object payload) { + return ArchivedMessage.builder() + .id(UUID.randomUUID()) + .destination("test-destination") + .contentType("application/json") + .payload(payload) + .headers(Map.of()) + .capturedAt(Instant.parse("2026-03-23T12:00:00Z")) + .archivedAt(Instant.parse("2026-03-23T12:01:00Z")) + .build(); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/config/ArchiveAutoConfigurationTest.java b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/config/ArchiveAutoConfigurationTest.java new file mode 100644 index 0000000..af7565f --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/config/ArchiveAutoConfigurationTest.java @@ -0,0 +1,65 @@ +package dev.inditex.scsoutbox.publish.archive.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import dev.inditex.scsoutbox.publish.archive.ArchiveOutboxMessagePublisherInterceptor; +import dev.inditex.scsoutbox.publish.archive.ArchivedMessageRepository; +import dev.inditex.scsoutbox.serialization.HeadersMapper; +import dev.inditex.scsoutbox.serialization.OutboxMessageReconverter; +import dev.inditex.scsoutbox.serialization.SerializationEngine; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.cloud.stream.config.BindingServiceProperties; + +class ArchiveAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ArchiveAutoConfiguration.class)); + + private ApplicationContextRunner contextRunnerWithDependencies() { + return this.contextRunner + .withPropertyValues("scs-outbox.publishing.archive.enabled=true") + .withBean(SerializationEngine.class, () -> mock(SerializationEngine.class)) + .withBean(HeadersMapper.class, () -> mock(HeadersMapper.class)) + .withBean(ArchivedMessageRepository.class, () -> mock(ArchivedMessageRepository.class)) + .withBean(BindingServiceProperties.class, () -> mock(BindingServiceProperties.class)) + .withBean(OutboxMessageReconverter.class, () -> mock(OutboxMessageReconverter.class)); + } + + @Nested + class ArchiveOutboxMessagePublisherInterceptorBean { + + @Test + void when_archive_enabled_with_dependencies_expect_interceptor_created() { + ArchiveAutoConfigurationTest.this.contextRunnerWithDependencies() + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(ArchiveOutboxMessagePublisherInterceptor.class); + }); + } + + @Test + void when_archive_disabled_expect_autoconfiguration_skipped() { + ArchiveAutoConfigurationTest.this.contextRunner + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(ArchiveOutboxMessagePublisherInterceptor.class); + }); + } + + @Test + void when_archived_message_repository_missing_expect_context_fails() { + ArchiveAutoConfigurationTest.this.contextRunner + .withPropertyValues("scs-outbox.publishing.archive.enabled=true") + .withBean(SerializationEngine.class, () -> mock(SerializationEngine.class)) + .withBean(HeadersMapper.class, () -> mock(HeadersMapper.class)) + .withBean(BindingServiceProperties.class, () -> mock(BindingServiceProperties.class)) + .withBean(OutboxMessageReconverter.class, () -> mock(OutboxMessageReconverter.class)) + .run(context -> assertThat(context).hasFailed()); + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/config/ArchivePropertiesTest.java b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/config/ArchivePropertiesTest.java new file mode 100644 index 0000000..88a3c57 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/config/ArchivePropertiesTest.java @@ -0,0 +1,27 @@ +package dev.inditex.scsoutbox.publish.archive.config; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class ArchivePropertiesTest { + + @Test + void json_payload_default_value() { + final ArchiveProperties archiveProperties = new ArchiveProperties(null); + assertFalse(archiveProperties.isJsonPayloadEnabled()); + } + + @Test + void json_payload_enabled() { + final ArchiveProperties archiveProperties = new ArchiveProperties(true); + assertTrue(archiveProperties.isJsonPayloadEnabled()); + } + + @Test + void json_payload_disabled() { + final ArchiveProperties archiveProperties = new ArchiveProperties(false); + assertFalse(archiveProperties.isJsonPayloadEnabled()); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/AvroToJsonMapperTest.java b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/AvroToJsonMapperTest.java new file mode 100644 index 0000000..af84c07 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/AvroToJsonMapperTest.java @@ -0,0 +1,94 @@ +package dev.inditex.scsoutbox.publish.archive.json; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonIgnore; +import org.apache.avro.specific.SpecificRecordBase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +class AvroToJsonMapperTest { + + private AvroToJsonMapper avroToJsonMapper; + + @BeforeEach + void setUp() { + this.avroToJsonMapper = new AvroToJsonMapper(); + } + + @Test + void map_complex_avro_message() { + final Cycle avroMessage = createComplexAvroMessage(); + + final String json = this.avroToJsonMapper.writeValueAsString(avroMessage); + + assertEquals( + "{\"id\":\"e5e313ab-110e-4890-8da3-7547f119c281\",\"distribution_range_id\":\"e5e313ab-110e-4890-8da3-7547f119c281\",\"week_cycle\":2,\"start_date\":\"2024-04-23T12:08:58.733828203Z\",\"end_date\":\"2024-04-23T12:08:58.733828203Z\",\"arrival_store_date\":\"ArrivalStoreData\",\"closed\":true,\"products\":[{\"distributable_product_id\":\"e5e313ab-110e-4890-8da3-7547f119c281\",\"comment\":{\"string\":\"comment\"},\"assignment_type\":\"assignmentType\",\"published\":true}]}", + json); + } + + private static Cycle createComplexAvroMessage() { + return Cycle.newBuilder() + .setClosed(true) + .setId(UUID.fromString("e5e313ab-110e-4890-8da3-7547f119c281")) + .setProducts(List.of( + Product.newBuilder() + .setComment("comment") + .setPublished(true) + .setAssignmentType("assignmentType") + .setDistributableProductId(UUID.fromString("e5e313ab-110e-4890-8da3-7547f119c281")) + .build())) + .setEndDate("2024-04-23T12:08:58.733828203Z") + .setStartDate("2024-04-23T12:08:58.733828203Z") + .setWeekCycle(2) + .setArrivalStoreDate("ArrivalStoreData") + .setDistributionRangeId(UUID.fromString("e5e313ab-110e-4890-8da3-7547f119c281")) + .build(); + } + + @Disabled("We choose to use the apache avro library to map the payload to json, " + + "the following test is left as a possible alternative using the jackson library") + @Test + void how_do_the_same_with_jackson_lib() { + final BookCreatedMessage avroMessage = BookCreatedMessage.newBuilder() + .setBookId(UUID.fromString("e5e313ab-110e-4890-8da3-7547f119c281")) + .build(); + + final String json = this.avroToJsonMapper.writeValueAsString(avroMessage); + + final ObjectMapper om = tools.jackson.databind.json.JsonMapper.builder() + .addMixIn(SpecificRecordBase.class, JacksonIgnoreAvroProperties.class) + .build(); + final String json2 = om.writeValueAsString(avroMessage); + assertEquals( + json, + json2); + } + + @JsonAutoDetect( + fieldVisibility = JsonAutoDetect.Visibility.ANY, + getterVisibility = JsonAutoDetect.Visibility.NONE, + setterVisibility = JsonAutoDetect.Visibility.NONE, + creatorVisibility = JsonAutoDetect.Visibility.NONE) + abstract class JacksonIgnoreAvroProperties { + + @JsonIgnore + public abstract org.apache.avro.Schema getClassSchema(); + + @JsonIgnore + public abstract org.apache.avro.specific.SpecificData getSpecificData(); + + @JsonIgnore + public abstract Object get(int fieldIndex); + + @JsonIgnore + public abstract org.apache.avro.Schema getSchema(); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/BookCreatedMessage.java b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/BookCreatedMessage.java new file mode 100644 index 0000000..8746cec --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/BookCreatedMessage.java @@ -0,0 +1,321 @@ +/** + * Autogenerated by Avro + * + * DO NOT EDIT DIRECTLY + */ +package dev.inditex.scsoutbox.publish.archive.json; + +import org.apache.avro.message.BinaryMessageDecoder; +import org.apache.avro.message.BinaryMessageEncoder; +import org.apache.avro.message.SchemaStore; +import org.apache.avro.specific.SpecificData; + +/** Event that represents a book created into the system */ +@org.apache.avro.specific.AvroGenerated +public class BookCreatedMessage extends org.apache.avro.specific.SpecificRecordBase implements org.apache.avro.specific.SpecificRecord { + private static final long serialVersionUID = -4459803347852198677L; + + public static final org.apache.avro.Schema SCHEMA$ = new org.apache.avro.Schema.Parser().parse( + "{\"type\":\"record\",\"name\":\"BookCreatedMessage\",\"namespace\":\"dev.inditex.karatelabs.book.events.v1\",\"doc\":\"Event that represents a book created into the system\",\"fields\":[{\"name\":\"book_id\",\"type\":{\"type\":\"string\",\"logicalType\":\"uuid\"}}]}"); + + public static org.apache.avro.Schema getClassSchema() { + return SCHEMA$; + } + + private static final SpecificData MODEL$ = new SpecificData(); + static { + MODEL$.addLogicalTypeConversion(new org.apache.avro.Conversions.UUIDConversion()); + } + + private static final BinaryMessageEncoder ENCODER = + new BinaryMessageEncoder<>(MODEL$, SCHEMA$); + + private static final BinaryMessageDecoder DECODER = + new BinaryMessageDecoder<>(MODEL$, SCHEMA$); + + /** + * Return the BinaryMessageEncoder instance used by this class. + * + * @return the message encoder used by this class + */ + public static BinaryMessageEncoder getEncoder() { + return ENCODER; + } + + /** + * Return the BinaryMessageDecoder instance used by this class. + * + * @return the message decoder used by this class + */ + public static BinaryMessageDecoder getDecoder() { + return DECODER; + } + + /** + * Create a new BinaryMessageDecoder instance for this class that uses the specified {@link SchemaStore}. + * + * @param resolver a {@link SchemaStore} used to find schemas by fingerprint + * @return a BinaryMessageDecoder instance for this class backed by the given SchemaStore + */ + public static BinaryMessageDecoder createDecoder(final SchemaStore resolver) { + return new BinaryMessageDecoder<>(MODEL$, SCHEMA$, resolver); + } + + /** + * Serializes this BookCreatedMessage to a ByteBuffer. + * + * @return a buffer holding the serialized data for this instance + * @throws java.io.IOException if this instance could not be serialized + */ + public java.nio.ByteBuffer toByteBuffer() throws java.io.IOException { + return ENCODER.encode(this); + } + + /** + * Deserializes a BookCreatedMessage from a ByteBuffer. + * + * @param b a byte buffer holding serialized data for an instance of this class + * @return a BookCreatedMessage instance decoded from the given buffer + * @throws java.io.IOException if the given bytes could not be deserialized into an instance of this class + */ + public static BookCreatedMessage fromByteBuffer( + final java.nio.ByteBuffer b) throws java.io.IOException { + return DECODER.decode(b); + } + + public java.util.UUID book_id; + + /** + * Default constructor. Note that this does not initialize fields to their default values from the schema. If that is desired then one + * should use newBuilder(). + */ + public BookCreatedMessage() { + } + + /** + * All-args constructor. + * + * @param book_id The new value for book_id + */ + public BookCreatedMessage(final java.util.UUID book_id) { + this.book_id = book_id; + } + + @Override + public SpecificData getSpecificData() { + return MODEL$; + } + + @Override + public org.apache.avro.Schema getSchema() { + return SCHEMA$; + } + + // Used by DatumWriter. Applications should not call. + @Override + public Object get(final int field$) { + switch (field$) { + case 0: + return this.book_id; + default: + throw new IndexOutOfBoundsException("Invalid index: " + field$); + } + } + + private static final org.apache.avro.Conversion[] conversions = + new org.apache.avro.Conversion[]{ + new org.apache.avro.Conversions.UUIDConversion(), + null + }; + + @Override + public org.apache.avro.Conversion getConversion(final int field) { + return conversions[field]; + } + + // Used by DatumReader. Applications should not call. + @Override + @SuppressWarnings(value = "unchecked") + public void put(final int field$, final Object value$) { + switch (field$) { + case 0: + this.book_id = (java.util.UUID) value$; + break; + default: + throw new IndexOutOfBoundsException("Invalid index: " + field$); + } + } + + /** + * Gets the value of the 'book_id' field. + * + * @return The value of the 'book_id' field. + */ + public java.util.UUID getBookId() { + return this.book_id; + } + + /** + * Sets the value of the 'book_id' field. + * + * @param value the value to set. + */ + public void setBookId(final java.util.UUID value) { + this.book_id = value; + } + + /** + * Creates a new BookCreatedMessage RecordBuilder. + * + * @return A new BookCreatedMessage RecordBuilder + */ + public static Builder newBuilder() { + return new Builder(); + } + + /** + * Creates a new BookCreatedMessage RecordBuilder by copying an existing Builder. + * + * @param other The existing builder to copy. + * @return A new BookCreatedMessage RecordBuilder + */ + public static Builder newBuilder(final Builder other) { + if (other == null) { + return new Builder(); + } else { + return new Builder(other); + } + } + + /** + * Creates a new BookCreatedMessage RecordBuilder by copying an existing BookCreatedMessage instance. + * + * @param other The existing instance to copy. + * @return A new BookCreatedMessage RecordBuilder + */ + public static Builder newBuilder(final BookCreatedMessage other) { + if (other == null) { + return new Builder(); + } else { + return new Builder(other); + } + } + + /** + * RecordBuilder for BookCreatedMessage instances. + */ + @org.apache.avro.specific.AvroGenerated + public static class Builder extends org.apache.avro.specific.SpecificRecordBuilderBase + implements org.apache.avro.data.RecordBuilder { + + private java.util.UUID book_id; + + /** Creates a new Builder */ + private Builder() { + super(SCHEMA$, MODEL$); + } + + /** + * Creates a Builder by copying an existing Builder. + * + * @param other The existing Builder to copy. + */ + private Builder(final Builder other) { + super(other); + if (isValidValue(this.fields()[0], other.book_id)) { + this.book_id = this.data().deepCopy(this.fields()[0].schema(), other.book_id); + this.fieldSetFlags()[0] = other.fieldSetFlags()[0]; + } + } + + /** + * Creates a Builder by copying an existing BookCreatedMessage instance + * + * @param other The existing instance to copy. + */ + private Builder(final BookCreatedMessage other) { + super(SCHEMA$, MODEL$); + if (isValidValue(this.fields()[0], other.book_id)) { + this.book_id = this.data().deepCopy(this.fields()[0].schema(), other.book_id); + this.fieldSetFlags()[0] = true; + } + } + + /** + * Gets the value of the 'book_id' field. + * + * @return The value. + */ + public java.util.UUID getBookId() { + return this.book_id; + } + + /** + * Sets the value of the 'book_id' field. + * + * @param value The value of 'book_id'. + * @return This builder. + */ + public Builder setBookId(final java.util.UUID value) { + this.validate(this.fields()[0], value); + this.book_id = value; + this.fieldSetFlags()[0] = true; + return this; + } + + /** + * Checks whether the 'book_id' field has been set. + * + * @return True if the 'book_id' field has been set, false otherwise. + */ + public boolean hasBookId() { + return this.fieldSetFlags()[0]; + } + + /** + * Clears the value of the 'book_id' field. + * + * @return This builder. + */ + public Builder clearBookId() { + this.book_id = null; + this.fieldSetFlags()[0] = false; + return this; + } + + @Override + @SuppressWarnings("unchecked") + public BookCreatedMessage build() { + try { + final BookCreatedMessage record = new BookCreatedMessage(); + record.book_id = this.fieldSetFlags()[0] ? this.book_id : (java.util.UUID) this.defaultValue(this.fields()[0]); + return record; + } catch (final org.apache.avro.AvroMissingFieldException e) { + throw e; + } catch (final Exception e) { + throw new org.apache.avro.AvroRuntimeException(e); + } + } + } + + @SuppressWarnings("unchecked") + private static final org.apache.avro.io.DatumWriter WRITER$ = + (org.apache.avro.io.DatumWriter) MODEL$.createDatumWriter(SCHEMA$); + + @Override + public void writeExternal(final java.io.ObjectOutput out) + throws java.io.IOException { + WRITER$.write(this, SpecificData.getEncoder(out)); + } + + @SuppressWarnings("unchecked") + private static final org.apache.avro.io.DatumReader READER$ = + (org.apache.avro.io.DatumReader) MODEL$.createDatumReader(SCHEMA$); + + @Override + public void readExternal(final java.io.ObjectInput in) + throws java.io.IOException { + READER$.read(this, SpecificData.getDecoder(in)); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/CompositeJsonMapperTest.java b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/CompositeJsonMapperTest.java new file mode 100644 index 0000000..f80bd53 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/CompositeJsonMapperTest.java @@ -0,0 +1,52 @@ +package dev.inditex.scsoutbox.publish.archive.json; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class CompositeJsonMapperTest { + + private CompositeJsonMapper compositeJsonMapper; + + private DefaultJsonMapper defaultJsonMapper; + + private AvroToJsonMapper avroToJsonMapper; + + @BeforeEach + void setUp() { + this.defaultJsonMapper = mock(DefaultJsonMapper.class); + when(this.defaultJsonMapper.getValueType()).thenCallRealMethod(); + this.avroToJsonMapper = mock(AvroToJsonMapper.class); + when(this.avroToJsonMapper.getValueType()).thenCallRealMethod(); + this.compositeJsonMapper = new CompositeJsonMapper( + this.defaultJsonMapper, List.of(this.avroToJsonMapper)); + } + + @Test + void delegate_to_correct_json_mapper() { + final BookCreatedMessage avroValue = BookCreatedMessage.newBuilder() + .setBookId(UUID.fromString("e5e313ab-110e-4890-8da3-7547f119c281")) + .build(); + + this.compositeJsonMapper.writeValueAsString(avroValue); + + verify(this.avroToJsonMapper).writeValueAsString(avroValue); + } + + @Test + void delegate_to_default_json_mapper_when_no_mapper_available() { + final Map value = Map.of("key", "value"); + + this.compositeJsonMapper.writeValueAsString(value); + + verify(this.defaultJsonMapper).writeValueAsString(value); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/Cycle.java b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/Cycle.java new file mode 100644 index 0000000..5b956ec --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/Cycle.java @@ -0,0 +1,902 @@ +/** + * Autogenerated by Avro + * + * DO NOT EDIT DIRECTLY + */ +package dev.inditex.scsoutbox.publish.archive.json; + +import org.apache.avro.message.BinaryMessageDecoder; +import org.apache.avro.message.BinaryMessageEncoder; +import org.apache.avro.message.SchemaStore; +import org.apache.avro.specific.SpecificData; + +@org.apache.avro.specific.AvroGenerated +public class Cycle extends org.apache.avro.specific.SpecificRecordBase implements org.apache.avro.specific.SpecificRecord { + private static final long serialVersionUID = 2660713656848663733L; + + public static final org.apache.avro.Schema SCHEMA$ = new org.apache.avro.Schema.Parser().parse( + "{\"type\":\"record\",\"name\":\"Cycle\",\"namespace\":\"dev.inditex.icbcasmng.pipe.cycleData.v2\",\"fields\":[{\"name\":\"id\",\"type\":{\"type\":\"string\",\"logicalType\":\"uuid\"},\"doc\":\"Cycle identifier\"},{\"name\":\"distribution_range_id\",\"type\":{\"type\":\"string\",\"logicalType\":\"uuid\"},\"doc\":\"Distribution range identifier\"},{\"name\":\"week_cycle\",\"type\":\"long\",\"doc\":\"Cycle identifier within the week\"},{\"name\":\"start_date\",\"type\":\"string\",\"doc\":\"Cycle start date,example: '2023-05-24'\"},{\"name\":\"end_date\",\"type\":\"string\",\"doc\":\"Cycle end date, example: '2023-05-26'\"},{\"name\":\"arrival_store_date\",\"type\":\"string\",\"doc\":\"Cycle arrival store date\"},{\"name\":\"closed\",\"type\":\"boolean\",\"doc\":\"closed\"},{\"name\":\"products\",\"type\":{\"type\":\"array\",\"items\":{\"type\":\"record\",\"name\":\"Product\",\"doc\":\"Distributable product information\",\"fields\":[{\"name\":\"distributable_product_id\",\"type\":{\"type\":\"string\",\"logicalType\":\"uuid\"},\"doc\":\"Distributable Product identifier\"},{\"name\":\"comment\",\"type\":[\"null\",\"string\"],\"doc\":\"Comment\"},{\"name\":\"assignment_type\",\"type\":\"string\",\"doc\":\"Assignment type\",\"enum\":[\"STANDARD\",\"ADVANCE\"]},{\"name\":\"published\",\"type\":\"boolean\",\"doc\":\"published\"}]}}}]}"); + + public static org.apache.avro.Schema getClassSchema() { + return SCHEMA$; + } + + private static final SpecificData MODEL$ = new SpecificData(); + static { + MODEL$.addLogicalTypeConversion(new org.apache.avro.Conversions.UUIDConversion()); + } + + private static final BinaryMessageEncoder ENCODER = + new BinaryMessageEncoder<>(MODEL$, SCHEMA$); + + private static final BinaryMessageDecoder DECODER = + new BinaryMessageDecoder<>(MODEL$, SCHEMA$); + + /** + * Return the BinaryMessageEncoder instance used by this class. + * + * @return the message encoder used by this class + */ + public static BinaryMessageEncoder getEncoder() { + return ENCODER; + } + + /** + * Return the BinaryMessageDecoder instance used by this class. + * + * @return the message decoder used by this class + */ + public static BinaryMessageDecoder getDecoder() { + return DECODER; + } + + /** + * Create a new BinaryMessageDecoder instance for this class that uses the specified {@link SchemaStore}. + * + * @param resolver a {@link SchemaStore} used to find schemas by fingerprint + * @return a BinaryMessageDecoder instance for this class backed by the given SchemaStore + */ + public static BinaryMessageDecoder createDecoder(final SchemaStore resolver) { + return new BinaryMessageDecoder<>(MODEL$, SCHEMA$, resolver); + } + + /** + * Serializes this Cycle to a ByteBuffer. + * + * @return a buffer holding the serialized data for this instance + * @throws java.io.IOException if this instance could not be serialized + */ + public java.nio.ByteBuffer toByteBuffer() throws java.io.IOException { + return ENCODER.encode(this); + } + + /** + * Deserializes a Cycle from a ByteBuffer. + * + * @param b a byte buffer holding serialized data for an instance of this class + * @return a Cycle instance decoded from the given buffer + * @throws java.io.IOException if the given bytes could not be deserialized into an instance of this class + */ + public static Cycle fromByteBuffer( + final java.nio.ByteBuffer b) throws java.io.IOException { + return DECODER.decode(b); + } + + /** Cycle identifier */ + public java.util.UUID id; + + /** Distribution range identifier */ + public java.util.UUID distribution_range_id; + + /** Cycle identifier within the week */ + public long week_cycle; + + /** Cycle start date,example: '2023-05-24' */ + public CharSequence start_date; + + /** Cycle end date, example: '2023-05-26' */ + public CharSequence end_date; + + /** Cycle arrival store date */ + public CharSequence arrival_store_date; + + /** closed */ + public boolean closed; + + public java.util.List products; + + /** + * Default constructor. Note that this does not initialize fields to their default values from the schema. If that is desired then one + * should use newBuilder(). + */ + public Cycle() { + } + + /** + * All-args constructor. + * + * @param id Cycle identifier + * @param distribution_range_id Distribution range identifier + * @param week_cycle Cycle identifier within the week + * @param start_date Cycle start date,example: '2023-05-24' + * @param end_date Cycle end date, example: '2023-05-26' + * @param arrival_store_date Cycle arrival store date + * @param closed closed + * @param products The new value for products + */ + public Cycle(final java.util.UUID id, final java.util.UUID distribution_range_id, final Long week_cycle, final CharSequence start_date, + final CharSequence end_date, + final CharSequence arrival_store_date, final Boolean closed, final java.util.List products) { + this.id = id; + this.distribution_range_id = distribution_range_id; + this.week_cycle = week_cycle; + this.start_date = start_date; + this.end_date = end_date; + this.arrival_store_date = arrival_store_date; + this.closed = closed; + this.products = products; + } + + @Override + public SpecificData getSpecificData() { + return MODEL$; + } + + @Override + public org.apache.avro.Schema getSchema() { + return SCHEMA$; + } + + // Used by DatumWriter. Applications should not call. + @Override + public Object get(final int field$) { + switch (field$) { + case 0: + return this.id; + case 1: + return this.distribution_range_id; + case 2: + return this.week_cycle; + case 3: + return this.start_date; + case 4: + return this.end_date; + case 5: + return this.arrival_store_date; + case 6: + return this.closed; + case 7: + return this.products; + default: + throw new IndexOutOfBoundsException("Invalid index: " + field$); + } + } + + private static final org.apache.avro.Conversion[] conversions = + new org.apache.avro.Conversion[]{ + new org.apache.avro.Conversions.UUIDConversion(), + new org.apache.avro.Conversions.UUIDConversion(), + null, + null, + null, + null, + null, + null, + null + }; + + @Override + public org.apache.avro.Conversion getConversion(final int field) { + return conversions[field]; + } + + // Used by DatumReader. Applications should not call. + @Override + @SuppressWarnings(value = "unchecked") + public void put(final int field$, final Object value$) { + switch (field$) { + case 0: + this.id = (java.util.UUID) value$; + break; + case 1: + this.distribution_range_id = (java.util.UUID) value$; + break; + case 2: + this.week_cycle = (Long) value$; + break; + case 3: + this.start_date = (CharSequence) value$; + break; + case 4: + this.end_date = (CharSequence) value$; + break; + case 5: + this.arrival_store_date = (CharSequence) value$; + break; + case 6: + this.closed = (Boolean) value$; + break; + case 7: + this.products = (java.util.List) value$; + break; + default: + throw new IndexOutOfBoundsException("Invalid index: " + field$); + } + } + + /** + * Gets the value of the 'id' field. + * + * @return Cycle identifier + */ + public java.util.UUID getId() { + return this.id; + } + + /** + * Sets the value of the 'id' field. Cycle identifier + * + * @param value the value to set. + */ + public void setId(final java.util.UUID value) { + this.id = value; + } + + /** + * Gets the value of the 'distribution_range_id' field. + * + * @return Distribution range identifier + */ + public java.util.UUID getDistributionRangeId() { + return this.distribution_range_id; + } + + /** + * Sets the value of the 'distribution_range_id' field. Distribution range identifier + * + * @param value the value to set. + */ + public void setDistributionRangeId(final java.util.UUID value) { + this.distribution_range_id = value; + } + + /** + * Gets the value of the 'week_cycle' field. + * + * @return Cycle identifier within the week + */ + public long getWeekCycle() { + return this.week_cycle; + } + + /** + * Sets the value of the 'week_cycle' field. Cycle identifier within the week + * + * @param value the value to set. + */ + public void setWeekCycle(final long value) { + this.week_cycle = value; + } + + /** + * Gets the value of the 'start_date' field. + * + * @return Cycle start date,example: '2023-05-24' + */ + public CharSequence getStartDate() { + return this.start_date; + } + + /** + * Sets the value of the 'start_date' field. Cycle start date,example: '2023-05-24' + * + * @param value the value to set. + */ + public void setStartDate(final CharSequence value) { + this.start_date = value; + } + + /** + * Gets the value of the 'end_date' field. + * + * @return Cycle end date, example: '2023-05-26' + */ + public CharSequence getEndDate() { + return this.end_date; + } + + /** + * Sets the value of the 'end_date' field. Cycle end date, example: '2023-05-26' + * + * @param value the value to set. + */ + public void setEndDate(final CharSequence value) { + this.end_date = value; + } + + /** + * Gets the value of the 'arrival_store_date' field. + * + * @return Cycle arrival store date + */ + public CharSequence getArrivalStoreDate() { + return this.arrival_store_date; + } + + /** + * Sets the value of the 'arrival_store_date' field. Cycle arrival store date + * + * @param value the value to set. + */ + public void setArrivalStoreDate(final CharSequence value) { + this.arrival_store_date = value; + } + + /** + * Gets the value of the 'closed' field. + * + * @return closed + */ + public boolean getClosed() { + return this.closed; + } + + /** + * Sets the value of the 'closed' field. closed + * + * @param value the value to set. + */ + public void setClosed(final boolean value) { + this.closed = value; + } + + /** + * Gets the value of the 'products' field. + * + * @return The value of the 'products' field. + */ + public java.util.List getProducts() { + return this.products; + } + + /** + * Sets the value of the 'products' field. + * + * @param value the value to set. + */ + public void setProducts(final java.util.List value) { + this.products = value; + } + + /** + * Creates a new Cycle RecordBuilder. + * + * @return A new Cycle RecordBuilder + */ + public static Builder newBuilder() { + return new Builder(); + } + + /** + * Creates a new Cycle RecordBuilder by copying an existing Builder. + * + * @param other The existing builder to copy. + * @return A new Cycle RecordBuilder + */ + public static Builder newBuilder(final Builder other) { + if (other == null) { + return new Builder(); + } else { + return new Builder(other); + } + } + + /** + * Creates a new Cycle RecordBuilder by copying an existing Cycle instance. + * + * @param other The existing instance to copy. + * @return A new Cycle RecordBuilder + */ + public static Builder newBuilder(final Cycle other) { + if (other == null) { + return new Builder(); + } else { + return new Builder(other); + } + } + + /** + * RecordBuilder for Cycle instances. + */ + @org.apache.avro.specific.AvroGenerated + public static class Builder extends org.apache.avro.specific.SpecificRecordBuilderBase + implements org.apache.avro.data.RecordBuilder { + + /** Cycle identifier */ + private java.util.UUID id; + + /** Distribution range identifier */ + private java.util.UUID distribution_range_id; + + /** Cycle identifier within the week */ + private long week_cycle; + + /** Cycle start date,example: '2023-05-24' */ + private CharSequence start_date; + + /** Cycle end date, example: '2023-05-26' */ + private CharSequence end_date; + + /** Cycle arrival store date */ + private CharSequence arrival_store_date; + + /** closed */ + private boolean closed; + + private java.util.List products; + + /** Creates a new Builder */ + private Builder() { + super(SCHEMA$, MODEL$); + } + + /** + * Creates a Builder by copying an existing Builder. + * + * @param other The existing Builder to copy. + */ + private Builder(final Builder other) { + super(other); + if (isValidValue(this.fields()[0], other.id)) { + this.id = this.data().deepCopy(this.fields()[0].schema(), other.id); + this.fieldSetFlags()[0] = other.fieldSetFlags()[0]; + } + if (isValidValue(this.fields()[1], other.distribution_range_id)) { + this.distribution_range_id = this.data().deepCopy(this.fields()[1].schema(), other.distribution_range_id); + this.fieldSetFlags()[1] = other.fieldSetFlags()[1]; + } + if (isValidValue(this.fields()[2], other.week_cycle)) { + this.week_cycle = this.data().deepCopy(this.fields()[2].schema(), other.week_cycle); + this.fieldSetFlags()[2] = other.fieldSetFlags()[2]; + } + if (isValidValue(this.fields()[3], other.start_date)) { + this.start_date = this.data().deepCopy(this.fields()[3].schema(), other.start_date); + this.fieldSetFlags()[3] = other.fieldSetFlags()[3]; + } + if (isValidValue(this.fields()[4], other.end_date)) { + this.end_date = this.data().deepCopy(this.fields()[4].schema(), other.end_date); + this.fieldSetFlags()[4] = other.fieldSetFlags()[4]; + } + if (isValidValue(this.fields()[5], other.arrival_store_date)) { + this.arrival_store_date = this.data().deepCopy(this.fields()[5].schema(), other.arrival_store_date); + this.fieldSetFlags()[5] = other.fieldSetFlags()[5]; + } + if (isValidValue(this.fields()[6], other.closed)) { + this.closed = this.data().deepCopy(this.fields()[6].schema(), other.closed); + this.fieldSetFlags()[6] = other.fieldSetFlags()[6]; + } + if (isValidValue(this.fields()[7], other.products)) { + this.products = this.data().deepCopy(this.fields()[7].schema(), other.products); + this.fieldSetFlags()[7] = other.fieldSetFlags()[7]; + } + } + + /** + * Creates a Builder by copying an existing Cycle instance + * + * @param other The existing instance to copy. + */ + private Builder(final Cycle other) { + super(SCHEMA$, MODEL$); + if (isValidValue(this.fields()[0], other.id)) { + this.id = this.data().deepCopy(this.fields()[0].schema(), other.id); + this.fieldSetFlags()[0] = true; + } + if (isValidValue(this.fields()[1], other.distribution_range_id)) { + this.distribution_range_id = this.data().deepCopy(this.fields()[1].schema(), other.distribution_range_id); + this.fieldSetFlags()[1] = true; + } + if (isValidValue(this.fields()[2], other.week_cycle)) { + this.week_cycle = this.data().deepCopy(this.fields()[2].schema(), other.week_cycle); + this.fieldSetFlags()[2] = true; + } + if (isValidValue(this.fields()[3], other.start_date)) { + this.start_date = this.data().deepCopy(this.fields()[3].schema(), other.start_date); + this.fieldSetFlags()[3] = true; + } + if (isValidValue(this.fields()[4], other.end_date)) { + this.end_date = this.data().deepCopy(this.fields()[4].schema(), other.end_date); + this.fieldSetFlags()[4] = true; + } + if (isValidValue(this.fields()[5], other.arrival_store_date)) { + this.arrival_store_date = this.data().deepCopy(this.fields()[5].schema(), other.arrival_store_date); + this.fieldSetFlags()[5] = true; + } + if (isValidValue(this.fields()[6], other.closed)) { + this.closed = this.data().deepCopy(this.fields()[6].schema(), other.closed); + this.fieldSetFlags()[6] = true; + } + if (isValidValue(this.fields()[7], other.products)) { + this.products = this.data().deepCopy(this.fields()[7].schema(), other.products); + this.fieldSetFlags()[7] = true; + } + } + + /** + * Gets the value of the 'id' field. Cycle identifier + * + * @return The value. + */ + public java.util.UUID getId() { + return this.id; + } + + /** + * Sets the value of the 'id' field. Cycle identifier + * + * @param value The value of 'id'. + * @return This builder. + */ + public Builder setId(final java.util.UUID value) { + this.validate(this.fields()[0], value); + this.id = value; + this.fieldSetFlags()[0] = true; + return this; + } + + /** + * Checks whether the 'id' field has been set. Cycle identifier + * + * @return True if the 'id' field has been set, false otherwise. + */ + public boolean hasId() { + return this.fieldSetFlags()[0]; + } + + /** + * Clears the value of the 'id' field. Cycle identifier + * + * @return This builder. + */ + public Builder clearId() { + this.id = null; + this.fieldSetFlags()[0] = false; + return this; + } + + /** + * Gets the value of the 'distribution_range_id' field. Distribution range identifier + * + * @return The value. + */ + public java.util.UUID getDistributionRangeId() { + return this.distribution_range_id; + } + + /** + * Sets the value of the 'distribution_range_id' field. Distribution range identifier + * + * @param value The value of 'distribution_range_id'. + * @return This builder. + */ + public Builder setDistributionRangeId(final java.util.UUID value) { + this.validate(this.fields()[1], value); + this.distribution_range_id = value; + this.fieldSetFlags()[1] = true; + return this; + } + + /** + * Checks whether the 'distribution_range_id' field has been set. Distribution range identifier + * + * @return True if the 'distribution_range_id' field has been set, false otherwise. + */ + public boolean hasDistributionRangeId() { + return this.fieldSetFlags()[1]; + } + + /** + * Clears the value of the 'distribution_range_id' field. Distribution range identifier + * + * @return This builder. + */ + public Builder clearDistributionRangeId() { + this.distribution_range_id = null; + this.fieldSetFlags()[1] = false; + return this; + } + + /** + * Gets the value of the 'week_cycle' field. Cycle identifier within the week + * + * @return The value. + */ + public long getWeekCycle() { + return this.week_cycle; + } + + /** + * Sets the value of the 'week_cycle' field. Cycle identifier within the week + * + * @param value The value of 'week_cycle'. + * @return This builder. + */ + public Builder setWeekCycle(final long value) { + this.validate(this.fields()[2], value); + this.week_cycle = value; + this.fieldSetFlags()[2] = true; + return this; + } + + /** + * Checks whether the 'week_cycle' field has been set. Cycle identifier within the week + * + * @return True if the 'week_cycle' field has been set, false otherwise. + */ + public boolean hasWeekCycle() { + return this.fieldSetFlags()[2]; + } + + /** + * Clears the value of the 'week_cycle' field. Cycle identifier within the week + * + * @return This builder. + */ + public Builder clearWeekCycle() { + this.fieldSetFlags()[2] = false; + return this; + } + + /** + * Gets the value of the 'start_date' field. Cycle start date,example: '2023-05-24' + * + * @return The value. + */ + public CharSequence getStartDate() { + return this.start_date; + } + + /** + * Sets the value of the 'start_date' field. Cycle start date,example: '2023-05-24' + * + * @param value The value of 'start_date'. + * @return This builder. + */ + public Builder setStartDate(final CharSequence value) { + this.validate(this.fields()[3], value); + this.start_date = value; + this.fieldSetFlags()[3] = true; + return this; + } + + /** + * Checks whether the 'start_date' field has been set. Cycle start date,example: '2023-05-24' + * + * @return True if the 'start_date' field has been set, false otherwise. + */ + public boolean hasStartDate() { + return this.fieldSetFlags()[3]; + } + + /** + * Clears the value of the 'start_date' field. Cycle start date,example: '2023-05-24' + * + * @return This builder. + */ + public Builder clearStartDate() { + this.start_date = null; + this.fieldSetFlags()[3] = false; + return this; + } + + /** + * Gets the value of the 'end_date' field. Cycle end date, example: '2023-05-26' + * + * @return The value. + */ + public CharSequence getEndDate() { + return this.end_date; + } + + /** + * Sets the value of the 'end_date' field. Cycle end date, example: '2023-05-26' + * + * @param value The value of 'end_date'. + * @return This builder. + */ + public Builder setEndDate(final CharSequence value) { + this.validate(this.fields()[4], value); + this.end_date = value; + this.fieldSetFlags()[4] = true; + return this; + } + + /** + * Checks whether the 'end_date' field has been set. Cycle end date, example: '2023-05-26' + * + * @return True if the 'end_date' field has been set, false otherwise. + */ + public boolean hasEndDate() { + return this.fieldSetFlags()[4]; + } + + /** + * Clears the value of the 'end_date' field. Cycle end date, example: '2023-05-26' + * + * @return This builder. + */ + public Builder clearEndDate() { + this.end_date = null; + this.fieldSetFlags()[4] = false; + return this; + } + + /** + * Gets the value of the 'arrival_store_date' field. Cycle arrival store date + * + * @return The value. + */ + public CharSequence getArrivalStoreDate() { + return this.arrival_store_date; + } + + /** + * Sets the value of the 'arrival_store_date' field. Cycle arrival store date + * + * @param value The value of 'arrival_store_date'. + * @return This builder. + */ + public Builder setArrivalStoreDate(final CharSequence value) { + this.validate(this.fields()[5], value); + this.arrival_store_date = value; + this.fieldSetFlags()[5] = true; + return this; + } + + /** + * Checks whether the 'arrival_store_date' field has been set. Cycle arrival store date + * + * @return True if the 'arrival_store_date' field has been set, false otherwise. + */ + public boolean hasArrivalStoreDate() { + return this.fieldSetFlags()[5]; + } + + /** + * Clears the value of the 'arrival_store_date' field. Cycle arrival store date + * + * @return This builder. + */ + public Builder clearArrivalStoreDate() { + this.arrival_store_date = null; + this.fieldSetFlags()[5] = false; + return this; + } + + /** + * Gets the value of the 'closed' field. closed + * + * @return The value. + */ + public boolean getClosed() { + return this.closed; + } + + /** + * Sets the value of the 'closed' field. closed + * + * @param value The value of 'closed'. + * @return This builder. + */ + public Builder setClosed(final boolean value) { + this.validate(this.fields()[6], value); + this.closed = value; + this.fieldSetFlags()[6] = true; + return this; + } + + /** + * Checks whether the 'closed' field has been set. closed + * + * @return True if the 'closed' field has been set, false otherwise. + */ + public boolean hasClosed() { + return this.fieldSetFlags()[6]; + } + + /** + * Clears the value of the 'closed' field. closed + * + * @return This builder. + */ + public Builder clearClosed() { + this.fieldSetFlags()[6] = false; + return this; + } + + /** + * Gets the value of the 'products' field. + * + * @return The value. + */ + public java.util.List getProducts() { + return this.products; + } + + /** + * Sets the value of the 'products' field. + * + * @param value The value of 'products'. + * @return This builder. + */ + public Builder setProducts(final java.util.List value) { + this.validate(this.fields()[7], value); + this.products = value; + this.fieldSetFlags()[7] = true; + return this; + } + + /** + * Checks whether the 'products' field has been set. + * + * @return True if the 'products' field has been set, false otherwise. + */ + public boolean hasProducts() { + return this.fieldSetFlags()[7]; + } + + /** + * Clears the value of the 'products' field. + * + * @return This builder. + */ + public Builder clearProducts() { + this.products = null; + this.fieldSetFlags()[7] = false; + return this; + } + + @Override + @SuppressWarnings("unchecked") + public Cycle build() { + try { + final Cycle record = new Cycle(); + record.id = this.fieldSetFlags()[0] ? this.id : (java.util.UUID) this.defaultValue(this.fields()[0]); + record.distribution_range_id = + this.fieldSetFlags()[1] ? this.distribution_range_id : (java.util.UUID) this.defaultValue(this.fields()[1]); + record.week_cycle = this.fieldSetFlags()[2] ? this.week_cycle : (Long) this.defaultValue(this.fields()[2]); + record.start_date = this.fieldSetFlags()[3] ? this.start_date : (CharSequence) this.defaultValue(this.fields()[3]); + record.end_date = this.fieldSetFlags()[4] ? this.end_date : (CharSequence) this.defaultValue(this.fields()[4]); + record.arrival_store_date = this.fieldSetFlags()[5] ? this.arrival_store_date : (CharSequence) this.defaultValue(this.fields()[5]); + record.closed = this.fieldSetFlags()[6] ? this.closed : (Boolean) this.defaultValue(this.fields()[6]); + record.products = this.fieldSetFlags()[7] ? this.products : (java.util.List) this.defaultValue(this.fields()[7]); + return record; + } catch (final org.apache.avro.AvroMissingFieldException e) { + throw e; + } catch (final Exception e) { + throw new org.apache.avro.AvroRuntimeException(e); + } + } + } + + @SuppressWarnings("unchecked") + private static final org.apache.avro.io.DatumWriter WRITER$ = + (org.apache.avro.io.DatumWriter) MODEL$.createDatumWriter(SCHEMA$); + + @Override + public void writeExternal(final java.io.ObjectOutput out) + throws java.io.IOException { + WRITER$.write(this, SpecificData.getEncoder(out)); + } + + @SuppressWarnings("unchecked") + private static final org.apache.avro.io.DatumReader READER$ = + (org.apache.avro.io.DatumReader) MODEL$.createDatumReader(SCHEMA$); + + @Override + public void readExternal(final java.io.ObjectInput in) + throws java.io.IOException { + READER$.read(this, SpecificData.getDecoder(in)); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/DefaultJsonMapperTest.java b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/DefaultJsonMapperTest.java new file mode 100644 index 0000000..68ef955 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/DefaultJsonMapperTest.java @@ -0,0 +1,32 @@ +package dev.inditex.scsoutbox.publish.archive.json; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +class DefaultJsonMapperTest { + + private DefaultJsonMapper defaultJsonMapper; + + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + this.objectMapper = mock(ObjectMapper.class); + this.defaultJsonMapper = new DefaultJsonMapper(this.objectMapper); + } + + @Test + void delegate_to_json_mapper() throws JacksonException { + final Map object = Map.of("key", "value"); + this.defaultJsonMapper.writeValueAsString(object); + verify(this.objectMapper).writeValueAsString(object); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/Product.java b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/Product.java new file mode 100644 index 0000000..5385941 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-archive/src/test/java/dev/inditex/scsoutbox/publish/archive/json/Product.java @@ -0,0 +1,573 @@ +/** + * Autogenerated by Avro + * + * DO NOT EDIT DIRECTLY + */ +package dev.inditex.scsoutbox.publish.archive.json; + +import org.apache.avro.message.BinaryMessageDecoder; +import org.apache.avro.message.BinaryMessageEncoder; +import org.apache.avro.message.SchemaStore; +import org.apache.avro.specific.SpecificData; + +/** Distributable product information */ +@org.apache.avro.specific.AvroGenerated +public class Product extends org.apache.avro.specific.SpecificRecordBase implements org.apache.avro.specific.SpecificRecord { + private static final long serialVersionUID = -6903173095167710534L; + + public static final org.apache.avro.Schema SCHEMA$ = new org.apache.avro.Schema.Parser().parse( + "{\"type\":\"record\",\"name\":\"Product\",\"namespace\":\"dev.inditex.icbcasmng.pipe.cycleData.v2\",\"doc\":\"Distributable product information\",\"fields\":[{\"name\":\"distributable_product_id\",\"type\":{\"type\":\"string\",\"logicalType\":\"uuid\"},\"doc\":\"Distributable Product identifier\"},{\"name\":\"comment\",\"type\":[\"null\",\"string\"],\"doc\":\"Comment\"},{\"name\":\"assignment_type\",\"type\":\"string\",\"doc\":\"Assignment type\",\"enum\":[\"STANDARD\",\"ADVANCE\"]},{\"name\":\"published\",\"type\":\"boolean\",\"doc\":\"published\"}]}"); + + public static org.apache.avro.Schema getClassSchema() { + return SCHEMA$; + } + + private static final SpecificData MODEL$ = new SpecificData(); + static { + MODEL$.addLogicalTypeConversion(new org.apache.avro.Conversions.UUIDConversion()); + } + + private static final BinaryMessageEncoder ENCODER = + new BinaryMessageEncoder<>(MODEL$, SCHEMA$); + + private static final BinaryMessageDecoder DECODER = + new BinaryMessageDecoder<>(MODEL$, SCHEMA$); + + /** + * Return the BinaryMessageEncoder instance used by this class. + * + * @return the message encoder used by this class + */ + public static BinaryMessageEncoder getEncoder() { + return ENCODER; + } + + /** + * Return the BinaryMessageDecoder instance used by this class. + * + * @return the message decoder used by this class + */ + public static BinaryMessageDecoder getDecoder() { + return DECODER; + } + + /** + * Create a new BinaryMessageDecoder instance for this class that uses the specified {@link SchemaStore}. + * + * @param resolver a {@link SchemaStore} used to find schemas by fingerprint + * @return a BinaryMessageDecoder instance for this class backed by the given SchemaStore + */ + public static BinaryMessageDecoder createDecoder(final SchemaStore resolver) { + return new BinaryMessageDecoder<>(MODEL$, SCHEMA$, resolver); + } + + /** + * Serializes this Product to a ByteBuffer. + * + * @return a buffer holding the serialized data for this instance + * @throws java.io.IOException if this instance could not be serialized + */ + public java.nio.ByteBuffer toByteBuffer() throws java.io.IOException { + return ENCODER.encode(this); + } + + /** + * Deserializes a Product from a ByteBuffer. + * + * @param b a byte buffer holding serialized data for an instance of this class + * @return a Product instance decoded from the given buffer + * @throws java.io.IOException if the given bytes could not be deserialized into an instance of this class + */ + public static Product fromByteBuffer( + final java.nio.ByteBuffer b) throws java.io.IOException { + return DECODER.decode(b); + } + + /** Distributable Product identifier */ + public java.util.UUID distributable_product_id; + + /** Comment */ + public CharSequence comment; + + /** Assignment type */ + public CharSequence assignment_type; + + /** published */ + public boolean published; + + /** + * Default constructor. Note that this does not initialize fields to their default values from the schema. If that is desired then one + * should use newBuilder(). + */ + public Product() { + } + + /** + * All-args constructor. + * + * @param distributable_product_id Distributable Product identifier + * @param comment Comment + * @param assignment_type Assignment type + * @param published published + */ + public Product(final java.util.UUID distributable_product_id, final CharSequence comment, final CharSequence assignment_type, + final Boolean published) { + this.distributable_product_id = distributable_product_id; + this.comment = comment; + this.assignment_type = assignment_type; + this.published = published; + } + + @Override + public SpecificData getSpecificData() { + return MODEL$; + } + + @Override + public org.apache.avro.Schema getSchema() { + return SCHEMA$; + } + + // Used by DatumWriter. Applications should not call. + @Override + public Object get(final int field$) { + switch (field$) { + case 0: + return this.distributable_product_id; + case 1: + return this.comment; + case 2: + return this.assignment_type; + case 3: + return this.published; + default: + throw new IndexOutOfBoundsException("Invalid index: " + field$); + } + } + + private static final org.apache.avro.Conversion[] conversions = + new org.apache.avro.Conversion[]{ + new org.apache.avro.Conversions.UUIDConversion(), + null, + null, + null, + null + }; + + @Override + public org.apache.avro.Conversion getConversion(final int field) { + return conversions[field]; + } + + // Used by DatumReader. Applications should not call. + @Override + @SuppressWarnings(value = "unchecked") + public void put(final int field$, final Object value$) { + switch (field$) { + case 0: + this.distributable_product_id = (java.util.UUID) value$; + break; + case 1: + this.comment = (CharSequence) value$; + break; + case 2: + this.assignment_type = (CharSequence) value$; + break; + case 3: + this.published = (Boolean) value$; + break; + default: + throw new IndexOutOfBoundsException("Invalid index: " + field$); + } + } + + /** + * Gets the value of the 'distributable_product_id' field. + * + * @return Distributable Product identifier + */ + public java.util.UUID getDistributableProductId() { + return this.distributable_product_id; + } + + /** + * Sets the value of the 'distributable_product_id' field. Distributable Product identifier + * + * @param value the value to set. + */ + public void setDistributableProductId(final java.util.UUID value) { + this.distributable_product_id = value; + } + + /** + * Gets the value of the 'comment' field. + * + * @return Comment + */ + public CharSequence getComment() { + return this.comment; + } + + /** + * Sets the value of the 'comment' field. Comment + * + * @param value the value to set. + */ + public void setComment(final CharSequence value) { + this.comment = value; + } + + /** + * Gets the value of the 'assignment_type' field. + * + * @return Assignment type + */ + public CharSequence getAssignmentType() { + return this.assignment_type; + } + + /** + * Sets the value of the 'assignment_type' field. Assignment type + * + * @param value the value to set. + */ + public void setAssignmentType(final CharSequence value) { + this.assignment_type = value; + } + + /** + * Gets the value of the 'published' field. + * + * @return published + */ + public boolean getPublished() { + return this.published; + } + + /** + * Sets the value of the 'published' field. published + * + * @param value the value to set. + */ + public void setPublished(final boolean value) { + this.published = value; + } + + /** + * Creates a new Product RecordBuilder. + * + * @return A new Product RecordBuilder + */ + public static Builder newBuilder() { + return new Builder(); + } + + /** + * Creates a new Product RecordBuilder by copying an existing Builder. + * + * @param other The existing builder to copy. + * @return A new Product RecordBuilder + */ + public static Builder newBuilder(final Builder other) { + if (other == null) { + return new Builder(); + } else { + return new Builder(other); + } + } + + /** + * Creates a new Product RecordBuilder by copying an existing Product instance. + * + * @param other The existing instance to copy. + * @return A new Product RecordBuilder + */ + public static Builder newBuilder(final Product other) { + if (other == null) { + return new Builder(); + } else { + return new Builder(other); + } + } + + /** + * RecordBuilder for Product instances. + */ + @org.apache.avro.specific.AvroGenerated + public static class Builder extends org.apache.avro.specific.SpecificRecordBuilderBase + implements org.apache.avro.data.RecordBuilder { + + /** Distributable Product identifier */ + private java.util.UUID distributable_product_id; + + /** Comment */ + private CharSequence comment; + + /** Assignment type */ + private CharSequence assignment_type; + + /** published */ + private boolean published; + + /** Creates a new Builder */ + private Builder() { + super(SCHEMA$, MODEL$); + } + + /** + * Creates a Builder by copying an existing Builder. + * + * @param other The existing Builder to copy. + */ + private Builder(final Builder other) { + super(other); + if (isValidValue(this.fields()[0], other.distributable_product_id)) { + this.distributable_product_id = this.data().deepCopy(this.fields()[0].schema(), other.distributable_product_id); + this.fieldSetFlags()[0] = other.fieldSetFlags()[0]; + } + if (isValidValue(this.fields()[1], other.comment)) { + this.comment = this.data().deepCopy(this.fields()[1].schema(), other.comment); + this.fieldSetFlags()[1] = other.fieldSetFlags()[1]; + } + if (isValidValue(this.fields()[2], other.assignment_type)) { + this.assignment_type = this.data().deepCopy(this.fields()[2].schema(), other.assignment_type); + this.fieldSetFlags()[2] = other.fieldSetFlags()[2]; + } + if (isValidValue(this.fields()[3], other.published)) { + this.published = this.data().deepCopy(this.fields()[3].schema(), other.published); + this.fieldSetFlags()[3] = other.fieldSetFlags()[3]; + } + } + + /** + * Creates a Builder by copying an existing Product instance + * + * @param other The existing instance to copy. + */ + private Builder(final Product other) { + super(SCHEMA$, MODEL$); + if (isValidValue(this.fields()[0], other.distributable_product_id)) { + this.distributable_product_id = this.data().deepCopy(this.fields()[0].schema(), other.distributable_product_id); + this.fieldSetFlags()[0] = true; + } + if (isValidValue(this.fields()[1], other.comment)) { + this.comment = this.data().deepCopy(this.fields()[1].schema(), other.comment); + this.fieldSetFlags()[1] = true; + } + if (isValidValue(this.fields()[2], other.assignment_type)) { + this.assignment_type = this.data().deepCopy(this.fields()[2].schema(), other.assignment_type); + this.fieldSetFlags()[2] = true; + } + if (isValidValue(this.fields()[3], other.published)) { + this.published = this.data().deepCopy(this.fields()[3].schema(), other.published); + this.fieldSetFlags()[3] = true; + } + } + + /** + * Gets the value of the 'distributable_product_id' field. Distributable Product identifier + * + * @return The value. + */ + public java.util.UUID getDistributableProductId() { + return this.distributable_product_id; + } + + /** + * Sets the value of the 'distributable_product_id' field. Distributable Product identifier + * + * @param value The value of 'distributable_product_id'. + * @return This builder. + */ + public Builder setDistributableProductId(final java.util.UUID value) { + this.validate(this.fields()[0], value); + this.distributable_product_id = value; + this.fieldSetFlags()[0] = true; + return this; + } + + /** + * Checks whether the 'distributable_product_id' field has been set. Distributable Product identifier + * + * @return True if the 'distributable_product_id' field has been set, false otherwise. + */ + public boolean hasDistributableProductId() { + return this.fieldSetFlags()[0]; + } + + /** + * Clears the value of the 'distributable_product_id' field. Distributable Product identifier + * + * @return This builder. + */ + public Builder clearDistributableProductId() { + this.distributable_product_id = null; + this.fieldSetFlags()[0] = false; + return this; + } + + /** + * Gets the value of the 'comment' field. Comment + * + * @return The value. + */ + public CharSequence getComment() { + return this.comment; + } + + /** + * Sets the value of the 'comment' field. Comment + * + * @param value The value of 'comment'. + * @return This builder. + */ + public Builder setComment(final CharSequence value) { + this.validate(this.fields()[1], value); + this.comment = value; + this.fieldSetFlags()[1] = true; + return this; + } + + /** + * Checks whether the 'comment' field has been set. Comment + * + * @return True if the 'comment' field has been set, false otherwise. + */ + public boolean hasComment() { + return this.fieldSetFlags()[1]; + } + + /** + * Clears the value of the 'comment' field. Comment + * + * @return This builder. + */ + public Builder clearComment() { + this.comment = null; + this.fieldSetFlags()[1] = false; + return this; + } + + /** + * Gets the value of the 'assignment_type' field. Assignment type + * + * @return The value. + */ + public CharSequence getAssignmentType() { + return this.assignment_type; + } + + /** + * Sets the value of the 'assignment_type' field. Assignment type + * + * @param value The value of 'assignment_type'. + * @return This builder. + */ + public Builder setAssignmentType(final CharSequence value) { + this.validate(this.fields()[2], value); + this.assignment_type = value; + this.fieldSetFlags()[2] = true; + return this; + } + + /** + * Checks whether the 'assignment_type' field has been set. Assignment type + * + * @return True if the 'assignment_type' field has been set, false otherwise. + */ + public boolean hasAssignmentType() { + return this.fieldSetFlags()[2]; + } + + /** + * Clears the value of the 'assignment_type' field. Assignment type + * + * @return This builder. + */ + public Builder clearAssignmentType() { + this.assignment_type = null; + this.fieldSetFlags()[2] = false; + return this; + } + + /** + * Gets the value of the 'published' field. published + * + * @return The value. + */ + public boolean getPublished() { + return this.published; + } + + /** + * Sets the value of the 'published' field. published + * + * @param value The value of 'published'. + * @return This builder. + */ + public Builder setPublished(final boolean value) { + this.validate(this.fields()[3], value); + this.published = value; + this.fieldSetFlags()[3] = true; + return this; + } + + /** + * Checks whether the 'published' field has been set. published + * + * @return True if the 'published' field has been set, false otherwise. + */ + public boolean hasPublished() { + return this.fieldSetFlags()[3]; + } + + /** + * Clears the value of the 'published' field. published + * + * @return This builder. + */ + public Builder clearPublished() { + this.fieldSetFlags()[3] = false; + return this; + } + + @Override + @SuppressWarnings("unchecked") + public Product build() { + try { + final Product record = new Product(); + record.distributable_product_id = + this.fieldSetFlags()[0] ? this.distributable_product_id : (java.util.UUID) this.defaultValue(this.fields()[0]); + record.comment = this.fieldSetFlags()[1] ? this.comment : (CharSequence) this.defaultValue(this.fields()[1]); + record.assignment_type = this.fieldSetFlags()[2] ? this.assignment_type : (CharSequence) this.defaultValue(this.fields()[2]); + record.published = this.fieldSetFlags()[3] ? this.published : (Boolean) this.defaultValue(this.fields()[3]); + return record; + } catch (final org.apache.avro.AvroMissingFieldException e) { + throw e; + } catch (final Exception e) { + throw new org.apache.avro.AvroRuntimeException(e); + } + } + } + + @SuppressWarnings("unchecked") + private static final org.apache.avro.io.DatumWriter WRITER$ = + (org.apache.avro.io.DatumWriter) MODEL$.createDatumWriter(SCHEMA$); + + @Override + public void writeExternal(final java.io.ObjectOutput out) + throws java.io.IOException { + WRITER$.write(this, SpecificData.getEncoder(out)); + } + + @SuppressWarnings("unchecked") + private static final org.apache.avro.io.DatumReader READER$ = + (org.apache.avro.io.DatumReader) MODEL$.createDatumReader(SCHEMA$); + + @Override + public void readExternal(final java.io.ObjectInput in) + throws java.io.IOException { + READER$.read(this, SpecificData.getDecoder(in)); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/pom.xml b/code/scs-outbox-libs/scs-outbox-core/pom.xml new file mode 100644 index 0000000..a226953 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + dev.inditex.scsoutbox + scs-outbox-libs + 1.0.0-SNAPSHOT + + + scs-outbox-core + + + + + org.projectlombok + lombok + + + org.aspectj + aspectjweaver + + + org.springframework + spring-messaging + + + org.springframework.cloud + spring-cloud-stream + + + net.javacrumbs.shedlock + shedlock-spring + + + org.springframework.kafka + spring-kafka + + + org.springframework.cloud + spring-cloud-context + + + org.jspecify + jspecify + + + io.micrometer + micrometer-core + true + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.junit.jupiter + junit-jupiter + test + + + org.springframework.boot + spring-boot-starter-test + test + + + ch.qos.logback + logback-classic + + + + + + diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/MessageCaptureTxService.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/MessageCaptureTxService.java new file mode 100755 index 0000000..e9de0c9 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/MessageCaptureTxService.java @@ -0,0 +1,32 @@ +package dev.inditex.scsoutbox; + +import java.time.Instant; +import java.util.UUID; + +import io.micrometer.core.annotation.Timed; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.Message; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +public class MessageCaptureTxService { + + private final OutboxMessageRepository repository; + + private final OutboxServiceProperties outboxServiceProperties; + + @Timed("outbox.capture.time") + @Transactional(propagation = Propagation.MANDATORY) + public void capture(final String bindingName, final Message msg) { + final OutboxMessage message = OutboxMessage.builder() + .id(UUID.randomUUID()) + .capturedAt(Instant.now()) + .destination(this.outboxServiceProperties.getDestination(bindingName)) + .bindingName(bindingName) + .headers(msg.getHeaders()) + .payload(msg.getPayload()) + .build(); + this.repository.save(message); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/OutboxMessage.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/OutboxMessage.java new file mode 100755 index 0000000..b529641 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/OutboxMessage.java @@ -0,0 +1,41 @@ +package dev.inditex.scsoutbox; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +@ToString +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class OutboxMessage { + + @EqualsAndHashCode.Include + @NonNull + private final UUID id; + + @NonNull + private final String destination; + + @NonNull + private final Object payload; + + @NonNull + private final Map headers; + + @NonNull + private final Instant capturedAt; + + @NonNull + private final String bindingName; + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/OutboxMessageRepository.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/OutboxMessageRepository.java new file mode 100755 index 0000000..5157944 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/OutboxMessageRepository.java @@ -0,0 +1,43 @@ +package dev.inditex.scsoutbox; + +import java.util.List; +import java.util.Set; + +public interface OutboxMessageRepository { + + int UNLIMITED = 0; + + /** + * Find all messages ordered by capturedAt. + * + * @param maxResults Maximum number of messages to return. use {@link #UNLIMITED} to unlimited messages + */ + List findAllOrderByCapturedAt(final int maxResults); + + /** + * Find all messages ordered by capturedAt, excluding specified destinations. + * + * @param excludedDestinations Set of destinations to exclude from the results + * @param maxResults Maximum number of messages to return. use {@link #UNLIMITED} to unlimited messages + */ + List findAllOrderByCapturedAtExcludingDestinations(final Set excludedDestinations, final int maxResults); + + /** + * Returns the number of messages in the outbox. + * + * @return the number of messages in the outbox + */ + long count(); + + /** + * Returns an estimated count of the messages in the outbox or exactly count if it is not possible. + * + * @return the estimated count of the messages in the outbox + */ + long estimatedCount(); + + void save(final OutboxMessage outboxMessage); + + void delete(final OutboxMessage outboxMessage); + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/OutboxServiceProperties.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/OutboxServiceProperties.java new file mode 100644 index 0000000..94939ed --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/OutboxServiceProperties.java @@ -0,0 +1,100 @@ +package dev.inditex.scsoutbox; + +import java.util.LinkedList; +import java.util.List; + +import dev.inditex.scsoutbox.config.BindingMatcher; +import dev.inditex.scsoutbox.config.OutboxProperties; +import dev.inditex.scsoutbox.config.OutboxProperties.Bindings; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.cloud.stream.config.BindingServiceProperties; + +@Slf4j +@RequiredArgsConstructor +public class OutboxServiceProperties implements InitializingBean { + + private final OutboxProperties properties; + + private final BindingServiceProperties bindingServiceProperties; + + /** + * Determines whether the outbox is enabled for the given Spring Cloud Stream binding name. + * + *

Evaluation rules (in order):

  1. If both {@code inclusions} and {@code exclusions} are empty, outbox is enabled for all + * bindings (default behaviour).
  2. If {@code inclusions} is empty, outbox is enabled unless the binding matches any entry in + * {@code exclusions}.
  3. Otherwise, outbox is enabled only if the binding matches at least one entry in {@code inclusions} AND does + * not match any entry in {@code exclusions}. Exclusions always take precedence.
+ * + *

Each entry in {@code inclusions} / {@code exclusions} is represented by a {@link dev.inditex.scsoutbox.config.BindingMatcher} that + * performs either an exact {@link String#equals} comparison or a full Java-regex match (when the entry is prefixed with + * {@code "regex:"}). + * + *

Caching: results are intentionally not memoised. The per-call cost (~6–137 ns depending on configuration) + * is negligible compared to the overall cost of the outbox operation (~1–50 ms), and caching would introduce stale-state risk if + * {@code OutboxProperties} ever becomes {@code @RefreshScope}-aware. See ADR-0001 for the full analysis and rationale. + * + * @param bindingName the Spring Cloud Stream binding name to evaluate + * @return {@code true} if the outbox should intercept messages for this binding, {@code false} otherwise + */ + public boolean isOutboxEnabledFor(final String bindingName) { + final Bindings bindings = this.properties.getBindings(); + if (bindings.getInclusions().isEmpty() && bindings.getExclusions().isEmpty()) { + // Default behaviour + return true; + } else if (bindings.getInclusions().isEmpty()) { + return bindings.getExclusions().stream().noneMatch(m -> m.matches(bindingName)); + } + return bindings.getInclusions().stream().anyMatch(m -> m.matches(bindingName)) + && bindings.getExclusions().stream().noneMatch(m -> m.matches(bindingName)); + } + + public String getDestination(final String bindingName) { + return this.bindingServiceProperties.getBindingProperties(bindingName).getDestination(); + } + + public boolean useNativeEncoding(final String bindingName) { + return this.bindingServiceProperties.getProducerProperties(bindingName).isUseNativeEncoding(); + } + + @Override + public void afterPropertiesSet() throws Exception { + final List bindingNames = List.copyOf(this.bindingServiceProperties.getBindings().keySet()); + + // Validate exact (non-regex) entries exist in SCS bindings configuration + final List exactInclusions = this.properties.getBindings().getInclusions().stream() + .filter(m -> !m.isRegex()) + .map(BindingMatcher::getRawValue) + .collect(LinkedList::new, LinkedList::add, LinkedList::addAll); + final List exactExclusions = this.properties.getBindings().getExclusions().stream() + .filter(m -> !m.isRegex()) + .map(BindingMatcher::getRawValue) + .collect(LinkedList::new, LinkedList::add, LinkedList::addAll); + + exactInclusions.removeAll(bindingNames); + exactExclusions.removeAll(bindingNames); + + if (!exactInclusions.isEmpty() || !exactExclusions.isEmpty()) { + throw new IllegalArgumentException( + "Binding names not detected in spring cloud stream bindings configuration." + + " Inclusions [ " + exactInclusions + "]" + + " Exclusions [ " + exactExclusions + "]"); + } + + // Warn about regex patterns that don't match any declared binding + this.properties.getBindings().getInclusions().stream() + .filter(BindingMatcher::isRegex) + .filter(m -> bindingNames.stream().noneMatch(m::matches)) + .forEach(m -> log.warn( + "Regex inclusion pattern '{}' does not match any declared Spring Cloud Stream binding.", m.getRawValue())); + + this.properties.getBindings().getExclusions().stream() + .filter(BindingMatcher::isRegex) + .filter(m -> bindingNames.stream().noneMatch(m::matches)) + .forEach(m -> log.warn( + "Regex exclusion pattern '{}' does not match any declared Spring Cloud Stream binding.", m.getRawValue())); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/config/BindingMatcher.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/config/BindingMatcher.java new file mode 100644 index 0000000..ccdeb7c --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/config/BindingMatcher.java @@ -0,0 +1,98 @@ +package dev.inditex.scsoutbox.config; + +import java.util.Objects; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * Encapsulates the logic for matching a binding name against either an exact name or a regular expression. + * + *

Entries prefixed with {@value #REGEX_PREFIX} are treated as Java-style regular expressions. All other entries are treated as exact + * binding names. + */ +public class BindingMatcher { + + public static final String REGEX_PREFIX = "regex:"; + + private final String rawValue; + + private final Pattern compiledPattern; + + private final boolean regex; + + /** + * Creates a new {@link BindingMatcher} from the given raw value. + * + * @param rawValue plain binding name or a {@code regex:}-prefixed regular expression + * @throws IllegalArgumentException if the regular expression syntax is invalid + */ + public BindingMatcher(final String rawValue) { + Objects.requireNonNull(rawValue, "Binding matcher value must not be null"); + this.rawValue = rawValue; + if (rawValue.startsWith(REGEX_PREFIX)) { + this.regex = true; + final String pattern = rawValue.substring(REGEX_PREFIX.length()); + try { + this.compiledPattern = Pattern.compile(pattern); + } catch (final PatternSyntaxException e) { + throw new IllegalArgumentException( + "Invalid regex pattern in binding configuration: '" + pattern + "'. " + e.getDescription(), e); + } + } else { + this.regex = false; + this.compiledPattern = null; + } + } + + /** + * Returns {@code true} if the given binding name matches this matcher. + * + *

For exact matchers the comparison is performed with {@link String#equals(Object)}. For regex matchers the full binding name must + * match the compiled pattern. + * + * @param bindingName the binding name to test + * @return {@code true} when the binding name matches + */ + public boolean matches(final String bindingName) { + if (this.regex) { + return this.compiledPattern.matcher(bindingName).matches(); + } + return this.rawValue.equals(bindingName); + } + + /** + * Returns {@code true} if this matcher was created from a {@code regex:}-prefixed value. + */ + public boolean isRegex() { + return this.regex; + } + + /** + * Returns the original raw value used to create this matcher (including the {@code regex:} prefix when applicable). + */ + public String getRawValue() { + return this.rawValue; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || this.getClass() != o.getClass()) { + return false; + } + final BindingMatcher that = (BindingMatcher) o; + return Objects.equals(this.rawValue, that.rawValue); + } + + @Override + public int hashCode() { + return Objects.hash(this.rawValue); + } + + @Override + public String toString() { + return this.rawValue; + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/config/OutboxAutoConfiguration.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/config/OutboxAutoConfiguration.java new file mode 100755 index 0000000..0dcf128 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/config/OutboxAutoConfiguration.java @@ -0,0 +1,176 @@ +package dev.inditex.scsoutbox.config; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import dev.inditex.scsoutbox.MessageCaptureTxService; +import dev.inditex.scsoutbox.OutboxMessageRepository; +import dev.inditex.scsoutbox.OutboxServiceProperties; +import dev.inditex.scsoutbox.interceptor.MessageChannelAccessor; +import dev.inditex.scsoutbox.interceptor.OutboxChannelInterceptor; +import dev.inditex.scsoutbox.publish.DestinationGroupingKeyGenerator; +import dev.inditex.scsoutbox.publish.GroupingKeyGenerator; +import dev.inditex.scsoutbox.publish.GroupingStrategy; +import dev.inditex.scsoutbox.publish.KafkaKeyGroupingKeyGenerator; +import dev.inditex.scsoutbox.publish.KeyGroupingStrategy; +import dev.inditex.scsoutbox.publish.OutboxMessageConverter; +import dev.inditex.scsoutbox.publish.OutboxMessagePublisher; +import dev.inditex.scsoutbox.publish.OutboxMessagePublisherInterceptor; +import dev.inditex.scsoutbox.publish.OutboxMessageSender; +import dev.inditex.scsoutbox.publish.OutboxPublishingTask; +import dev.inditex.scsoutbox.publish.ParallelPublisher; +import dev.inditex.scsoutbox.publish.StreamBridgeOutboxMessageSender; +import dev.inditex.scsoutbox.publish.config.PublishingProperties; +import dev.inditex.scsoutbox.scheduler.AfterCommitTrigger; +import dev.inditex.scsoutbox.scheduler.OutboxScheduledService; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.integration.config.GlobalChannelInterceptor; + +@Slf4j +@AutoConfiguration +@EnableConfigurationProperties({OutboxProperties.class, PublishingProperties.class}) +public class OutboxAutoConfiguration { + + /** + * Bean name for the repository used for message capture during application transactions. + */ + public static final String OUTBOX_MESSAGE_REPOSITORY_BEAN_NAME = "outboxMessageRepository"; + + /** + * Bean name for the repository used for message publishing during scheduled tasks. + */ + public static final String PUBLISHING_OUTBOX_MESSAGE_REPOSITORY_BEAN_NAME = "publishingOutboxMessageRepository"; + + private static final String OUTBOX_EXECUTOR_SERVICE_BEAN_NAME = "outboxExecutorService"; + + private static final String DEFAULT_OUTBOX_EXECUTOR_SERVICE_BEAN_NAME = "defaultOutboxExecutorService"; + + @Bean + @GlobalChannelInterceptor + public OutboxChannelInterceptor outboxChannelInterceptor( + final MessageCaptureTxService messageCaptureTxService, + final MessageChannelAccessor messageChannelAccessor, + final OutboxServiceProperties outboxServiceProperties) { + return new OutboxChannelInterceptor(messageCaptureTxService, messageChannelAccessor, outboxServiceProperties); + } + + @Bean + public MessageChannelAccessor messageChannelAccessor(final @Value("${spring.application.name:}") String appName) { + return new MessageChannelAccessor(appName); + } + + @Bean + public OutboxServiceProperties scsOutboxServiceProperties( + final OutboxProperties outboxProperties, + final BindingServiceProperties bindingServiceProperties) { + return new OutboxServiceProperties(outboxProperties, bindingServiceProperties); + } + + @Bean + public MessageCaptureTxService messageCaptureTxService( + @Qualifier(OUTBOX_MESSAGE_REPOSITORY_BEAN_NAME) final OutboxMessageRepository repository, + final OutboxServiceProperties outboxServiceProperties) { + return new MessageCaptureTxService( + repository, outboxServiceProperties); + } + + @Bean + @ConditionalOnProperty(value = "app.scheduling.enable", havingValue = "true", matchIfMissing = true) + public OutboxScheduledService scsOutboxScheduledService(final OutboxPublishingTask outboxPublishingTask) { + return new OutboxScheduledService(outboxPublishingTask); + } + + @Bean + public OutboxMessageSender outboxMessageSender( + final BindingServiceProperties bindingServiceProperties, + final StreamBridge streamBridge) { + return new StreamBridgeOutboxMessageSender(streamBridge, bindingServiceProperties); + } + + /** + * Registers a custom {@link org.springframework.messaging.converter.MessageConverter} that converts an {@code OutboxMessage} with a raw + * {@code byte[]} payload into a broker-ready message. The converter is exclusively targeted via a custom MIME type + * ({@code application/x-scs-outbox-raw}) that no other SCS converter recognizes. It extracts the raw bytes and captured headers + * (including the original content type) from the {@code OutboxMessage} so the binder sends the correct content type to the broker. + */ + @Bean + public OutboxMessageConverter outboxMessageConverter() { + return new OutboxMessageConverter(); + } + + @Bean + public OutboxMessagePublisher outboxMessagePublisher( + final OutboxMessageSender messageSender, + @Qualifier(PUBLISHING_OUTBOX_MESSAGE_REPOSITORY_BEAN_NAME) final OutboxMessageRepository publishingRepository, + final List interceptors) { + return new OutboxMessagePublisher(messageSender, publishingRepository, interceptors); + } + + @Bean + public OutboxPublishingTask outboxPublishingTask( + @Qualifier(PUBLISHING_OUTBOX_MESSAGE_REPOSITORY_BEAN_NAME) final OutboxMessageRepository publishingRepository, + @Qualifier(OUTBOX_EXECUTOR_SERVICE_BEAN_NAME) final ExecutorService executorService, + final OutboxMessagePublisher messagePublisher, + final PublishingProperties publishingProperties, + GroupingStrategy groupingStrategy) { + final ParallelPublisher parallelPublisher = new ParallelPublisher(executorService, messagePublisher); + return new OutboxPublishingTask(publishingRepository, parallelPublisher, groupingStrategy, publishingProperties); + } + + @ConditionalOnProperty(value = "scs-outbox.publishing.after-commit", havingValue = "true", matchIfMissing = false) + @Bean + public AfterCommitTrigger afterCommitTrigger( + final ApplicationEventPublisher applicationEventPublisher, + final OutboxScheduledService scsOutboxScheduledService) { + return new AfterCommitTrigger(applicationEventPublisher, scsOutboxScheduledService); + } + + /** + * Default executor service. + */ + @ConditionalOnMissingBean + @Bean(name = {DEFAULT_OUTBOX_EXECUTOR_SERVICE_BEAN_NAME, OUTBOX_EXECUTOR_SERVICE_BEAN_NAME}) + public ExecutorService defaultOutboxExecutorService() { + return Executors.newCachedThreadPool(); + } + + @ConditionalOnMissingBean(name = {OUTBOX_EXECUTOR_SERVICE_BEAN_NAME}) + @Bean(OUTBOX_EXECUTOR_SERVICE_BEAN_NAME) + public ExecutorService outboxExecutorService( + ExecutorService candidateExecutorServices) { + return candidateExecutorServices; + } + + @Bean + public GroupingStrategy groupingStrategy( + PublishingProperties publishingProperties, + @Autowired(required = false) GroupingKeyGenerator customGroupingKeyGenerator) { + + final GroupingKeyGenerator groupingKeyGenerator = switch (publishingProperties.getGroupingStrategy()) { + case DESTINATION -> new DestinationGroupingKeyGenerator(); + case KAFKA_MESSAGE_KEY -> new KafkaKeyGroupingKeyGenerator(); + case CUSTOM_GROUPING_KEY -> { + Objects.requireNonNull(customGroupingKeyGenerator, + "GroupingKeyGenerator bean must be provided when using CUSTOM_GROUPING_KEY"); + yield customGroupingKeyGenerator; + } + }; + log.info("Using {} as grouping strategy", groupingKeyGenerator.getClass().getSimpleName()); + return new KeyGroupingStrategy(groupingKeyGenerator); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/config/OutboxProperties.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/config/OutboxProperties.java new file mode 100644 index 0000000..05ecbee --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/config/OutboxProperties.java @@ -0,0 +1,53 @@ +package dev.inditex.scsoutbox.config; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@ConfigurationProperties("scs-outbox") +public class OutboxProperties { + + private final Bindings bindings; + + public OutboxProperties(final Bindings bindings) { + this.bindings = Objects.requireNonNullElseGet(bindings, () -> new Bindings(List.of(), List.of())); + } + + @Getter + public static class Bindings { + private final List inclusions = new ArrayList<>(); + + private final List exclusions = new ArrayList<>(); + + public Bindings(final List inclusions, final List exclusions) { + if (inclusions != null) { + inclusions.stream().map(BindingMatcher::new).forEach(this.inclusions::add); + } + if (exclusions != null) { + exclusions.stream().map(BindingMatcher::new).forEach(this.exclusions::add); + } + this.validateNoExactConflicts(); + } + + private void validateNoExactConflicts() { + final List exactInclusions = this.inclusions.stream() + .filter(m -> !m.isRegex()) + .map(BindingMatcher::getRawValue) + .toList(); + final List exactExclusions = this.exclusions.stream() + .filter(m -> !m.isRegex()) + .map(BindingMatcher::getRawValue) + .toList(); + final boolean hasConflict = exactInclusions.stream().anyMatch(exactExclusions::contains); + if (hasConflict) { + throw new IllegalArgumentException( + "inclusion list cannot contain any element of exclusion list. Inclusions: " + + this.inclusions + " Exclusions: " + this.exclusions); + } + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/interceptor/MessageChannelAccessor.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/interceptor/MessageChannelAccessor.java new file mode 100644 index 0000000..b0c0622 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/interceptor/MessageChannelAccessor.java @@ -0,0 +1,25 @@ +package dev.inditex.scsoutbox.interceptor; + +import lombok.RequiredArgsConstructor; +import org.springframework.integration.channel.AbstractMessageChannel; +import org.springframework.messaging.MessageChannel; +import org.springframework.util.StringUtils; + +@RequiredArgsConstructor +public class MessageChannelAccessor { + + private final String appName; + + /** + * In spring cloud stream 4.0.5 getFullChannelName return bindingName but in spring cloud stream 4.1.1 getFullChannelName return the. + * following pattern: ${spring.application.name}.[bindingName] + */ + public String getBindingName(final MessageChannel messageChannel) { + final String fullChannelName = ((AbstractMessageChannel) messageChannel).getFullChannelName(); + if (StringUtils.hasText(this.appName)) { + return fullChannelName.replace(this.appName + ".", ""); + } + return fullChannelName; + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/interceptor/OutboxChannelInterceptor.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/interceptor/OutboxChannelInterceptor.java new file mode 100755 index 0000000..1c8e387 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/interceptor/OutboxChannelInterceptor.java @@ -0,0 +1,63 @@ +package dev.inditex.scsoutbox.interceptor; + +import static dev.inditex.scsoutbox.publish.StreamBridgeOutboxMessageSender.SCS_OUTBOX_PUBLISH_MARK_HEADER; + +import dev.inditex.scsoutbox.MessageCaptureTxService; +import dev.inditex.scsoutbox.OutboxServiceProperties; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.ErrorMessage; +import org.springframework.messaging.support.MessageBuilder; + +@Slf4j +@RequiredArgsConstructor +@NullMarked +public class OutboxChannelInterceptor implements ChannelInterceptor { + + private final MessageCaptureTxService messageCaptureTxService; + + private final MessageChannelAccessor messageChannelAccessor; + + private final OutboxServiceProperties outboxServiceProperties; + + @Override + public @Nullable Message preSend(final Message message, final MessageChannel channel) { + final String bindingName = this.messageChannelAccessor.getBindingName(channel); + + // ErrorMessages are not allowed to be processed by outbox. + // ErrorMessages are system-level error handling messages from Spring Cloud Stream/Integration + // and should not be part of the transactional outbox pattern. + if (message instanceof ErrorMessage) { + log.debug("Skipping ErrorMessage from outbox processing for channel: {}", bindingName); + return message; + } + + if (this.isMarked(message)) { + return cleanMark(message); + } + if (this.outboxServiceProperties.isOutboxEnabledFor(bindingName)) { + this.messageCaptureTxService.capture(bindingName, message); + return null; + // return null because we need to stop the publishing message flow. + } + return message; + } + + private static Message cleanMark(final Message message) { + return MessageBuilder + .fromMessage(message) + .removeHeader(SCS_OUTBOX_PUBLISH_MARK_HEADER) + .build(); + } + + private boolean isMarked(final Message message) { + return message.getHeaders().containsKey(SCS_OUTBOX_PUBLISH_MARK_HEADER); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/DestinationGroupingKeyGenerator.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/DestinationGroupingKeyGenerator.java new file mode 100644 index 0000000..dc1c0a0 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/DestinationGroupingKeyGenerator.java @@ -0,0 +1,17 @@ +package dev.inditex.scsoutbox.publish; + +import org.jspecify.annotations.NullMarked; + +/** + * Generates a grouping key based on the destination value from the provided {@link GroupingValues}. This implementation of + * {@link GroupingKeyGenerator} uses the destination as the grouping key, which can be useful for partitioning or routing messages by their + * destination. + */ +@NullMarked +public class DestinationGroupingKeyGenerator implements GroupingKeyGenerator { + + @Override + public GroupingKey generate(GroupingValues values) { + return GroupingKey.of(values.getDestination()); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/GroupingKey.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/GroupingKey.java new file mode 100644 index 0000000..a337fd0 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/GroupingKey.java @@ -0,0 +1,18 @@ +package dev.inditex.scsoutbox.publish; + +import lombok.EqualsAndHashCode; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +import org.jspecify.annotations.NullMarked; + +@RequiredArgsConstructor(staticName = "of") +@EqualsAndHashCode +@ToString +@NullMarked +public class GroupingKey { + private final String value; + + public String asString() { + return this.value; + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/GroupingKeyGenerator.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/GroupingKeyGenerator.java new file mode 100644 index 0000000..403eb7d --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/GroupingKeyGenerator.java @@ -0,0 +1,14 @@ +package dev.inditex.scsoutbox.publish; + +/** + * Strategy interface for generating a grouping key based on provided grouping values. Implementations of this interface define how to + * create a {@link GroupingKey} from the given {@link GroupingValues}, which can be used for partitioning, routing, or grouping messages in + * messaging systems. + * + *

See also:

  • {@link DestinationGroupingKeyGenerator}
  • {@link KafkaKeyGroupingKeyGenerator}
+ */ +public interface GroupingKeyGenerator { + + GroupingKey generate(GroupingValues values); + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/GroupingStrategy.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/GroupingStrategy.java new file mode 100644 index 0000000..1229cb3 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/GroupingStrategy.java @@ -0,0 +1,10 @@ +package dev.inditex.scsoutbox.publish; + +import java.util.List; +import java.util.Map; + +import dev.inditex.scsoutbox.OutboxMessage; + +public interface GroupingStrategy { + Map> group(List messages); +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/GroupingValues.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/GroupingValues.java new file mode 100644 index 0000000..dd2278e --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/GroupingValues.java @@ -0,0 +1,30 @@ +package dev.inditex.scsoutbox.publish; + +import java.util.Map; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.jspecify.annotations.NullMarked; + +@Getter +@EqualsAndHashCode +@ToString +@NullMarked +public class GroupingValues { + private final String destination; + + private final String bindingName; + + private final Map messageHeaders; + + private GroupingValues(String destination, String bindingName, Map messageHeaders) { + this.destination = destination; + this.bindingName = bindingName; + this.messageHeaders = Map.copyOf(messageHeaders); + } + + public static GroupingValues of(String destination, String bindingName, Map messageHeaders) { + return new GroupingValues(destination, bindingName, messageHeaders); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/KafkaKeyGroupingKeyGenerator.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/KafkaKeyGroupingKeyGenerator.java new file mode 100644 index 0000000..1705987 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/KafkaKeyGroupingKeyGenerator.java @@ -0,0 +1,20 @@ +package dev.inditex.scsoutbox.publish; + +import org.jspecify.annotations.NullMarked; +import org.springframework.kafka.support.KafkaHeaders; + +/** + * Generates a grouping key for Kafka messages by combining the destination and the Kafka message key. This implementation of + * {@link GroupingKeyGenerator} is useful for partitioning or routing messages in Kafka based on both the destination and the message key, + * ensuring consistent grouping. + */ +@NullMarked +public class KafkaKeyGroupingKeyGenerator implements GroupingKeyGenerator { + + @Override + public GroupingKey generate(GroupingValues values) { + return GroupingKey.of( + values.getDestination() + "-" + values.getMessageHeaders().get(KafkaHeaders.KEY)); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/KeyGroupingStrategy.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/KeyGroupingStrategy.java new file mode 100644 index 0000000..24c120a --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/KeyGroupingStrategy.java @@ -0,0 +1,27 @@ +package dev.inditex.scsoutbox.publish; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import dev.inditex.scsoutbox.OutboxMessage; + +import lombok.RequiredArgsConstructor; +import org.jspecify.annotations.NullMarked; + +@RequiredArgsConstructor +@NullMarked +public class KeyGroupingStrategy implements GroupingStrategy { + + private final GroupingKeyGenerator groupingKeyGenerator; + + @Override + public Map> group(List messages) { + return messages.stream().collect(Collectors.groupingBy( + message -> this.groupingKeyGenerator.generate( + GroupingValues.of( + message.getDestination(), + message.getBindingName(), + message.getHeaders())))); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/MessageNotPublishedException.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/MessageNotPublishedException.java new file mode 100644 index 0000000..01e3228 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/MessageNotPublishedException.java @@ -0,0 +1,8 @@ +package dev.inditex.scsoutbox.publish; + +public class MessageNotPublishedException extends RuntimeException { + + public MessageNotPublishedException(final String message) { + super(message); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxMessageConverter.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxMessageConverter.java new file mode 100644 index 0000000..f416884 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxMessageConverter.java @@ -0,0 +1,67 @@ +package dev.inditex.scsoutbox.publish; + +import dev.inditex.scsoutbox.OutboxMessage; + +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.MimeType; + +/** + * A Spring {@link MessageConverter} that converts an {@link OutboxMessage} with a raw {@code byte[]} payload into a broker-ready + * {@link Message} — extracting the raw bytes, copying the captured headers, and preserving the original content type. + * + *

This converter centralizes the conversion logic for the raw passthrough flow. The {@link StreamBridgeOutboxMessageSender} wraps the + * {@code OutboxMessage} in a {@code Message} (with the publish mark header for the interceptor) and delegates to + * {@code StreamBridge.send()} with the custom MIME type. After the interceptor lets the message through, this converter handles the + * transformation from domain object to wire-ready message. + * + *

This converter declares support exclusively for the custom {@link #SCS_OUTBOX_MESSAGE_MIME_TYPE} + * ({@code application/x-scs-outbox-raw}). Because no other converter in the SCS {@code CompositeMessageConverter} chain recognizes this + * MIME type, this converter is guaranteed to be the only one that processes the message — regardless of its position in the chain. + * + *

When converting, the converter:

  1. Verifies the payload is an {@link OutboxMessage} with a {@code byte[]} payload
  2. + *
  3. Extracts the raw bytes from the {@code OutboxMessage}
  4. Copies the captured message headers from the {@code OutboxMessage}, + * which already contain the original {@code contentType} properly resolved by the headers mapper
  5. Adds the + * {@link StreamBridgeOutboxMessageSender#SCS_OUTBOX_PUBLISH_MARK_HEADER} so the + * {@link dev.inditex.scsoutbox.interceptor.OutboxChannelInterceptor} recognizes the converted message as outbox-published and does not + * capture it again
+ * + *

This converter is only registered when {@code scs-outbox.use-scs-encoding=true}. + */ +@Slf4j +public class OutboxMessageConverter implements MessageConverter { + + /** + * Custom MIME type used as a signal to route messages through this converter. No other SCS converter recognizes this type, so the + * {@code CompositeMessageConverter} will skip all built-in converters and delegate to this one. + */ + public static final MimeType SCS_OUTBOX_MESSAGE_MIME_TYPE = MimeType.valueOf("application/scs-outbox-message"); + + @Override + public @Nullable Object fromMessage(final Message message, final Class targetClass) { + // This converter is outbound-only; inbound conversion is not supported + return null; + } + + @Override + public @Nullable Message toMessage(final Object payload, final @Nullable MessageHeaders headers) { + if (payload instanceof OutboxMessage outboxMessage && outboxMessage.getPayload() instanceof byte[]) { + log.debug("Raw passthrough: converting OutboxMessage [{}] with contentType [{}]", + outboxMessage.getId(), outboxMessage.getHeaders().get(MessageHeaders.CONTENT_TYPE)); + return MessageBuilder + .withPayload(outboxMessage.getPayload()) + // copy original message headers + .copyHeaders(outboxMessage.getHeaders()) + // copy additional headers without overwriting, like, SCS_OUTBOX_PUBLISH_MARK_HEADER + .copyHeadersIfAbsent(headers) + // .setHeader(StreamBridgeOutboxMessageSender.SCS_OUTBOX_PUBLISH_MARK_HEADER, "") + .build(); + } + return null; + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxMessagePublisher.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxMessagePublisher.java new file mode 100755 index 0000000..d4cff3b --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxMessagePublisher.java @@ -0,0 +1,38 @@ +package dev.inditex.scsoutbox.publish; + +import java.util.List; + +import dev.inditex.scsoutbox.OutboxMessage; +import dev.inditex.scsoutbox.OutboxMessageRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Slf4j +public class OutboxMessagePublisher { + + private final OutboxMessageSender messageSender; + + private final OutboxMessageRepository outboxMessageRepository; + + private final List interceptors; + + @Transactional + public void publish(final OutboxMessage message) { + final boolean sent = this.messageSender.send(message); + if (!sent) { + throw new MessageNotPublishedException( + "message [" + message.getId() + "] not published."); + } + this.postSend(message); + this.outboxMessageRepository.delete(message); + log.info("message [" + message.getId() + "] published. " + message); + } + + private void postSend(final OutboxMessage message) { + this.interceptors.forEach(interceptor -> interceptor.postSend(message)); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxMessagePublisherInterceptor.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxMessagePublisherInterceptor.java new file mode 100644 index 0000000..8d4120c --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxMessagePublisherInterceptor.java @@ -0,0 +1,9 @@ +package dev.inditex.scsoutbox.publish; + +import dev.inditex.scsoutbox.OutboxMessage; + +public interface OutboxMessagePublisherInterceptor { + + void postSend(OutboxMessage outboxMessage); + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxMessageSender.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxMessageSender.java new file mode 100644 index 0000000..c20562c --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxMessageSender.java @@ -0,0 +1,8 @@ +package dev.inditex.scsoutbox.publish; + +import dev.inditex.scsoutbox.OutboxMessage; + +public interface OutboxMessageSender { + + boolean send(final OutboxMessage outboxMessage); +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxPublishingTask.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxPublishingTask.java new file mode 100755 index 0000000..b24fcf7 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxPublishingTask.java @@ -0,0 +1,68 @@ +package dev.inditex.scsoutbox.publish; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import dev.inditex.scsoutbox.OutboxMessage; +import dev.inditex.scsoutbox.OutboxMessageRepository; +import dev.inditex.scsoutbox.publish.config.PublishingProperties; + +import io.micrometer.core.annotation.Timed; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class OutboxPublishingTask { + + private final OutboxMessageRepository outboxMessageRepository; + + private final ParallelPublisher parallelPublisher; + + private final GroupingStrategy groupingStrategy; + + private final PublishingProperties publishingProperties; + + public OutboxPublishingTask(final OutboxMessageRepository repository, final ParallelPublisher parallelPublisher, + final GroupingStrategy groupingStrategy, + final PublishingProperties publishingProperties) { + this.outboxMessageRepository = repository; + this.parallelPublisher = parallelPublisher; + this.groupingStrategy = groupingStrategy; + this.publishingProperties = publishingProperties; + } + + @Timed("outbox.publishing.time") + public OutboxPublishingTaskReport run() { + final Instant start = Instant.now(); + + // Check global pause first to avoid unnecessary processing + if (this.publishingProperties.isPaused()) { + log.warn("Outbox publishing is globally paused. No messages will be processed. " + + "Set scs-outbox.publishing.paused=false to resume publishing."); + return this.createReport(start, 0); + } + + final List messages = this.fetchPendingMessages(); + final Map> group = this.groupingStrategy.group(messages); + final int publishedCount = this.parallelPublisher.publish(group); + return this.createReport(start, publishedCount); + } + + private OutboxPublishingTaskReport createReport(final Instant start, final int publishedCount) { + return OutboxPublishingTaskReport.of(start, Instant.now(), publishedCount); + } + + private List fetchPendingMessages() { + final Set pausedDestinations = this.publishingProperties.getPausedDestinations(); + final int batchSize = this.publishingProperties.getBatchSize(); + + if (pausedDestinations.isEmpty()) { + return this.outboxMessageRepository.findAllOrderByCapturedAt(batchSize); + } else { + log.debug("Fetching pending messages excluding paused destinations: {}", pausedDestinations); + return this.outboxMessageRepository.findAllOrderByCapturedAtExcludingDestinations(pausedDestinations, batchSize); + } + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxPublishingTaskReport.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxPublishingTaskReport.java new file mode 100644 index 0000000..5954383 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/OutboxPublishingTaskReport.java @@ -0,0 +1,44 @@ +package dev.inditex.scsoutbox.publish; + +import java.time.Duration; +import java.time.Instant; + +import lombok.Getter; + +@Getter +public class OutboxPublishingTaskReport { + + private final Duration duration; + + private final int numOfPublishedMessages; + + private final double throughput; + + private OutboxPublishingTaskReport(Instant start, Instant stop, int numOfPublishedMessages) { + if (stop.isBefore(start)) { + throw new IllegalArgumentException( + "stop must be after start. stop: " + stop + " start: " + start + " thread: " + Thread.currentThread().getName()); + } + this.duration = Duration.between(start, stop); + this.numOfPublishedMessages = numOfPublishedMessages; + final double durationInSeconds = this.getDurationInSeconds(); + this.throughput = numOfPublishedMessages / durationInSeconds; + } + + public static OutboxPublishingTaskReport of(Instant start, Instant stop, int numOfPublishedMessages) { + return new OutboxPublishingTaskReport(start, stop, numOfPublishedMessages); + } + + @Override + public String toString() { + return "OutboxPublishingTaskReport" + + "{ duration(sec)=" + this.getDurationInSeconds() + + ", numOfPublishedMessages=" + this.numOfPublishedMessages + + ", throughput(msg/sec)=" + this.throughput + + "}"; + } + + private double getDurationInSeconds() { + return this.duration.toNanos() / 1e9; + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/ParallelPublisher.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/ParallelPublisher.java new file mode 100644 index 0000000..12b0dc4 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/ParallelPublisher.java @@ -0,0 +1,101 @@ +package dev.inditex.scsoutbox.publish; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; + +import dev.inditex.scsoutbox.OutboxMessage; + +import lombok.extern.slf4j.Slf4j; + +/** + * Class that handles parallel publishing of Outbox messages. + */ +@Slf4j +public class ParallelPublisher { + + private final ExecutorService executorService; + + private final OutboxMessagePublisher publisher; + + /** + * ParallelPublisher constructor. + * + * @param executorService execution service to handle parallel tasks + * @param publisher the Outbox message publisher + */ + public ParallelPublisher(ExecutorService executorService, OutboxMessagePublisher publisher) { + this.executorService = executorService; + this.publisher = publisher; + } + + /** + * Publishes grouped messages in parallel and waits for all tasks to complete. + * + * @param groupedMessages a map of messages grouped by key + * @return the total number of published messages + */ + public int publish(Map> groupedMessages) { + final List> futures = this.publishInParallel(groupedMessages); + return this.waitForCompletion(futures); + } + + /** + * Publishes grouped messages in parallel. + * + * @param groupedMessages a map of messages grouped by key + * @return a list of futures representing the publishing tasks + */ + private List> publishInParallel(Map> groupedMessages) { + return groupedMessages.values().stream() + .map(this::sortAndPublish) + .toList(); + } + + /** + * Sorts and publishes a list of messages. + * + * @param messages the list of messages to publish + * @return a future representing the publishing task + */ + private Future sortAndPublish(List messages) { + return this.executorService.submit(() -> { + messages.sort(Comparator.comparing(OutboxMessage::getCapturedAt)); + final AtomicInteger publishedCount = new AtomicInteger(); + try { + for (final OutboxMessage message : messages) { + this.publisher.publish(message); + publishedCount.incrementAndGet(); + } + } catch (final Exception e) { + log.warn("Unexpected error in publishing task", e); + } + return publishedCount.get(); + }); + } + + /** + * Waits for all publishing tasks to complete. + * + * @param futures the list of futures representing the publishing tasks + * @return the total number of published messages + */ + private int waitForCompletion(List> futures) { + final AtomicInteger publishedCount = new AtomicInteger(); + for (final Future future : futures) { + try { + publishedCount.addAndGet(future.get()); + } catch (final InterruptedException e) { + log.warn("InterruptedException caught!", e); + Thread.currentThread().interrupt(); + } catch (final Exception e) { + log.warn("Error retrieving future result", e); + } + } + return publishedCount.get(); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/StreamBridgeOutboxMessageSender.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/StreamBridgeOutboxMessageSender.java new file mode 100644 index 0000000..4e818dd --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/StreamBridgeOutboxMessageSender.java @@ -0,0 +1,67 @@ +package dev.inditex.scsoutbox.publish; + +import dev.inditex.scsoutbox.OutboxMessage; + +import lombok.RequiredArgsConstructor; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.MimeType; + +@RequiredArgsConstructor +public class StreamBridgeOutboxMessageSender implements OutboxMessageSender { + + public static final String SCS_OUTBOX_PUBLISH_MARK_HEADER = "scs-outbox-publish-mark"; + + private final StreamBridge bridge; + + private final BindingServiceProperties bindingServiceProperties; + + public boolean send(final OutboxMessage outboxMessage) { + if (outboxMessage.getPayload() instanceof byte[]) { + return this.sendRaw(outboxMessage); + } + return this.sendDefault(outboxMessage); + } + + /** + * Sends a message with a raw byte[] payload, bypassing Spring Cloud Stream re-serialization. The {@link OutboxMessage} is sent as the + * message payload with the custom {@link OutboxMessageConverter#SCS_OUTBOX_MESSAGE_MIME_TYPE} so that only the + * {@link OutboxMessageConverter} processes it — no other converter in the SCS chain recognizes this MIME type. + * + *

The publish mark header is set on the wrapper {@code Message} so the + * {@link dev.inditex.scsoutbox.interceptor.OutboxChannelInterceptor} recognizes this as an outbox-published message and lets it through + * (instead of capturing it again). The converter then handles the full conversion: extracting the raw bytes and copying the captured + * headers (including the original {@code contentType}). + */ + private boolean sendRaw(final OutboxMessage outboxMessage) { + final Message message = MessageBuilder + .withPayload(outboxMessage) + .setHeader(SCS_OUTBOX_PUBLISH_MARK_HEADER, "") + .build(); + return this.bridge.send(outboxMessage.getBindingName(), message, OutboxMessageConverter.SCS_OUTBOX_MESSAGE_MIME_TYPE); + } + + /** + * Sends a message using the default Spring Cloud Stream pipeline, which will re-serialize the payload. + */ + private boolean sendDefault(final OutboxMessage outboxMessage) { + final MimeType contentType = this.getBindingContentType(outboxMessage.getBindingName()); + + final Message message = MessageBuilder + .withPayload(outboxMessage.getPayload()) + .copyHeaders(outboxMessage.getHeaders()) + .setHeader(SCS_OUTBOX_PUBLISH_MARK_HEADER, "") + .build(); + return this.bridge.send( + outboxMessage.getBindingName(), + message, + contentType); + } + + private MimeType getBindingContentType(final String bindingName) { + final String contentType = this.bindingServiceProperties.getBindingProperties(bindingName).getContentType(); + return MimeType.valueOf(contentType); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/config/PublishingProperties.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/config/PublishingProperties.java new file mode 100644 index 0000000..331f530 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/publish/config/PublishingProperties.java @@ -0,0 +1,46 @@ +package dev.inditex.scsoutbox.publish.config; + +import java.util.Objects; +import java.util.Set; + +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; +import org.springframework.cloud.context.config.annotation.RefreshScope; + +@Getter +@ConfigurationProperties("scs-outbox.publishing") +@RefreshScope +public class PublishingProperties { + + public static final int DEFAULT_BATCH_SIZE = 1000; + + private final int batchSize; + + private final GroupingMode groupingStrategy; + + private final Set pausedDestinations; + + private final boolean paused; + + private final boolean afterCommit; + + public enum GroupingMode { + DESTINATION, + KAFKA_MESSAGE_KEY, + CUSTOM_GROUPING_KEY + } + + @ConstructorBinding + public PublishingProperties(final Integer batchSize, final String groupingStrategy, + final Set pausedDestinations, final Boolean paused, final Boolean afterCommit) { + if (batchSize != null && batchSize <= 0) { + throw new IllegalArgumentException("Batch size must be greater than 0"); + } + this.batchSize = Objects.requireNonNullElse(batchSize, DEFAULT_BATCH_SIZE); + this.groupingStrategy = groupingStrategy != null ? GroupingMode.valueOf(groupingStrategy) : GroupingMode.DESTINATION; + this.pausedDestinations = Objects.requireNonNullElse(pausedDestinations, Set.of()); + this.paused = Objects.requireNonNullElse(paused, false); + this.afterCommit = Objects.requireNonNullElse(afterCommit, false); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/scheduler/AfterCommitTrigger.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/scheduler/AfterCommitTrigger.java new file mode 100644 index 0000000..f8cfb84 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/scheduler/AfterCommitTrigger.java @@ -0,0 +1,38 @@ +package dev.inditex.scsoutbox.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.annotation.After; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Async; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Aspect +@RequiredArgsConstructor +@Slf4j +public class AfterCommitTrigger { + + private final ApplicationEventPublisher applicationEventPublisher; + + private final OutboxScheduledService outboxScheduledService; + + @After( + value = "execution(* dev.inditex.scsoutbox.MessageCaptureTxService.capture(..))") + public void publishMessageCapturedEvent() { + log.debug("Message captured"); + this.applicationEventPublisher.publishEvent(new MessageCaptured() {}); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + public void afterCommit(final MessageCaptured event) { + log.debug("Triggering outbox publishing task after commit. on event: " + event.getClass().getSimpleName()); + this.outboxScheduledService.outboxPublishingTask(); + } + + public interface MessageCaptured { + + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/scheduler/OutboxScheduledService.java b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/scheduler/OutboxScheduledService.java new file mode 100644 index 0000000..c1bc1b0 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/java/dev/inditex/scsoutbox/scheduler/OutboxScheduledService.java @@ -0,0 +1,30 @@ +package dev.inditex.scsoutbox.scheduler; + +import dev.inditex.scsoutbox.publish.OutboxPublishingTask; +import dev.inditex.scsoutbox.publish.OutboxPublishingTaskReport; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; + +@RequiredArgsConstructor +@Slf4j +public class OutboxScheduledService { + + private final OutboxPublishingTask outboxPublishingTask; + + @Scheduled( + cron = "${scs-outbox.publishing.scheduler.cron-expression:}", + fixedRateString = "#{ ('${scs-outbox.publishing.scheduler.cron-expression:}' eq '') ?" + + " ${scs-outbox.publishing.scheduler.fixed-rate:'5000'} :" + + "${scs-outbox.publishing.scheduler.fixed-rate:''}}", + initialDelayString = "${scs-outbox.publishing.scheduler.initial-delay:}") + @SchedulerLock(name = "${scs-outbox.publishing.scheduler.task-name:outboxPublishingTask}", + lockAtMostFor = "${scs-outbox.publishing.scheduler.lock-at-most-for:PT5m}") + public void outboxPublishingTask() { + final OutboxPublishingTaskReport report = this.outboxPublishingTask.run(); + log.debug(report.toString()); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/code/scs-outbox-libs/scs-outbox-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..b84ca48 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +dev.inditex.scsoutbox.config.OutboxAutoConfiguration \ No newline at end of file diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/InMemoryOutboxMessageRepository.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/InMemoryOutboxMessageRepository.java new file mode 100755 index 0000000..2b3641d --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/InMemoryOutboxMessageRepository.java @@ -0,0 +1,70 @@ +package dev.inditex.scsoutbox; + +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +public class InMemoryOutboxMessageRepository implements OutboxMessageRepository { + + private final Map messages = new ConcurrentHashMap(); + + private final List removedMessages = new CopyOnWriteArrayList<>(); + + public void init(final List outboxMessageList) { + this.messages.clear(); + outboxMessageList.forEach( + unpublishedMessage -> this.messages.put(unpublishedMessage.getId(), unpublishedMessage)); + } + + @Override + public void save(final OutboxMessage entity) { + this.messages.put(entity.getId(), entity); + } + + private List findAllOrderByCapturedAt() { + final List values = new LinkedList<>(this.messages.values()); + values.sort((m1, m2) -> m1.getCapturedAt().compareTo(m2.getCapturedAt())); + return List.copyOf(values); + } + + @Override + public List findAllOrderByCapturedAt(int limit) { + if (limit <= 0) { + return this.findAllOrderByCapturedAt(); + } + return this.findAllOrderByCapturedAt().stream().limit(limit).toList(); + } + + @Override + public List findAllOrderByCapturedAtExcludingDestinations(Set excludedDestinations, int limit) { + return this.findAllOrderByCapturedAt().stream() + .filter(msg -> !excludedDestinations.contains(msg.getDestination())) + .limit(limit > 0 ? limit : Long.MAX_VALUE) + .toList(); + } + + @Override + public long count() { + return this.messages.size(); + } + + @Override + public long estimatedCount() { + return this.count(); + } + + @Override + public void delete(final OutboxMessage outboxMessage) { + this.messages.remove(outboxMessage.getId()); + this.removedMessages.add(outboxMessage); + } + + public List getDeleted() { + return List.copyOf(this.removedMessages); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/MessageCaptureTxServiceTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/MessageCaptureTxServiceTest.java new file mode 100755 index 0000000..61c16b8 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/MessageCaptureTxServiceTest.java @@ -0,0 +1,74 @@ +package dev.inditex.scsoutbox; + +import static dev.inditex.scsoutbox.OutboxMessageRepository.UNLIMITED; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; + +class MessageCaptureTxServiceTest { + + private MessageCaptureTxService messageCaptureTxService; + + private InMemoryOutboxMessageRepository repository; + + private OutboxServiceProperties outboxServiceProperties; + + private final String destination = "topicName"; + + private final String bindingName = "bindingName"; + + @BeforeEach + void setUp() { + this.repository = new InMemoryOutboxMessageRepository(); + this.outboxServiceProperties = mock(OutboxServiceProperties.class); + when(this.outboxServiceProperties.getDestination(this.bindingName)).thenReturn(this.destination); + this.messageCaptureTxService = new MessageCaptureTxService( + this.repository, this.outboxServiceProperties); + } + + @Test + void when_message_is_captured_then_message_is_saved_in_repository() { + final Message capturedMessage = MessageBuilder.withPayload(new Object()).build(); + + this.messageCaptureTxService.capture(this.bindingName, capturedMessage); + + final OutboxMessage outboxMessage = this.repository.findAllOrderByCapturedAt(UNLIMITED).stream().findFirst().get(); + assertThat(outboxMessage.getPayload()).isEqualTo(capturedMessage.getPayload()); + assertThat(outboxMessage.getHeaders()).isEqualTo(capturedMessage.getHeaders()); + assertThat(outboxMessage.getDestination()).isEqualTo(this.destination); + assertThat(outboxMessage.getBindingName()).isEqualTo(this.bindingName); + } + + @Test + void when_native_encoding_is_enabled_then_original_payload_is_saved() { + when(this.outboxServiceProperties.useNativeEncoding(this.bindingName)).thenReturn(true); + final Message message = MessageBuilder.withPayload(new Object()).build(); + + this.messageCaptureTxService.capture(this.bindingName, message); + + final OutboxMessage outboxMessage = this.repository.findAllOrderByCapturedAt(UNLIMITED).stream().findFirst().get(); + assertThat(outboxMessage.getPayload()).isEqualTo(message.getPayload()); + assertThat(outboxMessage.getHeaders()).isEqualTo(message.getHeaders()); + assertThat(outboxMessage.getDestination()).isEqualTo(this.destination); + assertThat(outboxMessage.getBindingName()).isEqualTo(this.bindingName); + } + + @Test + void when_scs_encoding_is_enabled_then_original_payload_is_saved() { + final Message message = MessageBuilder.withPayload(new Object()).build(); + + this.messageCaptureTxService.capture(this.bindingName, message); + + final OutboxMessage outboxMessage = this.repository.findAllOrderByCapturedAt(UNLIMITED).stream().findFirst().get(); + assertThat(outboxMessage.getPayload()).isEqualTo(message.getPayload()); + assertThat(outboxMessage.getHeaders()).isEqualTo(message.getHeaders()); + assertThat(outboxMessage.getDestination()).isEqualTo(this.destination); + assertThat(outboxMessage.getBindingName()).isEqualTo(this.bindingName); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/OutboxMessageMother.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/OutboxMessageMother.java new file mode 100755 index 0000000..2e800b4 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/OutboxMessageMother.java @@ -0,0 +1,32 @@ +package dev.inditex.scsoutbox; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import dev.inditex.scsoutbox.OutboxMessage.OutboxMessageBuilder; + +public abstract class OutboxMessageMother { + + public static OutboxMessage anOutboxMessage() { + return OutboxMessage.builder() + .id(UUID.randomUUID()) + .capturedAt(Instant.now()) + .destination("destination") + .bindingName("bindingName") + .payload("payload") + .headers(Map.of()) + .build(); + } + + public static OutboxMessageBuilder anOutboxMessageBuilder() { + return OutboxMessage.builder() + .id(UUID.randomUUID()) + .capturedAt(Instant.now()) + .destination("destination") + .bindingName("bindingName") + .payload("payload") + .headers(Map.of()); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/OutboxMessageTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/OutboxMessageTest.java new file mode 100755 index 0000000..5bb855a --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/OutboxMessageTest.java @@ -0,0 +1,99 @@ +package dev.inditex.scsoutbox; + +import static dev.inditex.scsoutbox.OutboxMessageMother.anOutboxMessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.ThrowableAssert.ThrowingCallable; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class OutboxMessageTest { + + @Nested + class Equals { + + @Test + void when_same_id_expect_equal() { + final byte[] payload = "payload".getBytes(StandardCharsets.UTF_8); + final UUID sameId = UUID.randomUUID(); + final String destination = "destination"; + final Instant capturedAt = Instant.parse("2025-01-01T00:00:00Z"); + final OutboxMessage outboxMessage = OutboxMessage.builder() + .id(sameId) + .payload(payload) + .destination(destination) + .bindingName("bindingName") + .capturedAt(capturedAt) + .headers(Map.of()) + .build(); + final OutboxMessage otherOutboxMessage = OutboxMessage.builder() + .id(sameId) + .payload(payload) + .destination(destination) + .bindingName("bindingName") + .capturedAt(capturedAt) + .headers(Map.of()) + .build(); + + assertThat(outboxMessage).isEqualTo(otherOutboxMessage); + } + + @Test + void when_different_id_expect_not_equal() { + final byte[] payload = "payload".getBytes(StandardCharsets.UTF_8); + final Instant capturedAt = Instant.parse("2025-01-01T00:00:00Z"); + final OutboxMessage outboxMessage = OutboxMessage.builder() + .id(UUID.randomUUID()) + .payload(payload) + .destination("destination") + .bindingName("bindingName") + .capturedAt(capturedAt) + .headers(Map.of()) + .build(); + final OutboxMessage otherOutboxMessage = OutboxMessage.builder() + .id(UUID.randomUUID()) + .payload(payload) + .destination("destination") + .bindingName("bindingName") + .capturedAt(capturedAt) + .headers(Map.of()) + .build(); + + assertThat(outboxMessage).isNotEqualTo(otherOutboxMessage); + } + } + + @Nested + class Build { + + static Stream nullFieldProvider() { + return Stream.of( + Arguments.of("id", (ThrowingCallable) () -> anOutboxMessageBuilder().id(null).build()), + Arguments.of("payload", (ThrowingCallable) () -> anOutboxMessageBuilder().payload(null).build()), + Arguments.of("destination", (ThrowingCallable) () -> anOutboxMessageBuilder().destination(null).build()), + Arguments.of("bindingName", (ThrowingCallable) () -> anOutboxMessageBuilder().bindingName(null).build()), + Arguments.of("capturedAt", (ThrowingCallable) () -> anOutboxMessageBuilder().capturedAt(null).build()), + Arguments.of("headers", (ThrowingCallable) () -> anOutboxMessageBuilder().headers(null).build())); + } + + @ParameterizedTest + @MethodSource("nullFieldProvider") + void when_required_field_is_null_expect_null_pointer_exception(final String fieldName, final ThrowingCallable callable) { + assertThatThrownBy(callable) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining(fieldName + " is marked non-null but is null"); + } + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/OutboxServicePropertiesTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/OutboxServicePropertiesTest.java new file mode 100644 index 0000000..d1c24d6 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/OutboxServicePropertiesTest.java @@ -0,0 +1,292 @@ +package dev.inditex.scsoutbox; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import dev.inditex.scsoutbox.config.OutboxProperties; +import dev.inditex.scsoutbox.config.OutboxProperties.Bindings; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.cloud.stream.binder.ProducerProperties; +import org.springframework.cloud.stream.config.BindingProperties; +import org.springframework.cloud.stream.config.BindingServiceProperties; + +class OutboxServicePropertiesTest { + + private List inclusionsList; + + private List exclusionsList; + + private OutboxServiceProperties outboxServiceProperties; + + private BindingServiceProperties bindingServiceProperties; + + @BeforeEach + void setUp() { + this.inclusionsList = new ArrayList<>(); + this.exclusionsList = new ArrayList<>(); + final Bindings bindings = new Bindings(this.inclusionsList, this.exclusionsList); + final OutboxProperties properties = new OutboxProperties(bindings); + this.bindingServiceProperties = mock(BindingServiceProperties.class); + this.outboxServiceProperties = new OutboxServiceProperties(properties, this.bindingServiceProperties); + + } + + @Test + void enabled_by_default() { + final String bindingName = "bindingName"; + + final boolean enabled = this.outboxServiceProperties.isOutboxEnabledFor(bindingName); + + assertTrue(enabled); + } + + @Test + void enabled_if_inclusion_list_contains_binding_name() { + final String bindingName = "bindingName"; + this.inclusionsList.add(bindingName); + this.updateLists(this.inclusionsList, this.exclusionsList); + + final boolean enabled = this.outboxServiceProperties.isOutboxEnabledFor(bindingName); + + assertTrue(enabled); + } + + @Test + void disabled_if_exclusion_list_contains_binding_name() { + final String bindingName = "bindingName"; + this.exclusionsList.add(bindingName); + this.updateLists(this.inclusionsList, this.exclusionsList); + + final boolean enabled = this.outboxServiceProperties.isOutboxEnabledFor(bindingName); + + assertFalse(enabled); + } + + @Test + void disabled_if_inclusions_list_not_contains_binding_name() { + this.inclusionsList.add("anyBindingName"); + this.updateLists(this.inclusionsList, this.exclusionsList); + + final boolean enabled = this.outboxServiceProperties.isOutboxEnabledFor("anyOtherBindingName"); + + assertFalse(enabled); + } + + @Test + void enabled_if_exclusions_list_not_contains_binding_name() { + this.exclusionsList.add("anyBindingName"); + this.updateLists(this.inclusionsList, this.exclusionsList); + + final boolean enabled = this.outboxServiceProperties.isOutboxEnabledFor("anyOtherBindingName"); + + assertTrue(enabled); + } + + @Test + void disabled_if_exclusions_and_exclusions_list_not_contain_binding_name() { + this.inclusionsList.add("anyBindingName"); + this.exclusionsList.add("anyOtherBindingName"); + this.updateLists(this.inclusionsList, this.exclusionsList); + + final boolean enabled = this.outboxServiceProperties.isOutboxEnabledFor("anotherBindingName"); + + assertFalse(enabled); + } + + @Test + void on_initialize_validate_that_binding_names_are_included_in_scs_binding_names() { + final String includedBindingName = "includedBindingName"; + final String notIncludedBindingName1 = "notIncludedBindingName1"; + final String notIncludedBindingName2 = "notIncludedBindingName2"; + when(this.bindingServiceProperties.getBindings()).thenReturn(Map.of(includedBindingName, new BindingProperties())); + this.inclusionsList.add(includedBindingName); + this.inclusionsList.add(notIncludedBindingName1); + this.exclusionsList.add(notIncludedBindingName2); + this.updateLists(this.inclusionsList, this.exclusionsList); + + final IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> this.outboxServiceProperties.afterPropertiesSet()); + final String exceptionMessage = exception.getMessage(); + assertTrue( + exceptionMessage.contains(notIncludedBindingName1) + && exceptionMessage.contains(notIncludedBindingName2) + && !exceptionMessage.contains(includedBindingName)); + } + + private void updateLists(final List inclusionsList, final List exclusionsList) { + final Bindings bindings = new Bindings(inclusionsList, exclusionsList); + final OutboxProperties properties = new OutboxProperties(bindings); + this.outboxServiceProperties = new OutboxServiceProperties(properties, this.bindingServiceProperties); + } + + @Test + void get_destination_from_binding_name() { + final String bindingName = "bindingName"; + final String destination = "destination"; + final BindingProperties bindingProperties = new BindingProperties(); + bindingProperties.setDestination(destination); + when(this.bindingServiceProperties.getBindingProperties(bindingName)).thenReturn(bindingProperties); + + final String result = this.outboxServiceProperties.getDestination(bindingName); + + assertEquals(destination, result); + } + + @Test + void use_native_encoding_from_binding_name() { + final String bindingName = "bindingName"; + final ProducerProperties producerProperties = new ProducerProperties(); + producerProperties.setUseNativeEncoding(true); + when(this.bindingServiceProperties.getProducerProperties(bindingName)).thenReturn(producerProperties); + + final boolean result = this.outboxServiceProperties.useNativeEncoding(bindingName); + + assertTrue(result); + } + + @Nested + class RegexBindingMatchingTest { + + @Test + void enabled_if_regex_inclusion_matches_binding_name() { + OutboxServicePropertiesTest.this.inclusionsList.add("regex:produce-.*-out-\\d+"); + OutboxServicePropertiesTest.this.updateLists( + OutboxServicePropertiesTest.this.inclusionsList, OutboxServicePropertiesTest.this.exclusionsList); + + final boolean enabled = + OutboxServicePropertiesTest.this.outboxServiceProperties.isOutboxEnabledFor("produce-book-created-out-0"); + + assertTrue(enabled); + } + + @Test + void disabled_if_regex_inclusion_does_not_match_binding_name() { + OutboxServicePropertiesTest.this.inclusionsList.add("regex:produce-.*-out-\\d+"); + OutboxServicePropertiesTest.this.updateLists( + OutboxServicePropertiesTest.this.inclusionsList, OutboxServicePropertiesTest.this.exclusionsList); + + final boolean enabled = + OutboxServicePropertiesTest.this.outboxServiceProperties.isOutboxEnabledFor("consume-book-created-in-0"); + + assertFalse(enabled); + } + + @Test + void disabled_if_regex_exclusion_matches_binding_name() { + OutboxServicePropertiesTest.this.exclusionsList.add("regex:produce-.*-out-\\d+"); + OutboxServicePropertiesTest.this.updateLists( + OutboxServicePropertiesTest.this.inclusionsList, OutboxServicePropertiesTest.this.exclusionsList); + + final boolean enabled = + OutboxServicePropertiesTest.this.outboxServiceProperties.isOutboxEnabledFor("produce-book-created-out-0"); + + assertFalse(enabled); + } + + @Test + void enabled_if_regex_exclusion_does_not_match_binding_name() { + OutboxServicePropertiesTest.this.exclusionsList.add("regex:produce-.*-out-\\d+"); + OutboxServicePropertiesTest.this.updateLists( + OutboxServicePropertiesTest.this.inclusionsList, OutboxServicePropertiesTest.this.exclusionsList); + + final boolean enabled = + OutboxServicePropertiesTest.this.outboxServiceProperties.isOutboxEnabledFor("consume-book-created-in-0"); + + assertTrue(enabled); + } + + @Test + void disabled_if_regex_exclusion_matches_even_when_regex_inclusion_also_matches() { + OutboxServicePropertiesTest.this.inclusionsList.add("regex:produce-.*-out-\\d+"); + OutboxServicePropertiesTest.this.exclusionsList.add("regex:produce-book-.*"); + OutboxServicePropertiesTest.this.updateLists( + OutboxServicePropertiesTest.this.inclusionsList, OutboxServicePropertiesTest.this.exclusionsList); + + final boolean enabled = + OutboxServicePropertiesTest.this.outboxServiceProperties.isOutboxEnabledFor("produce-book-created-out-0"); + + assertFalse(enabled); + } + + @Test + void enabled_with_mixed_exact_and_regex_inclusion() { + OutboxServicePropertiesTest.this.inclusionsList.add("exact-binding-name"); + OutboxServicePropertiesTest.this.inclusionsList.add("regex:produce-.*-out-\\d+"); + OutboxServicePropertiesTest.this.updateLists( + OutboxServicePropertiesTest.this.inclusionsList, OutboxServicePropertiesTest.this.exclusionsList); + + assertTrue( + OutboxServicePropertiesTest.this.outboxServiceProperties.isOutboxEnabledFor("exact-binding-name")); + assertTrue( + OutboxServicePropertiesTest.this.outboxServiceProperties.isOutboxEnabledFor("produce-book-created-out-0")); + assertFalse( + OutboxServicePropertiesTest.this.outboxServiceProperties.isOutboxEnabledFor("other-binding")); + } + + @Test + void disabled_with_exact_exclusion_overriding_regex_inclusion() { + OutboxServicePropertiesTest.this.inclusionsList.add("regex:produce-.*-out-\\d+"); + OutboxServicePropertiesTest.this.exclusionsList.add("produce-book-created-out-0"); + OutboxServicePropertiesTest.this.updateLists( + OutboxServicePropertiesTest.this.inclusionsList, OutboxServicePropertiesTest.this.exclusionsList); + + final boolean enabled = + OutboxServicePropertiesTest.this.outboxServiceProperties.isOutboxEnabledFor("produce-book-created-out-0"); + + assertFalse(enabled); + } + + @Test + void regex_entries_are_not_validated_against_scs_bindings_on_initialize() { + when(OutboxServicePropertiesTest.this.bindingServiceProperties.getBindings()) + .thenReturn(Map.of("produce-book-created-out-0", new BindingProperties())); + OutboxServicePropertiesTest.this.inclusionsList.add("regex:produce-.*-out-\\d+"); + OutboxServicePropertiesTest.this.exclusionsList.add("regex:consume-.*"); + OutboxServicePropertiesTest.this.updateLists( + OutboxServicePropertiesTest.this.inclusionsList, OutboxServicePropertiesTest.this.exclusionsList); + + assertDoesNotThrow(() -> OutboxServicePropertiesTest.this.outboxServiceProperties.afterPropertiesSet()); + } + + @Test + void mixed_exact_and_regex_entries_validation_only_applies_to_exact_entries() { + when(OutboxServicePropertiesTest.this.bindingServiceProperties.getBindings()) + .thenReturn(Map.of("produce-book-created-out-0", new BindingProperties())); + OutboxServicePropertiesTest.this.inclusionsList.add("produce-book-created-out-0"); + OutboxServicePropertiesTest.this.inclusionsList.add("regex:produce-.*-out-\\d+"); + OutboxServicePropertiesTest.this.exclusionsList.add("regex:consume-.*"); + OutboxServicePropertiesTest.this.updateLists( + OutboxServicePropertiesTest.this.inclusionsList, OutboxServicePropertiesTest.this.exclusionsList); + + assertDoesNotThrow(() -> OutboxServicePropertiesTest.this.outboxServiceProperties.afterPropertiesSet()); + } + + @Test + void exact_entry_not_in_scs_bindings_still_fails_when_mixed_with_regex() { + when(OutboxServicePropertiesTest.this.bindingServiceProperties.getBindings()) + .thenReturn(Map.of("produce-book-created-out-0", new BindingProperties())); + OutboxServicePropertiesTest.this.inclusionsList.add("non-existent-binding"); + OutboxServicePropertiesTest.this.inclusionsList.add("regex:produce-.*-out-\\d+"); + OutboxServicePropertiesTest.this.updateLists( + OutboxServicePropertiesTest.this.inclusionsList, OutboxServicePropertiesTest.this.exclusionsList); + + final IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, + () -> OutboxServicePropertiesTest.this.outboxServiceProperties.afterPropertiesSet()); + assertTrue(exception.getMessage().contains("non-existent-binding")); + } + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/config/BindingMatcherTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/config/BindingMatcherTest.java new file mode 100644 index 0000000..02a8fc2 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/config/BindingMatcherTest.java @@ -0,0 +1,132 @@ +package dev.inditex.scsoutbox.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class BindingMatcherTest { + + // --- Exact matching --- + + @Test + void exact_matches_same_name() { + final BindingMatcher matcher = new BindingMatcher("produce-book-created-out-0"); + assertTrue(matcher.matches("produce-book-created-out-0")); + } + + @Test + void exact_does_not_match_different_name() { + final BindingMatcher matcher = new BindingMatcher("produce-book-created-out-0"); + assertFalse(matcher.matches("produce-user-added-out-0")); + } + + @Test + void exact_is_not_regex() { + final BindingMatcher matcher = new BindingMatcher("produce-book-created-out-0"); + assertFalse(matcher.isRegex()); + } + + @Test + void exact_raw_value_is_preserved() { + final BindingMatcher matcher = new BindingMatcher("produce-book-created-out-0"); + assertEquals("produce-book-created-out-0", matcher.getRawValue()); + } + + // --- Regex matching --- + + @Test + void regex_matches_binding_name() { + final BindingMatcher matcher = new BindingMatcher("regex:produce-.*-out-\\d+"); + assertTrue(matcher.matches("produce-book-created-out-0")); + assertTrue(matcher.matches("produce-user-added-out-1")); + } + + @Test + void regex_does_not_match_non_matching_name() { + final BindingMatcher matcher = new BindingMatcher("regex:produce-.*-out-\\d+"); + assertFalse(matcher.matches("consume-book-created-in-0")); + } + + @Test + void regex_is_regex() { + final BindingMatcher matcher = new BindingMatcher("regex:produce-.*-out-\\d+"); + assertTrue(matcher.isRegex()); + } + + @Test + void regex_raw_value_includes_prefix() { + final BindingMatcher matcher = new BindingMatcher("regex:produce-.*-out-\\d+"); + assertEquals("regex:produce-.*-out-\\d+", matcher.getRawValue()); + } + + @Test + void regex_requires_full_match() { + final BindingMatcher matcher = new BindingMatcher("regex:out-\\d+"); + // Should NOT match because Pattern.matches() requires the entire string to match + assertFalse(matcher.matches("produce-book-created-out-0")); + } + + // --- Fail-fast on invalid regex --- + + @Test + void invalid_regex_throws_illegal_argument_exception() { + assertThrows(IllegalArgumentException.class, () -> new BindingMatcher("regex:[invalid")); + } + + @Test + void invalid_regex_exception_contains_pattern() { + final IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new BindingMatcher("regex:[invalid")); + assertTrue(exception.getMessage().contains("[invalid")); + } + + // --- Null value --- + + @Test + void null_value_throws_null_pointer_exception() { + assertThrows(NullPointerException.class, () -> new BindingMatcher(null)); + } + + // --- equals and hashCode --- + + @Test + void equals_same_raw_value() { + final BindingMatcher matcher1 = new BindingMatcher("produce-book-created-out-0"); + final BindingMatcher matcher2 = new BindingMatcher("produce-book-created-out-0"); + assertEquals(matcher1, matcher2); + assertEquals(matcher1.hashCode(), matcher2.hashCode()); + } + + @Test + void equals_same_regex_raw_value() { + final BindingMatcher matcher1 = new BindingMatcher("regex:produce-.*-out-\\d+"); + final BindingMatcher matcher2 = new BindingMatcher("regex:produce-.*-out-\\d+"); + assertEquals(matcher1, matcher2); + assertEquals(matcher1.hashCode(), matcher2.hashCode()); + } + + @Test + void not_equals_different_raw_value() { + final BindingMatcher matcher1 = new BindingMatcher("produce-book-created-out-0"); + final BindingMatcher matcher2 = new BindingMatcher("produce-user-added-out-0"); + assertNotEquals(matcher1, matcher2); + } + + @Test + void not_equals_null() { + final BindingMatcher matcher = new BindingMatcher("produce-book-created-out-0"); + assertNotEquals(null, matcher); + } + + // --- toString --- + + @Test + void toString_returns_raw_value() { + final BindingMatcher matcher = new BindingMatcher("regex:produce-.*-out-\\d+"); + assertEquals("regex:produce-.*-out-\\d+", matcher.toString()); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/config/ExecutorServiceAutoConfigurationTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/config/ExecutorServiceAutoConfigurationTest.java new file mode 100644 index 0000000..4c94821 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/config/ExecutorServiceAutoConfigurationTest.java @@ -0,0 +1,150 @@ +package dev.inditex.scsoutbox.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import dev.inditex.scsoutbox.OutboxMessageRepository; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.cloud.autoconfigure.RefreshAutoConfiguration; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.messaging.converter.CompositeMessageConverter; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +class ExecutorServiceAutoConfigurationTest { + + @Nested + @SpringBootTest( + classes = {OutboxAutoConfiguration.class, RefreshAutoConfiguration.class}, + properties = {}) + class NoExecutorServiceDefined extends AbstractExecutorService { + + @Test + void creates_one_by_default() { + final Map beans = this.context.getBeansOfType(ExecutorService.class); + System.out.println(beans); + assertThat(this.executorService).isEqualTo(beans.get("defaultOutboxExecutorService")); + } + + } + + @Nested + @SpringBootTest( + classes = {ExecutorServiceDefined.TestConfig.class, OutboxAutoConfiguration.class, RefreshAutoConfiguration.class}, + properties = {}) + class ExecutorServiceDefined extends AbstractExecutorService { + + @Test + void uses_the_defined() { + final Map beans = this.context.getBeansOfType(ExecutorService.class); + System.out.println(beans); + assertThat(this.executorService).isEqualTo(beans.get("executorService")); + } + + @TestConfiguration + public static class TestConfig { + + @Bean + public ExecutorService executorService() { + return Executors.newFixedThreadPool(1); + } + } + } + + @Nested + @SpringBootTest( + classes = {OutboxExecutorServiceDefined.TestConfig.class, OutboxAutoConfiguration.class, RefreshAutoConfiguration.class}, + properties = {}) + class OutboxExecutorServiceDefined extends AbstractExecutorService { + + @Test + void uses_outbox_executor_service() { + final Map beans = this.context.getBeansOfType(ExecutorService.class); + System.out.println(beans); + assertThat(this.executorService).isEqualTo(beans.get("outboxExecutorService")); + } + + @TestConfiguration + public static class TestConfig { + + @Bean + @Primary + public ExecutorService primaryService() { + return Executors.newFixedThreadPool(1); + } + + @Bean + public ExecutorService outboxExecutorService() { + return Executors.newFixedThreadPool(1); + } + } + } + + @Nested + @SpringBootTest( + classes = {PrimaryExecutorServiceDefined.TestConfig.class, OutboxAutoConfiguration.class, RefreshAutoConfiguration.class}, + properties = {}) + class PrimaryExecutorServiceDefined extends AbstractExecutorService { + + @Test + void uses_the_primary() { + final Map beans = this.context.getBeansOfType(ExecutorService.class); + System.out.println(beans); + assertThat(this.executorService).isEqualTo(beans.get("primaryExecutorService")); + } + + @TestConfiguration + public static class TestConfig { + + @Bean + @Primary + public ExecutorService primaryExecutorService() { + return Executors.newFixedThreadPool(1); + } + + @Bean + public ExecutorService nonPrimaryExecutorService() { + return Executors.newFixedThreadPool(1); + } + } + } + + static class AbstractExecutorService { + + @Autowired + protected ApplicationContext context; + + @Autowired + @Qualifier("outboxExecutorService") + protected ExecutorService executorService; + + @MockitoBean(name = "outboxMessageRepository") + private OutboxMessageRepository outboxMessageRepository; + + @MockitoBean(name = "publishingOutboxMessageRepository") + private OutboxMessageRepository publishingOutboxMessageRepository; + + @MockitoBean + private BindingServiceProperties bindingServiceProperties; + + @MockitoBean + private CompositeMessageConverter compositeMessageConverter; + + @MockitoBean + private StreamBridge streamBridge; + + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/config/OutboxAutoConfigurationTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/config/OutboxAutoConfigurationTest.java new file mode 100644 index 0000000..cdcaa24 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/config/OutboxAutoConfigurationTest.java @@ -0,0 +1,270 @@ +package dev.inditex.scsoutbox.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.util.concurrent.ExecutorService; + +import dev.inditex.scsoutbox.MessageCaptureTxService; +import dev.inditex.scsoutbox.OutboxMessageRepository; +import dev.inditex.scsoutbox.OutboxServiceProperties; +import dev.inditex.scsoutbox.interceptor.MessageChannelAccessor; +import dev.inditex.scsoutbox.interceptor.OutboxChannelInterceptor; +import dev.inditex.scsoutbox.publish.DestinationGroupingKeyGenerator; +import dev.inditex.scsoutbox.publish.GroupingKeyGenerator; +import dev.inditex.scsoutbox.publish.KafkaKeyGroupingKeyGenerator; +import dev.inditex.scsoutbox.publish.KeyGroupingStrategy; +import dev.inditex.scsoutbox.publish.OutboxMessagePublisher; +import dev.inditex.scsoutbox.publish.OutboxMessageSender; +import dev.inditex.scsoutbox.publish.OutboxPublishingTask; +import dev.inditex.scsoutbox.scheduler.AfterCommitTrigger; +import dev.inditex.scsoutbox.scheduler.OutboxScheduledService; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.cloud.autoconfigure.RefreshAutoConfiguration; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.messaging.converter.CompositeMessageConverter; + +class OutboxAutoConfigurationTest { + + private final ApplicationContextRunner baseContextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration.class, RefreshAutoConfiguration.class)) + .withBean("outboxMessageRepository", OutboxMessageRepository.class, () -> mock(OutboxMessageRepository.class)) + .withBean("publishingOutboxMessageRepository", OutboxMessageRepository.class, () -> mock(OutboxMessageRepository.class)) + .withBean(BindingServiceProperties.class, () -> mock(BindingServiceProperties.class)) + .withBean(CompositeMessageConverter.class, () -> mock(CompositeMessageConverter.class)) + .withBean(StreamBridge.class, () -> mock(StreamBridge.class)); + + @Nested + @DisplayName("groupingStrategy is CUSTOM_GROUPING_KEY") + class CustomGroupingKey { + + private final ApplicationContextRunner contextRunner = OutboxAutoConfigurationTest.this.baseContextRunner + .withPropertyValues("scs-outbox.publishing.grouping-strategy=CUSTOM_GROUPING_KEY"); + + @Test + @DisplayName("fails context loading when no custom generator is provided") + void fails_context_loading_when_custom_grouping_key_generator_missing() { + this.contextRunner + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasRootCauseInstanceOf(NullPointerException.class) + .hasMessageContaining("GroupingKeyGenerator bean must be provided when using CUSTOM_GROUPING_KEY"); + }); + } + + @Test + @DisplayName("does not fail context loading when custom GroupingKeyGenerator is provided") + void does_not_fail_when_custom_grouping_key_generator_provided() { + final GroupingKeyGenerator customGenerator = values -> null; + + this.contextRunner + .withBean(GroupingKeyGenerator.class, () -> customGenerator) + .run(context -> { + assertThat(context).hasNotFailed(); + }); + } + } + + @Nested + @DisplayName("groupingStrategy is DESTINATION") + class DestinationGroupingKey { + + private final ApplicationContextRunner contextRunner = OutboxAutoConfigurationTest.this.baseContextRunner + .withPropertyValues("scs-outbox.publishing.grouping-strategy=DESTINATION"); + + @Test + @DisplayName("creates DestinationGroupingKeyGenerator when using DESTINATION") + void creates_destination_grouping_key_generator() { + this.contextRunner + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(KeyGroupingStrategy.class); + final KeyGroupingStrategy strategy = context.getBean(KeyGroupingStrategy.class); + assertThat(strategy).extracting("groupingKeyGenerator") + .isInstanceOf(DestinationGroupingKeyGenerator.class); + }); + } + } + + @Nested + @DisplayName("groupingStrategy is KAFKA_MESSAGE_KEY") + class KafkaMessageKeyGrouping { + + private final ApplicationContextRunner contextRunner = OutboxAutoConfigurationTest.this.baseContextRunner + .withPropertyValues("scs-outbox.publishing.grouping-strategy=KAFKA_MESSAGE_KEY"); + + @Test + @DisplayName("creates KafkaKeyGroupingKeyGenerator when using KAFKA_MESSAGE_KEY") + void creates_kafka_key_grouping_key_generator() { + this.contextRunner + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(KeyGroupingStrategy.class); + final KeyGroupingStrategy strategy = context.getBean(KeyGroupingStrategy.class); + assertThat(strategy).extracting("groupingKeyGenerator") + .isInstanceOf(KafkaKeyGroupingKeyGenerator.class); + }); + } + } + + @Nested + @DisplayName("OutboxScheduledService configuration") + class ScheduledServiceConfiguration { + + @Test + @DisplayName("creates OutboxScheduledService when scheduling is enabled") + void creates_scheduled_service_when_scheduling_enabled() { + OutboxAutoConfigurationTest.this.baseContextRunner + .withPropertyValues("app.scheduling.enable=true") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(OutboxScheduledService.class); + }); + } + + @Test + @DisplayName("creates OutboxScheduledService when scheduling property is missing") + void creates_scheduled_service_when_scheduling_property_missing() { + OutboxAutoConfigurationTest.this.baseContextRunner + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(OutboxScheduledService.class); + }); + } + + @Test + @DisplayName("does not create OutboxScheduledService when scheduling is disabled") + void does_not_create_scheduled_service_when_scheduling_disabled() { + OutboxAutoConfigurationTest.this.baseContextRunner + .withPropertyValues("app.scheduling.enable=false") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(OutboxScheduledService.class); + }); + } + } + + @Nested + @DisplayName("AfterCommitTrigger configuration") + class AfterCommitTriggerConfiguration { + + @Test + @DisplayName("creates AfterCommitTrigger when after-commit is enabled") + void creates_after_commit_trigger_when_enabled() { + OutboxAutoConfigurationTest.this.baseContextRunner + .withBean(ApplicationEventPublisher.class, () -> mock(ApplicationEventPublisher.class)) + .withPropertyValues("scs-outbox.publishing.after-commit=true") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(AfterCommitTrigger.class); + }); + } + + @Test + @DisplayName("does not create AfterCommitTrigger when after-commit is disabled") + void does_not_create_after_commit_trigger_when_disabled() { + OutboxAutoConfigurationTest.this.baseContextRunner + .withBean(ApplicationEventPublisher.class, () -> mock(ApplicationEventPublisher.class)) + .withPropertyValues("scs-outbox.publishing.after-commit=false") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(AfterCommitTrigger.class); + }); + } + + @Test + @DisplayName("does not create AfterCommitTrigger when property is missing") + void does_not_create_after_commit_trigger_when_property_missing() { + OutboxAutoConfigurationTest.this.baseContextRunner + .withBean(ApplicationEventPublisher.class, () -> mock(ApplicationEventPublisher.class)) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(AfterCommitTrigger.class); + }); + } + } + + @Nested + @DisplayName("ExecutorService configuration") + class ExecutorServiceConfiguration { + + @Test + @DisplayName("creates default executor service when no custom executor is provided") + void creates_default_executor_service_when_no_custom_executor() { + OutboxAutoConfigurationTest.this.baseContextRunner + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(ExecutorService.class); + assertThat(context).hasBean("defaultOutboxExecutorService"); + assertThat(context).hasBean("outboxExecutorService"); + }); + } + + @Test + @DisplayName("uses custom executor service when provided") + void uses_custom_executor_service_when_provided() { + final ExecutorService customExecutor = mock(ExecutorService.class); + + OutboxAutoConfigurationTest.this.baseContextRunner + .withBean("outboxExecutorService", ExecutorService.class, () -> customExecutor) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).getBean("outboxExecutorService").isSameAs(customExecutor); + assertThat(context).doesNotHaveBean("defaultOutboxExecutorService"); + }); + } + } + + @Nested + @DisplayName("Core beans configuration") + class CoreBeansConfiguration { + + @Test + @DisplayName("creates all required core beans") + void creates_all_required_core_beans() { + OutboxAutoConfigurationTest.this.baseContextRunner + .withPropertyValues("spring.application.name=test-app") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(OutboxChannelInterceptor.class); + assertThat(context).hasSingleBean(MessageChannelAccessor.class); + assertThat(context).hasSingleBean(OutboxServiceProperties.class); + assertThat(context).hasSingleBean(MessageCaptureTxService.class); + assertThat(context).hasSingleBean(OutboxMessageSender.class); + assertThat(context).hasSingleBean(OutboxMessagePublisher.class); + assertThat(context).hasSingleBean(OutboxPublishingTask.class); + }); + } + + @Test + @DisplayName("creates MessageChannelAccessor with application name") + void creates_message_channel_accessor_with_application_name() { + OutboxAutoConfigurationTest.this.baseContextRunner + .withPropertyValues("spring.application.name=my-test-app") + .run(context -> { + assertThat(context).hasNotFailed(); + final MessageChannelAccessor accessor = context.getBean(MessageChannelAccessor.class); + assertThat(accessor).extracting("appName").isEqualTo("my-test-app"); + }); + } + + @Test + @DisplayName("creates MessageChannelAccessor with empty name when application name is missing") + void creates_message_channel_accessor_with_empty_name_when_missing() { + OutboxAutoConfigurationTest.this.baseContextRunner + .run(context -> { + assertThat(context).hasNotFailed(); + final MessageChannelAccessor accessor = context.getBean(MessageChannelAccessor.class); + assertThat(accessor).extracting("appName").isEqualTo(""); + }); + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/config/OutboxPropertiesTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/config/OutboxPropertiesTest.java new file mode 100644 index 0000000..0a149ef --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/config/OutboxPropertiesTest.java @@ -0,0 +1,97 @@ +package dev.inditex.scsoutbox.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +import dev.inditex.scsoutbox.config.OutboxProperties.Bindings; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class OutboxPropertiesTest { + + @Nested + class Constructor { + + @Test + void when_null_bindings_expect_empty_bindings() { + final OutboxProperties properties = new OutboxProperties(null); + + assertThat(properties.getBindings()).isNotNull(); + } + } + + @Nested + class BindingsConstructor { + + @Test + void when_null_inclusions_and_exclusions_expect_empty_lists() { + final Bindings bindings = new Bindings(null, null); + + assertThat(bindings.getInclusions()).isEmpty(); + assertThat(bindings.getExclusions()).isEmpty(); + } + + @Test + void when_same_binding_in_both_lists_expect_illegal_argument_exception() { + final String bindingName = "bindingName"; + + assertThatThrownBy(() -> new Bindings(List.of(bindingName), List.of(bindingName))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void when_valid_regex_entries_expect_accepted() { + final Bindings bindings = new Bindings( + List.of("regex:produce-.*-out-\\d+"), + List.of("regex:consume-.*")); + + assertThat(bindings.getInclusions()).hasSize(1); + assertThat(bindings.getInclusions().get(0).isRegex()).isTrue(); + assertThat(bindings.getExclusions()).hasSize(1); + assertThat(bindings.getExclusions().get(0).isRegex()).isTrue(); + } + + @Test + void when_invalid_regex_in_inclusions_expect_illegal_argument_exception() { + assertThatThrownBy(() -> new Bindings(List.of("regex:[invalid"), List.of())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void when_invalid_regex_in_exclusions_expect_illegal_argument_exception() { + assertThatThrownBy(() -> new Bindings(List.of(), List.of("regex:[invalid"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void when_regex_entries_in_both_lists_expect_no_conflict() { + final Bindings bindings = new Bindings( + List.of("regex:produce-.*"), + List.of("regex:produce-book-.*")); + + assertThat(bindings.getInclusions()).isNotEmpty(); + assertThat(bindings.getExclusions()).isNotEmpty(); + } + + @Test + void when_mixed_entries_with_different_exact_values_expect_no_conflict() { + final Bindings bindings = new Bindings( + List.of("binding-a", "regex:produce-.*"), + List.of("binding-b", "regex:consume-.*")); + + assertThat(bindings.getInclusions()).hasSize(2); + assertThat(bindings.getExclusions()).hasSize(2); + } + + @Test + void when_exact_conflict_mixed_with_regex_expect_illegal_argument_exception() { + assertThatThrownBy(() -> new Bindings( + List.of("binding-a", "regex:produce-.*"), + List.of("binding-a", "regex:consume-.*"))) + .isInstanceOf(IllegalArgumentException.class); + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/interceptor/MessageChannelAccessorTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/interceptor/MessageChannelAccessorTest.java new file mode 100644 index 0000000..958ad17 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/interceptor/MessageChannelAccessorTest.java @@ -0,0 +1,27 @@ +package dev.inditex.scsoutbox.interceptor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.springframework.integration.channel.AbstractMessageChannel; + +class MessageChannelAccessorTest { + + @Test + void when_message_channel_is_prefixed() { + final MessageChannelAccessor accessor = new MessageChannelAccessor("appName"); + final AbstractMessageChannel messageChannel = mock(AbstractMessageChannel.class); + when(messageChannel.getFullChannelName()).thenReturn("appName.bindingName"); + assertEquals("bindingName", accessor.getBindingName(messageChannel)); + } + + @Test + void when_message_channel_is_not_prefixed() { + final MessageChannelAccessor accessor = new MessageChannelAccessor("appName"); + final AbstractMessageChannel messageChannel = mock(AbstractMessageChannel.class); + when(messageChannel.getFullChannelName()).thenReturn("bindingName"); + assertEquals("bindingName", accessor.getBindingName(messageChannel)); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/interceptor/OutboxChannelInterceptorTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/interceptor/OutboxChannelInterceptorTest.java new file mode 100644 index 0000000..c02b5c9 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/interceptor/OutboxChannelInterceptorTest.java @@ -0,0 +1,95 @@ +package dev.inditex.scsoutbox.interceptor; + +import static dev.inditex.scsoutbox.publish.StreamBridgeOutboxMessageSender.SCS_OUTBOX_PUBLISH_MARK_HEADER; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; + +import dev.inditex.scsoutbox.MessageCaptureTxService; +import dev.inditex.scsoutbox.OutboxServiceProperties; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.integration.channel.AbstractMessageChannel; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.ErrorMessage; +import org.springframework.messaging.support.MessageBuilder; + +class OutboxChannelInterceptorTest { + + private OutboxChannelInterceptor interceptor; + + private AbstractMessageChannel channel; + + private MessageCaptureTxService messageCaptureTxService; + + private OutboxServiceProperties outboxServiceProperties; + + @BeforeEach + void setUp() { + this.messageCaptureTxService = mock(MessageCaptureTxService.class); + final MessageChannelAccessor messageChannelAccessor = mock(MessageChannelAccessor.class); + this.outboxServiceProperties = mock(OutboxServiceProperties.class); + this.interceptor = new OutboxChannelInterceptor( + this.messageCaptureTxService, messageChannelAccessor, this.outboxServiceProperties); + this.channel = mock(AbstractMessageChannel.class); + } + + @Test + void when_outbox_is_disable_for_binding_name_then_return_original_message() { + final Message message = MessageBuilder.createMessage("payload", new MessageHeaders(Map.of())); + when(this.outboxServiceProperties.isOutboxEnabledFor(any())).thenReturn(false); + + final Message result = this.interceptor.preSend(message, this.channel); + + assertEquals(message, result); + } + + @Test + void when_outbox_is_enabled_and_message_is_marked_then_return_message_without_mark() { + when(this.outboxServiceProperties.isOutboxEnabledFor(any())).thenReturn(true); + final Message message = MessageBuilder.createMessage( + "payload", new MessageHeaders(Map.of( + SCS_OUTBOX_PUBLISH_MARK_HEADER, ""))); + + final Message result = this.interceptor.preSend(message, this.channel); + + assertEquals(message.getPayload(), result.getPayload()); + assertFalse(result.getHeaders().containsKey(SCS_OUTBOX_PUBLISH_MARK_HEADER)); + } + + @Test + void when_outbox_is_enabled_then_retain_message_and_return_null() { + when(this.outboxServiceProperties.isOutboxEnabledFor(any())).thenReturn(true); + + final Message message = MessageBuilder.createMessage( + "payload", new MessageHeaders(Map.of())); + + final Message result = this.interceptor.preSend(message, this.channel); + + verify(this.messageCaptureTxService).capture(any(), any()); + assertNull(result); + } + + @Test + void when_message_is_error_message_then_skip_outbox_processing() { + when(this.outboxServiceProperties.isOutboxEnabledFor(any())).thenReturn(true); + + final Message message = new ErrorMessage(new RuntimeException("Test error")); + + final Message result = this.interceptor.preSend(message, this.channel); + + verify(this.messageCaptureTxService, never()).capture(any(), any()); + assertEquals(message, result); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/AbstractOutboxPublishingTaskTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/AbstractOutboxPublishingTaskTest.java new file mode 100755 index 0000000..5545aa2 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/AbstractOutboxPublishingTaskTest.java @@ -0,0 +1,102 @@ +package dev.inditex.scsoutbox.publish; + +import static dev.inditex.scsoutbox.OutboxMessageRepository.UNLIMITED; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; + +import dev.inditex.scsoutbox.InMemoryOutboxMessageRepository; +import dev.inditex.scsoutbox.OutboxMessage; +import dev.inditex.scsoutbox.publish.config.PublishingProperties; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.mockito.InOrder; +import org.mockito.Mockito; + +abstract class AbstractOutboxPublishingTaskTest { + + // Shared constants + protected static final int DEFAULT_BATCH_SIZE = 1000; + + protected static final int DEFAULT_SEND_DELAY = 30; + + protected static final Boolean DEFAULT_SEND_RESPONSE = true; + + protected static final int DEFAULT_POOL_SIZE = 5; + + // Common components + protected InMemoryOutboxMessageRepository repository; + + protected OutboxMessageSender messageSender; + + protected OutboxMessagePublisher publisher; + + protected ExecutorService executorService; + + protected ParallelPublisher parallelPublisher; + + protected PublishingProperties publishingProperties; + + protected OutboxPublishingTask task; + + @BeforeEach + public void setUp() { + this.setupBasicComponents(); + this.setupDefaultTask(); + } + + protected void setupBasicComponents() { + this.repository = new InMemoryOutboxMessageRepository(); + this.messageSender = mock(OutboxMessageSender.class); + when(this.messageSender.send(any())).thenReturn(true); + this.publisher = new OutboxMessagePublisher(this.messageSender, this.repository, List.of()); + this.executorService = Executors.newSingleThreadExecutor(); + this.parallelPublisher = new ParallelPublisher(this.executorService, this.publisher); + this.publishingProperties = new PublishingProperties(DEFAULT_BATCH_SIZE, "DESTINATION", Set.of(), false, false); + } + + protected void setupDefaultTask() { + this.task = new OutboxPublishingTask( + this.repository, + this.parallelPublisher, + new KeyGroupingStrategy(new DestinationGroupingKeyGenerator()), + this.publishingProperties); + } + + @AfterEach + public void tearDown() { + this.executorService.shutdownNow(); + } + + protected void assertPublishedMessages(final List publishedMessages) { + final List unpublishedMessages = this.repository.findAllOrderByCapturedAt(UNLIMITED); + final Map> publishedMessagesByDestination = publishedMessages.stream() + .collect(Collectors.groupingBy(OutboxMessage::getDestination)); + publishedMessagesByDestination.keySet().forEach( + destination -> { + final InOrder inOrder = Mockito.inOrder(this.messageSender); + publishedMessagesByDestination.get(destination).forEach( + publishedMessage -> { + inOrder.verify(this.messageSender).send(publishedMessage); + assertFalse(unpublishedMessages.contains(publishedMessage), + "expected message to be published but found it pending"); + }); + }); + } + + protected void assertPendingMessages(final List outboxMessages) { + assertEquals(this.repository.findAllOrderByCapturedAt(UNLIMITED), outboxMessages); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/DestinationGroupingKeyGeneratorTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/DestinationGroupingKeyGeneratorTest.java new file mode 100644 index 0000000..e9dfa39 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/DestinationGroupingKeyGeneratorTest.java @@ -0,0 +1,44 @@ +package dev.inditex.scsoutbox.publish; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import java.util.Collections; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class DestinationGroupingKeyGeneratorTest { + + private DestinationGroupingKeyGenerator generator; + + @BeforeEach + void setUp() { + this.generator = new DestinationGroupingKeyGenerator(); + } + + @Test + @DisplayName("generate returns key with destination value") + void generate_returns_key_with_destination_value() { + final GroupingValues values = GroupingValues.of("my-destination", "binding", Collections.emptyMap()); + final GroupingKey key = this.generator.generate(values); + assertEquals("my-destination", key.asString()); + } + + @Test + @DisplayName("generate returns different keys for different destinations") + void generate_returns_different_keys_for_different_destinations() { + final GroupingKey key1 = this.generator.generate(GroupingValues.of("dest1", "binding", Collections.emptyMap())); + final GroupingKey key2 = this.generator.generate(GroupingValues.of("dest2", "binding", Collections.emptyMap())); + assertNotEquals(key1, key2); + } + + @Test + @DisplayName("generate returns same key for same destination") + void generate_returns_same_key_for_same_destination() { + final GroupingKey key1 = this.generator.generate(GroupingValues.of("dest", "binding1", Collections.emptyMap())); + final GroupingKey key2 = this.generator.generate(GroupingValues.of("dest", "binding2", Collections.emptyMap())); + assertEquals(key1, key2); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/GroupingKeyTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/GroupingKeyTest.java new file mode 100644 index 0000000..98219b4 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/GroupingKeyTest.java @@ -0,0 +1,49 @@ +package dev.inditex.scsoutbox.publish; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class GroupingKeyTest { + + @Test + @DisplayName("of() creates instance with correct value") + void of_creates_instance_with_correct_value() { + final GroupingKey key = GroupingKey.of("abc"); + assertEquals("abc", key.asString()); + } + + @Test + @DisplayName("asString returns the original value") + void as_string_returns_original_value() { + final GroupingKey key = GroupingKey.of("xyz"); + assertEquals("xyz", key.asString()); + } + + @Test + @DisplayName("equals and hashCode are consistent for equal values") + void equals_and_hash_code_are_consistent_for_equal_values() { + final GroupingKey key1 = GroupingKey.of("same"); + final GroupingKey key2 = GroupingKey.of("same"); + assertEquals(key1, key2); + assertEquals(key1.hashCode(), key2.hashCode()); + } + + @Test + @DisplayName("toString contains value") + void to_string_contains_value() { + final GroupingKey key = GroupingKey.of("val"); + assertTrue(key.toString().contains("val")); + } + + @Test + @DisplayName("different values are not equal") + void different_values_are_not_equal() { + final GroupingKey key1 = GroupingKey.of("a"); + final GroupingKey key2 = GroupingKey.of("b"); + assertNotEquals(key1, key2); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/GroupingValuesTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/GroupingValuesTest.java new file mode 100644 index 0000000..396e540 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/GroupingValuesTest.java @@ -0,0 +1,73 @@ +package dev.inditex.scsoutbox.publish; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class GroupingValuesTest { + + @Test + @DisplayName("of() creates instance with correct values") + void of_creates_instance_with_correct_values() { + final String destination = "topic"; + final String bindingName = "binding"; + final Map headers = new HashMap<>(); + headers.put("key", "value"); + + final GroupingValues groupingValues = GroupingValues.of(destination, bindingName, headers); + + assertEquals(destination, groupingValues.getDestination()); + assertEquals(bindingName, groupingValues.getBindingName()); + assertEquals(headers, groupingValues.getMessageHeaders()); + } + + @Test + @DisplayName("messageHeaders is unmodifiable") + void message_headers_is_unmodifiable() { + final GroupingValues groupingValues = GroupingValues.of("dest", "bind", Collections.singletonMap("k", "v")); + final Map messageHeaders = groupingValues.getMessageHeaders(); + assertThrows(UnsupportedOperationException.class, () -> messageHeaders.put("x", 1)); + } + + @Test + @DisplayName("equals and hashCode are consistent for equal values") + void equals_and_hash_code_are_consistent_for_equal_values() { + final Map headers1 = new HashMap<>(); + headers1.put("a", 1); + final Map headers2 = new HashMap<>(); + headers2.put("a", 1); + final GroupingValues g1 = GroupingValues.of("d", "b", headers1); + final GroupingValues g2 = GroupingValues.of("d", "b", headers2); + assertEquals(g1, g2); + assertEquals(g1.hashCode(), g2.hashCode()); + } + + @Test + @DisplayName("toString contains all fields") + void to_string_contains_all_fields() { + final GroupingValues groupingValues = GroupingValues.of("dest", "bind", Collections.singletonMap("k", "v")); + final String str = groupingValues.toString(); + assertTrue(str.contains("dest")); + assertTrue(str.contains("bind")); + assertTrue(str.contains("k")); + assertTrue(str.contains("v")); + } + + @Nested + class EdgeCases { + @Test + @DisplayName("of() with empty headers map") + void of_with_empty_headers() { + final GroupingValues groupingValues = GroupingValues.of("d", "b", Collections.emptyMap()); + assertTrue(groupingValues.getMessageHeaders().isEmpty()); + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/KafkaKeyGroupingKeyGeneratorTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/KafkaKeyGroupingKeyGeneratorTest.java new file mode 100644 index 0000000..7a2a03e --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/KafkaKeyGroupingKeyGeneratorTest.java @@ -0,0 +1,67 @@ +package dev.inditex.scsoutbox.publish; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.kafka.support.KafkaHeaders; + +class KafkaKeyGroupingKeyGeneratorTest { + + private KafkaKeyGroupingKeyGenerator generator; + + @BeforeEach + void setUp() { + this.generator = new KafkaKeyGroupingKeyGenerator(); + } + + @Test + @DisplayName("generate returns key with destination and kafka key") + void generate_returns_key_with_destination_and_kafka_key() { + final Map headers = new HashMap<>(); + headers.put(KafkaHeaders.KEY, "kafka-key"); + final GroupingValues values = GroupingValues.of("topic", "binding", headers); + final GroupingKey key = this.generator.generate(values); + assertEquals("topic-kafka-key", key.asString()); + } + + @Test + @DisplayName("generate returns key with destination and null when kafka key is missing") + void generate_returns_key_with_null_when_kafka_key_missing() { + final GroupingValues values = GroupingValues.of("topic", "binding", Collections.emptyMap()); + final GroupingKey key = this.generator.generate(values); + assertEquals("topic-null", key.asString()); + } + + @Test + @DisplayName("generate returns different keys for different kafka keys") + void generate_returns_different_keys_for_different_kafka_keys() { + final Map headers1 = new HashMap<>(); + headers1.put(KafkaHeaders.KEY, "key1"); + final Map headers2 = new HashMap<>(); + headers2.put(KafkaHeaders.KEY, "key2"); + final GroupingValues values1 = GroupingValues.of("topic", "binding", headers1); + final GroupingValues values2 = GroupingValues.of("topic", "binding", headers2); + final GroupingKey key1 = this.generator.generate(values1); + final GroupingKey key2 = this.generator.generate(values2); + assertNotEquals(key1, key2); + } + + @Test + @DisplayName("generate returns same key for same destination and kafka key") + void generate_returns_same_key_for_same_destination_and_kafka_key() { + final Map headers = new HashMap<>(); + headers.put(KafkaHeaders.KEY, "same-key"); + final GroupingValues values1 = GroupingValues.of("topic", "binding1", headers); + final GroupingValues values2 = GroupingValues.of("topic", "binding2", headers); + final GroupingKey key1 = this.generator.generate(values1); + final GroupingKey key2 = this.generator.generate(values2); + assertEquals(key1, key2); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/MultiThreadingOutboxPublishingTaskTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/MultiThreadingOutboxPublishingTaskTest.java new file mode 100755 index 0000000..d276123 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/MultiThreadingOutboxPublishingTaskTest.java @@ -0,0 +1,83 @@ +package dev.inditex.scsoutbox.publish; + +import static dev.inditex.scsoutbox.OutboxMessageMother.anOutboxMessageBuilder; +import static dev.inditex.scsoutbox.OutboxMessageRepository.UNLIMITED; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import dev.inditex.scsoutbox.OutboxMessage; +import dev.inditex.scsoutbox.publish.config.PublishingProperties; + +import org.junit.jupiter.api.Test; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +class MultiThreadingOutboxPublishingTaskTest extends AbstractOutboxPublishingTaskTest { + + @Test + void multithreading_test() { + // Given a + // ThreadPool size of 5 + // and + // 30 ms of send message delay + // then + // 150 messages can be published in less than 1.5 seconds + final int ThreadPoolSize = 5; + final int sendMessageDelay = 25; + final int numOfMessages = 150; + this.generateScenario(ThreadPoolSize, sendMessageDelay, numOfMessages); + final List unpublishedOutboxMessages = this.repository.findAllOrderByCapturedAt(UNLIMITED); + + final OutboxPublishingTaskReport report = this.task.run(); + assertThat(report.getDuration().toMillis()).isLessThan(1500); + assertThat(report.getThroughput()).isGreaterThan(100); + this.assertPublishedMessages(unpublishedOutboxMessages); + } + + private void generateScenario(final int threadPoolSize, final int sendMessageDelay, final int numOfMessages) { + this.executorService = Executors.newFixedThreadPool(threadPoolSize); + this.task = new OutboxPublishingTask(this.repository, + new ParallelPublisher(this.executorService, this.publisher), + new KeyGroupingStrategy(new DestinationGroupingKeyGenerator()), + new PublishingProperties(null, null, null, null, null)); + when(this.messageSender.send(any())).thenAnswer(new AnswerWithDelay(sendMessageDelay, true)); + this.repository.init(buildMessages(numOfMessages)); + } + + private static List buildMessages(final int numOfMessages) { + final List messages = new ArrayList<>(numOfMessages); + for (int i = 1; i <= numOfMessages; i++) { + final String destination = "" + i % 5; + messages.add(anOutboxMessageBuilder() + .destination(destination) + .build()); + } + return messages; + } + + private class AnswerWithDelay implements Answer { + + private final int delay; + + private final Object response; + + public AnswerWithDelay(final int delay, final Object response) { + this.delay = delay; + this.response = response; + } + + @Override + @SuppressWarnings("java:S2925") + public Object answer(final InvocationOnMock invocationOnMock) throws Throwable { + TimeUnit.MILLISECONDS.sleep(this.delay); + return this.response; + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/OutboxMessageConverterTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/OutboxMessageConverterTest.java new file mode 100644 index 0000000..7bdc2f6 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/OutboxMessageConverterTest.java @@ -0,0 +1,162 @@ +package dev.inditex.scsoutbox.publish; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import dev.inditex.scsoutbox.OutboxMessage; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.MimeType; + +class OutboxMessageConverterTest { + + private final OutboxMessageConverter converter = new OutboxMessageConverter(); + + @Nested + class ToMessage { + + @Test + void when_outbox_message_with_byte_array_expect_same_bytes_returned() { + final byte[] data = new byte[]{1, 2, 3, 4, 5}; + final OutboxMessage outboxMessage = OutboxMessageConverterTest.this.buildOutboxMessage(data, + Map.of(MessageHeaders.CONTENT_TYPE, MimeType.valueOf("application/json"))); + final MessageHeaders headers = MessageBuilder.withPayload(outboxMessage).build().getHeaders(); + + final Message result = OutboxMessageConverterTest.this.converter.toMessage(outboxMessage, headers); + + assertThat(result).isNotNull(); + assertThat(result.getPayload()).isEqualTo(data); + } + + @Test + void when_outbox_message_converted_expect_original_content_type_preserved() { + final MimeType originalContentType = MimeType.valueOf("application/*+avro"); + final OutboxMessage outboxMessage = OutboxMessageConverterTest.this.buildOutboxMessage(new byte[]{10, 20}, + Map.of(MessageHeaders.CONTENT_TYPE, originalContentType)); + final MessageHeaders headers = MessageBuilder.withPayload(outboxMessage).build().getHeaders(); + + final Message result = OutboxMessageConverterTest.this.converter.toMessage(outboxMessage, headers); + + assertThat(result).isNotNull(); + assertThat(result.getHeaders()).containsEntry(MessageHeaders.CONTENT_TYPE, originalContentType); + } + + @Test + void when_outbox_message_converted_expect_captured_headers_preserved() { + final Map capturedHeaders = Map.of( + MessageHeaders.CONTENT_TYPE, MimeType.valueOf("application/json"), + "kafka_messageKey", "my-key", + "custom-header", "custom-value"); + final OutboxMessage outboxMessage = OutboxMessageConverterTest.this.buildOutboxMessage(new byte[]{1}, capturedHeaders); + final MessageHeaders headers = MessageBuilder.withPayload(outboxMessage).build().getHeaders(); + + final Message result = OutboxMessageConverterTest.this.converter.toMessage(outboxMessage, headers); + + assertThat(result).isNotNull(); + assertThat(result.getHeaders()).containsEntry("kafka_messageKey", "my-key"); + assertThat(result.getHeaders()).containsEntry("custom-header", "custom-value"); + } + + @Test + void when_outbox_message_has_no_content_type_header_expect_message_without_content_type() { + final OutboxMessage outboxMessage = OutboxMessageConverterTest.this.buildOutboxMessage(new byte[]{1, 2}, Map.of()); + final MessageHeaders headers = MessageBuilder.withPayload(outboxMessage).build().getHeaders(); + + final Message result = OutboxMessageConverterTest.this.converter.toMessage(outboxMessage, headers); + + assertThat(result).isNotNull(); + assertThat(result.getPayload()).isEqualTo(new byte[]{1, 2}); + } + + @Test + void when_outer_headers_have_key_absent_in_outbox_headers_expect_it_is_added_to_result() { + final OutboxMessage outboxMessage = OutboxMessageConverterTest.this.buildOutboxMessage(new byte[]{1}, + Map.of(MessageHeaders.CONTENT_TYPE, MimeType.valueOf("application/json"))); + final MessageHeaders outerHeaders = MessageBuilder.withPayload(new byte[0]) + .setHeader("outer-only-header", "outer-only-value") + .build().getHeaders(); + + final Message result = OutboxMessageConverterTest.this.converter.toMessage(outboxMessage, outerHeaders); + + assertThat(result).isNotNull(); + assertThat(result.getHeaders()).containsEntry("outer-only-header", "outer-only-value"); + } + + @Test + void when_conflicting_header_in_outer_and_outbox_expect_outbox_message_header_takes_precedence() { + final Map outboxHeaders = Map.of( + "shared-header", "outbox-value", + MessageHeaders.CONTENT_TYPE, MimeType.valueOf("application/json")); + final OutboxMessage outboxMessage = OutboxMessageConverterTest.this.buildOutboxMessage(new byte[]{1}, outboxHeaders); + final MessageHeaders outerHeaders = MessageBuilder.withPayload(new byte[0]) + .setHeader("shared-header", "outer-value") + .build().getHeaders(); + + final Message result = OutboxMessageConverterTest.this.converter.toMessage(outboxMessage, outerHeaders); + + assertThat(result).isNotNull(); + assertThat(result.getHeaders()).containsEntry("shared-header", "outbox-value"); + } + + @Test + void when_payload_is_not_outbox_message_expect_null_returned() { + final MessageHeaders headers = MessageBuilder.withPayload("not an OutboxMessage").build().getHeaders(); + + final Message result = OutboxMessageConverterTest.this.converter.toMessage("not an OutboxMessage", headers); + + assertThat(result).isNull(); + } + + @Test + void when_outbox_message_payload_is_not_byte_array_expect_null_returned() { + final OutboxMessage outboxMessage = OutboxMessageConverterTest.this.buildOutboxMessage("string payload", + Map.of(MessageHeaders.CONTENT_TYPE, MimeType.valueOf("application/json"))); + final MessageHeaders headers = MessageBuilder.withPayload(outboxMessage).build().getHeaders(); + + final Message result = OutboxMessageConverterTest.this.converter.toMessage(outboxMessage, headers); + + assertThat(result).isNull(); + } + + @Test + @SuppressWarnings("NullableProblems") + void when_null_payload_expect_null_returned() { + final MessageHeaders headers = MessageBuilder.withPayload("dummy").build().getHeaders(); + + final Message result = OutboxMessageConverterTest.this.converter.toMessage(null, headers); + + assertThat(result).isNull(); + } + } + + @Nested + class FromMessage { + + @Test + void when_from_message_called_expect_null_returned() { + final Message message = MessageBuilder.withPayload(new byte[]{1, 2, 3}).build(); + + final Object result = OutboxMessageConverterTest.this.converter.fromMessage(message, byte[].class); + + assertThat(result).isNull(); + } + } + + private OutboxMessage buildOutboxMessage(final Object payload, final Map headers) { + return OutboxMessage.builder() + .id(UUID.randomUUID()) + .capturedAt(Instant.parse("2026-03-23T12:00:00Z")) + .destination("test-destination") + .bindingName("test-binding") + .payload(payload) + .headers(headers) + .build(); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/OutboxMessagePublisherTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/OutboxMessagePublisherTest.java new file mode 100755 index 0000000..d14b529 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/OutboxMessagePublisherTest.java @@ -0,0 +1,60 @@ +package dev.inditex.scsoutbox.publish; + +import static dev.inditex.scsoutbox.OutboxMessageMother.anOutboxMessage; +import static dev.inditex.scsoutbox.OutboxMessageRepository.UNLIMITED; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import dev.inditex.scsoutbox.InMemoryOutboxMessageRepository; +import dev.inditex.scsoutbox.OutboxMessage; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class OutboxMessagePublisherTest { + + private InMemoryOutboxMessageRepository repository; + + private OutboxMessageSender messageSender; + + private OutboxMessagePublisher publisher; + + @BeforeEach + void setUp() { + this.messageSender = mock(OutboxMessageSender.class); + when(this.messageSender.send(any(OutboxMessage.class))).thenReturn(true); + this.repository = new InMemoryOutboxMessageRepository(); + this.publisher = new OutboxMessagePublisher( + this.messageSender, this.repository, List.of()); + } + + @Nested + class Publish { + + @Test + void when_message_sender_cannot_send_message_expect_error() { + when(OutboxMessagePublisherTest.this.messageSender.send(any(OutboxMessage.class))).thenReturn(false); + + assertThatThrownBy(() -> OutboxMessagePublisherTest.this.publisher.publish(anOutboxMessage())) + .isInstanceOf(MessageNotPublishedException.class); + } + + @Test + void when_message_is_sent_expect_deleted_from_repository() { + final OutboxMessage message = anOutboxMessage(); + OutboxMessagePublisherTest.this.repository.save(message); + + OutboxMessagePublisherTest.this.publisher.publish(message); + + assertThat(OutboxMessagePublisherTest.this.repository.findAllOrderByCapturedAt(UNLIMITED)).isEmpty(); + } + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/OutboxPublishingTaskReportTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/OutboxPublishingTaskReportTest.java new file mode 100644 index 0000000..b1f671c --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/OutboxPublishingTaskReportTest.java @@ -0,0 +1,40 @@ +package dev.inditex.scsoutbox.publish; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import java.time.Duration; +import java.time.Instant; + +import org.junit.jupiter.api.Test; + +class OutboxPublishingTaskReportTest { + + @Test + void stop_must_be_after_start() { + final Instant start = Instant.now(); + final Instant stop = start.minus(Duration.ofMillis(100)); + assertThatThrownBy(() -> OutboxPublishingTaskReport.of(start, stop, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("stop must be after start"); + } + + @Test + void duration_is_the_time_between_start_and_stop_report() { + final Duration expectedDuration = Duration.ofMillis(100); + final Instant start = Instant.now(); + final Instant stop = start.plus(expectedDuration); + final OutboxPublishingTaskReport report = OutboxPublishingTaskReport.of(start, stop, 0); + + assertThat(report.getDuration()).isEqualTo(expectedDuration); + } + + @Test + void throughput_is_the_number_of_messages_by_second() { + final Instant start = Instant.now(); + final Instant stop = start.plus(Duration.ofSeconds(1)); + final OutboxPublishingTaskReport report = OutboxPublishingTaskReport.of(start, stop, 100); + + assertThat(report.getThroughput()).isEqualTo(100); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/OutboxPublishingTaskTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/OutboxPublishingTaskTest.java new file mode 100755 index 0000000..e19db71 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/OutboxPublishingTaskTest.java @@ -0,0 +1,254 @@ +package dev.inditex.scsoutbox.publish; + +import static dev.inditex.scsoutbox.OutboxMessageMother.anOutboxMessage; +import static dev.inditex.scsoutbox.OutboxMessageMother.anOutboxMessageBuilder; + +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import dev.inditex.scsoutbox.OutboxMessage; +import dev.inditex.scsoutbox.publish.config.PublishingProperties; + +import org.junit.jupiter.api.Test; +import org.springframework.kafka.support.KafkaHeaders; + +class OutboxPublishingTaskTest extends AbstractOutboxPublishingTaskTest { + + @Test + void when_the_number_of_unpublished_messages_is_greater_than_the_batch_size() { + final int batchSize = 2; + final PublishingProperties properties = new PublishingProperties(batchSize, "KAFKA_MESSAGE_KEY", Set.of(), false, false); + this.task = new OutboxPublishingTask(this.repository, this.parallelPublisher, + new KeyGroupingStrategy(new KafkaKeyGroupingKeyGenerator()), properties); + final OutboxMessage firstMessage = anOutboxMessage(); + final OutboxMessage secondMessage = anOutboxMessage(); + final OutboxMessage thirdMessage = anOutboxMessage(); + this.repository.init(List.of(firstMessage, secondMessage, thirdMessage)); + + this.task.run(); + + this.assertPublishedMessages(List.of(firstMessage, secondMessage)); + this.assertPendingMessages(List.of(thirdMessage)); + } + + @Test + void when_no_messages_do_nothing() { + this.repository.init(List.of()); + + this.task.run(); + + this.assertPublishedMessages(List.of()); + this.assertPendingMessages(List.of()); + } + + @Test + void when_the_number_of_unpublished_messages_are_less_or_equal_than_the_batch_size() { + final int batchSize = 2; + final PublishingProperties properties = new PublishingProperties(batchSize, "KAFKA_MESSAGE_KEY", Set.of(), false, false); + this.task = new OutboxPublishingTask(this.repository, this.parallelPublisher, + new KeyGroupingStrategy(new KafkaKeyGroupingKeyGenerator()), properties); + final OutboxMessage firstMessage = anOutboxMessage(); + final OutboxMessage secondMessage = anOutboxMessage(); + this.repository.init(List.of(firstMessage, secondMessage)); + + this.task.run(); + + this.assertPendingMessages(List.of()); + this.assertPublishedMessages(List.of(firstMessage, secondMessage)); + } + + @Test + void maintains_order_by_destination_using_destination_strategy() { + final OutboxMessage msg3 = anOutboxMessageBuilder().destination("3").build(); + final OutboxMessage msg2A = anOutboxMessageBuilder().destination("2").build(); + final OutboxMessage msg2B = anOutboxMessageBuilder().destination("2").build(); + final OutboxMessage msg1A = anOutboxMessageBuilder().destination("1").build(); + final OutboxMessage msg1B = anOutboxMessageBuilder().destination("1").build(); + // this order is not important + this.repository.init(List.of(msg3, msg2B, msg2A, msg1A, msg1B)); + + this.task.run(); + + this.assertPendingMessages(List.of()); + // just fail if the order in the same destination is not maintained + this.assertPublishedMessages(List.of(msg1A, msg1B, msg2A, msg2B, msg3)); + } + + @Test + void maintains_order_by_kafka_key_using_kafka_key_strategy() { + final PublishingProperties properties = new PublishingProperties(DEFAULT_BATCH_SIZE, "KAFKA_MESSAGE_KEY", + Set.of(), false, false); + this.task = new OutboxPublishingTask(this.repository, this.parallelPublisher, + new KeyGroupingStrategy(new KafkaKeyGroupingKeyGenerator()), properties); + + final OutboxMessage msg1A = anOutboxMessageBuilder().destination("1").headers(Map.of(KafkaHeaders.KEY, "1")) + .build(); + final OutboxMessage msg1B = anOutboxMessageBuilder().destination("1").headers(Map.of(KafkaHeaders.KEY, "1")) + .build(); + final OutboxMessage msg1C = anOutboxMessageBuilder().destination("1").headers(Map.of(KafkaHeaders.KEY, "2")) + .build(); + final OutboxMessage msg2A = anOutboxMessageBuilder().destination("2").headers(Map.of(KafkaHeaders.KEY, "1")) + .build(); + // this order is not important + this.repository.init(List.of(msg1C, msg1A, msg1B, msg2A)); + + this.task.run(); + + this.assertPendingMessages(List.of()); + // just fail if the order in the same destination an kafka key is not maintained + this.assertPublishedMessages(List.of(msg1A, msg1B, msg1C, msg2A)); + } + + @Test + void failed_messages_block_subsequent_messages_with_same_destination() { + final OutboxMessage msg1A = anOutboxMessageBuilder().destination("1").build(); + final OutboxMessage msg1B = anOutboxMessageBuilder().destination("1").build(); + final OutboxMessage msg1C = anOutboxMessageBuilder().destination("1").build(); + final OutboxMessage msg1D = anOutboxMessageBuilder().destination("1").build(); + final OutboxMessage msg2A = anOutboxMessageBuilder().destination("2").build(); + final OutboxMessage msg2B = anOutboxMessageBuilder().destination("2").build(); + + this.repository.init(List.of(msg1A, msg1B, msg1C, msg1D, msg2A, msg2B)); + + // simulate fail sending third message of destination 1 and second message of + // destination 2 + this.configureFailingMessages(List.of(msg1C, msg2B)); + + this.task.run(); + + this.assertPublishedMessages(List.of(msg1A, msg1B, msg2A)); + this.assertPendingMessages(List.of(msg1C, msg1D, msg2B)); + } + + private void configureFailingMessages(final List messages) { + messages.forEach(message -> when(this.messageSender.send(message)).thenReturn(false)); + } + + @Test + void paused_destinations_are_filtered_out_and_not_processed() { + final Set pausedDestinations = Set.of("paused-1", "paused-2"); + final PublishingProperties properties = new PublishingProperties(DEFAULT_BATCH_SIZE, "DESTINATION", + pausedDestinations, false, false); + this.task = new OutboxPublishingTask(this.repository, this.parallelPublisher, + new KeyGroupingStrategy(new DestinationGroupingKeyGenerator()), properties); + + final OutboxMessage enabledMessage = anOutboxMessageBuilder().destination("enabled").build(); + final OutboxMessage paused1Message = anOutboxMessageBuilder().destination("paused-1").build(); + final OutboxMessage paused2Message = anOutboxMessageBuilder().destination("paused-2").build(); + final OutboxMessage anotherEnabledMessage = anOutboxMessageBuilder().destination("another-enabled").build(); + + this.repository.init(List.of(enabledMessage, paused1Message, paused2Message, anotherEnabledMessage)); + + this.task.run(); + + // Only enabled destinations should be processed + this.assertPublishedMessages(List.of(enabledMessage, anotherEnabledMessage)); + // paused destination messages should remain pending (never retrieved from DB) + this.assertPendingMessages(List.of(paused1Message, paused2Message)); + } + + @Test + void paused_destinations_do_not_count_towards_batch_size_limit() { + final int batchSize = 2; + final Set pausedDestinations = Set.of("paused-dest"); + final PublishingProperties properties = new PublishingProperties(batchSize, "DESTINATION", pausedDestinations, + false, false); + this.task = new OutboxPublishingTask(this.repository, this.parallelPublisher, + new KeyGroupingStrategy(new DestinationGroupingKeyGenerator()), properties); + + // Create messages with controlled capturedAt times to ensure predictable + // ordering + final Instant baseTime = Instant.parse("2023-01-01T10:00:00Z"); + + // First in time: paused message (should be filtered out, not count towards + // batch) + final OutboxMessage pausedMessage1 = anOutboxMessageBuilder() + .destination("paused-dest") + .capturedAt(baseTime) + .build(); + + // Second in time: enabled message (should be processed - 1st in batch) + final OutboxMessage enabledMessage1 = anOutboxMessageBuilder() + .destination("enabled-dest") + .capturedAt(baseTime.plusSeconds(1)) + .build(); + + // Third in time: another paused message (should be filtered out, not count + // towards batch) + final OutboxMessage pausedMessage2 = anOutboxMessageBuilder() + .destination("paused-dest") + .capturedAt(baseTime.plusSeconds(2)) + .build(); + + // Fourth in time: enabled message (should be processed - 2nd in batch) + final OutboxMessage enabledMessage2 = anOutboxMessageBuilder() + .destination("enabled-dest") + .capturedAt(baseTime.plusSeconds(3)) + .build(); + + // Fifth in time: enabled message (should NOT be processed - exceeds batch size) + final OutboxMessage enabledMessage3 = anOutboxMessageBuilder() + .destination("enabled-dest") + .capturedAt(baseTime.plusSeconds(4)) + .build(); + + this.repository.init(List.of(pausedMessage1, enabledMessage1, pausedMessage2, enabledMessage2, enabledMessage3)); + + this.task.run(); + + // Key test: Only 2 enabled messages processed despite paused messages being + // interleaved + // This proves paused messages don't count towards batch size since they're + // filtered at query level + this.assertPublishedMessages(List.of(enabledMessage1, enabledMessage2)); + + // Third enabled message should remain pending (batch size exceeded) + // All paused messages should remain pending (never retrieved from DB) + this.assertPendingMessages(List.of(pausedMessage1, pausedMessage2, enabledMessage3)); + } + + @Test + void globally_paused_publishing_processes_no_messages() { + final PublishingProperties properties = new PublishingProperties(DEFAULT_BATCH_SIZE, "DESTINATION", Set.of(), + true, false); + this.task = new OutboxPublishingTask(this.repository, this.parallelPublisher, + new KeyGroupingStrategy(new DestinationGroupingKeyGenerator()), properties); + + final OutboxMessage message1 = anOutboxMessage(); + final OutboxMessage message2 = anOutboxMessage(); + this.repository.init(List.of(message1, message2)); + + this.task.run(); + + // When globally paused, no messages should be processed + this.assertPublishedMessages(List.of()); + // All messages should remain pending + this.assertPendingMessages(List.of(message1, message2)); + } + + @Test + void globally_paused_publishing_ignores_paused_destinations() { + final Set pausedDestinations = Set.of("destination1"); + final PublishingProperties properties = new PublishingProperties(DEFAULT_BATCH_SIZE, "DESTINATION", + pausedDestinations, true, false); + this.task = new OutboxPublishingTask(this.repository, this.parallelPublisher, + new KeyGroupingStrategy(new DestinationGroupingKeyGenerator()), properties); + + final OutboxMessage message1 = anOutboxMessageBuilder().destination("destination1").build(); + final OutboxMessage message2 = anOutboxMessageBuilder().destination("destination2").build(); + this.repository.init(List.of(message1, message2)); + + this.task.run(); + + // When globally paused, NO messages should be processed, regardless of paused + // destinations + this.assertPublishedMessages(List.of()); + // All messages should remain pending + this.assertPendingMessages(List.of(message1, message2)); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/StreamBridgeOutboxMessageSenderTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/StreamBridgeOutboxMessageSenderTest.java new file mode 100644 index 0000000..f6e18ff --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/StreamBridgeOutboxMessageSenderTest.java @@ -0,0 +1,164 @@ +package dev.inditex.scsoutbox.publish; + +import static dev.inditex.scsoutbox.publish.StreamBridgeOutboxMessageSender.SCS_OUTBOX_PUBLISH_MARK_HEADER; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import dev.inditex.scsoutbox.OutboxMessage; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cloud.stream.config.BindingProperties; +import org.springframework.cloud.stream.config.BindingServiceProperties; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.util.MimeType; + +@ExtendWith(MockitoExtension.class) +class StreamBridgeOutboxMessageSenderTest { + + private StreamBridgeOutboxMessageSender sender; + + @Mock + private StreamBridge bridge; + + @Mock + private BindingServiceProperties bindingServiceProperties; + + @Captor + private ArgumentCaptor> messageCaptor; + + @BeforeEach + void beforeEach() { + this.sender = new StreamBridgeOutboxMessageSender(this.bridge, this.bindingServiceProperties); + } + + @Nested + class SendDefault { + + @Test + void when_payload_is_object_expect_default_send_with_content_type() { + final BindingProperties bindingProps = new BindingProperties(); + bindingProps.setContentType("application/json"); + doReturn(bindingProps).when(StreamBridgeOutboxMessageSenderTest.this.bindingServiceProperties) + .getBindingProperties("myBinding"); + doReturn(true).when(StreamBridgeOutboxMessageSenderTest.this.bridge) + .send(any(String.class), any(Object.class), any(MimeType.class)); + final OutboxMessage outboxMessage = OutboxMessage.builder() + .id(UUID.randomUUID()) + .capturedAt(Instant.parse("2025-10-22T18:45:00Z")) + .destination("myDestination") + .bindingName("myBinding") + .payload("stringPayload") + .headers(Map.of()) + .build(); + + final boolean result = StreamBridgeOutboxMessageSenderTest.this.sender.send(outboxMessage); + + assertThat(result).isTrue(); + verify(StreamBridgeOutboxMessageSenderTest.this.bridge, times(1)) + .send(eq("myBinding"), StreamBridgeOutboxMessageSenderTest.this.messageCaptor.capture(), + eq(MimeType.valueOf("application/json"))); + final Message sentMessage = StreamBridgeOutboxMessageSenderTest.this.messageCaptor.getValue(); + assertThat(sentMessage.getPayload()).isEqualTo("stringPayload"); + assertThat(sentMessage.getHeaders()).containsKey(SCS_OUTBOX_PUBLISH_MARK_HEADER); + } + } + + @Nested + class SendRaw { + + @Test + void when_payload_is_byte_array_expect_outbox_message_sent_with_custom_mime_type() { + doReturn(true).when(StreamBridgeOutboxMessageSenderTest.this.bridge) + .send(any(String.class), any(Object.class), any(MimeType.class)); + final byte[] rawPayload = new byte[]{1, 2, 3, 4, 5}; + final OutboxMessage outboxMessage = OutboxMessage.builder() + .id(UUID.randomUUID()) + .capturedAt(Instant.parse("2025-10-22T18:45:00Z")) + .destination("myDestination") + .bindingName("myBinding") + .payload(rawPayload) + .headers(Map.of(MessageHeaders.CONTENT_TYPE, MimeType.valueOf("application/*+avro"))) + .build(); + + final boolean result = StreamBridgeOutboxMessageSenderTest.this.sender.send(outboxMessage); + + assertThat(result).isTrue(); + verify(StreamBridgeOutboxMessageSenderTest.this.bridge, times(1)) + .send(eq("myBinding"), StreamBridgeOutboxMessageSenderTest.this.messageCaptor.capture(), + eq(OutboxMessageConverter.SCS_OUTBOX_MESSAGE_MIME_TYPE)); + final Message sentMessage = StreamBridgeOutboxMessageSenderTest.this.messageCaptor.getValue(); + assertThat(sentMessage.getPayload()).isInstanceOf(OutboxMessage.class); + assertThat(((OutboxMessage) sentMessage.getPayload()).getPayload()).isEqualTo(rawPayload); + } + + @Test + void when_payload_is_byte_array_expect_outbox_message_carries_original_headers() { + doReturn(true).when(StreamBridgeOutboxMessageSenderTest.this.bridge) + .send(any(String.class), any(Object.class), any(MimeType.class)); + final MimeType capturedContentType = MimeType.valueOf("application/json"); + final Map originalHeaders = Map.of( + "custom-header", "custom-value", + MessageHeaders.CONTENT_TYPE, capturedContentType); + final OutboxMessage outboxMessage = OutboxMessage.builder() + .id(UUID.randomUUID()) + .capturedAt(Instant.parse("2025-10-22T18:45:00Z")) + .destination("myDestination") + .bindingName("myBinding") + .payload(new byte[]{1, 2, 3}) + .headers(originalHeaders) + .build(); + + StreamBridgeOutboxMessageSenderTest.this.sender.send(outboxMessage); + + verify(StreamBridgeOutboxMessageSenderTest.this.bridge, times(1)) + .send(eq("myBinding"), StreamBridgeOutboxMessageSenderTest.this.messageCaptor.capture(), + eq(OutboxMessageConverter.SCS_OUTBOX_MESSAGE_MIME_TYPE)); + final Message sentMessage = StreamBridgeOutboxMessageSenderTest.this.messageCaptor.getValue(); + final OutboxMessage sentOutboxMessage = (OutboxMessage) sentMessage.getPayload(); + assertThat(sentOutboxMessage.getHeaders()).containsEntry("custom-header", "custom-value"); + assertThat(sentOutboxMessage.getHeaders()).containsEntry(MessageHeaders.CONTENT_TYPE, capturedContentType); + } + + @Test + void when_payload_is_byte_array_expect_publish_mark_header_in_message_wrapper() { + doReturn(true).when(StreamBridgeOutboxMessageSenderTest.this.bridge) + .send(any(String.class), any(Object.class), any(MimeType.class)); + final OutboxMessage outboxMessage = OutboxMessage.builder() + .id(UUID.randomUUID()) + .capturedAt(Instant.parse("2025-10-22T18:45:00Z")) + .destination("myDestination") + .bindingName("myBinding") + .payload(new byte[]{1, 2, 3}) + .headers(Map.of(MessageHeaders.CONTENT_TYPE, MimeType.valueOf("application/json"))) + .build(); + + StreamBridgeOutboxMessageSenderTest.this.sender.send(outboxMessage); + + verify(StreamBridgeOutboxMessageSenderTest.this.bridge, times(1)) + .send(eq("myBinding"), StreamBridgeOutboxMessageSenderTest.this.messageCaptor.capture(), + eq(OutboxMessageConverter.SCS_OUTBOX_MESSAGE_MIME_TYPE)); + final Message sentMessage = StreamBridgeOutboxMessageSenderTest.this.messageCaptor.getValue(); + // The wrapper Message must carry the publish mark header so the OutboxChannelInterceptor + // recognizes it as an outbox-published message and lets it through + assertThat(sentMessage.getHeaders()).containsKey(SCS_OUTBOX_PUBLISH_MARK_HEADER); + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/config/PublishingPropertiesTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/config/PublishingPropertiesTest.java new file mode 100644 index 0000000..7b82028 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/publish/config/PublishingPropertiesTest.java @@ -0,0 +1,59 @@ +package dev.inditex.scsoutbox.publish.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Set; + +import dev.inditex.scsoutbox.publish.config.PublishingProperties.GroupingMode; + +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class PublishingPropertiesTest { + + @Nested + class Constructor { + + @Test + void when_all_params_provided_expect_correct_values() { + final var properties = new PublishingProperties(2, "KAFKA_MESSAGE_KEY", Set.of("destination1", "destination2"), true, true); + + assertThat(properties.getBatchSize()).isEqualTo(2); + assertThat(properties.getGroupingStrategy()).isEqualTo(GroupingMode.KAFKA_MESSAGE_KEY); + assertThat(properties.getPausedDestinations()).containsExactlyInAnyOrder("destination1", "destination2"); + assertThat(properties.isPaused()).isTrue(); + assertThat(properties.isAfterCommit()).isTrue(); + } + + @Test + void when_null_params_expect_default_values() { + final var properties = new PublishingProperties(null, null, null, null, null); + + assertThat(properties.getBatchSize()).isEqualTo(PublishingProperties.DEFAULT_BATCH_SIZE); + assertThat(properties.getGroupingStrategy()).isEqualTo(GroupingMode.DESTINATION); + assertThat(properties.getPausedDestinations()).isEmpty(); + assertThat(properties.isPaused()).isFalse(); + assertThat(properties.isAfterCommit()).isFalse(); + } + + @ParameterizedTest + @ValueSource(ints = {-1, 0}) + void when_invalid_batch_size_expect_exception(final int batchSize) { + final ThrowingCallable result = () -> new PublishingProperties(batchSize, null, null, null, null); + + assertThatThrownBy(result).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void when_invalid_grouping_strategy_expect_exception() { + final ThrowingCallable result = () -> new PublishingProperties(null, "INVALID", null, null, null); + + assertThatThrownBy(result).isInstanceOf(IllegalArgumentException.class); + } + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/scheduler/AfterCommitTriggerTest.java b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/scheduler/AfterCommitTriggerTest.java new file mode 100644 index 0000000..fee1099 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-core/src/test/java/dev/inditex/scsoutbox/scheduler/AfterCommitTriggerTest.java @@ -0,0 +1,42 @@ +package dev.inditex.scsoutbox.scheduler; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import dev.inditex.scsoutbox.scheduler.AfterCommitTrigger.MessageCaptured; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; + +class AfterCommitTriggerTest { + + private AfterCommitTrigger afterCommitTrigger; + + private ApplicationEventPublisher applicationEventPublisher; + + private OutboxScheduledService outboxScheduledService; + + @BeforeEach + void setUp() { + this.applicationEventPublisher = mock(ApplicationEventPublisher.class); + this.outboxScheduledService = mock(OutboxScheduledService.class); + this.afterCommitTrigger = new AfterCommitTrigger(this.applicationEventPublisher, this.outboxScheduledService); + } + + @Test + void publish_message_capture_event() { + this.afterCommitTrigger.publishMessageCapturedEvent(); + + verify(this.applicationEventPublisher).publishEvent(any(MessageCaptured.class)); + } + + @Test + void execute_outbox_publishing_task() { + this.afterCommitTrigger.afterCommit(new MessageCaptured() {}); + + verify(this.outboxScheduledService).outboxPublishingTask(); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/pom.xml b/code/scs-outbox-libs/scs-outbox-jdbc/pom.xml new file mode 100644 index 0000000..d184636 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/pom.xml @@ -0,0 +1,89 @@ + + + 4.0.0 + + dev.inditex.scsoutbox + scs-outbox-libs + 1.0.0-SNAPSHOT + + + scs-outbox-jdbc + + + + + dev.inditex.scsoutbox + scs-outbox-core + + + dev.inditex.scsoutbox + scs-outbox-serialization + + + net.javacrumbs.shedlock + shedlock-provider-jdbc-template + + + org.springframework + spring-jdbc + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + dev.inditex.scsoutbox + scs-outbox-test-support + test + + + + com.h2database + h2 + test + + + org.junit.jupiter + junit-jupiter + test + + + org.mariadb.jdbc + mariadb-java-client + test + + + org.postgresql + postgresql + test + + + org.springframework.boot + spring-boot-starter-test + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + org.testcontainers + testcontainers-mariadb + test + + + org.testcontainers + testcontainers-postgresql + test + + + org.testcontainers + testcontainers + test + + + + diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/DataSourceMetadata.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/DataSourceMetadata.java new file mode 100644 index 0000000..f2e269a --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/DataSourceMetadata.java @@ -0,0 +1,112 @@ +package dev.inditex.scsoutbox.jdbc; + +import java.sql.Connection; +import java.sql.SQLException; +import javax.sql.DataSource; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Class that encapsulates database metadata detection functionality. This class handles detection of database type and default schemaName. + */ +@Slf4j +public class DataSourceMetadata { + + /** + * Enumeration of supported database types. + */ + public enum JdbcDatabaseType { + POSTGRESQL, MARIADB, OTHER + } + + @Getter + private final JdbcDatabaseType databaseType; + + @Getter + private final String defaultSchema; + + /** + * Exception thrown when there is a database access error. + */ + public static class DatabaseAccessException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public DatabaseAccessException(String message, Throwable cause) { + super(message, cause); + } + } + + /** + * Creates a new JdbcDataSourceMetadata instance and detects both database type and default schemaName. + * + * @param dataSource The data source to extract metadata from + */ + public DataSourceMetadata(DataSource dataSource) { + try (final Connection connection = dataSource.getConnection()) { + this.databaseType = this.detectDatabaseType(connection); + this.defaultSchema = this.detectDefaultSchema(connection); + log.info("Detected database type: {}, default schema: {}", this.databaseType, + this.defaultSchema.isEmpty() ? "" : this.defaultSchema); + } catch (final SQLException e) { + throw new DatabaseAccessException("Error accessing database metadata", e); + } + } + + /** + * Detects the database type from the connection metadata. + * + * @param connection The database connection + * @return The detected database type + * @throws SQLException If there is an error accessing the database metadata + */ + private JdbcDatabaseType detectDatabaseType(Connection connection) throws SQLException { + final String databaseProductName = connection.getMetaData().getDatabaseProductName(); + if (databaseProductName.toLowerCase().contains("postgresql")) { + return JdbcDatabaseType.POSTGRESQL; + } else if (databaseProductName.toLowerCase().contains("mariadb")) { + return JdbcDatabaseType.MARIADB; + } else { + return JdbcDatabaseType.OTHER; + } + } + + /** + * Detects the default schemaName from the connection. If schemaName cannot be detected, returns an empty string instead of throwing an + * exception. + * + * @param connection The database connection + * @return The detected default schemaName or empty string if not detected + */ + private String detectDefaultSchema(Connection connection) { + try { + // Try to get the current schemaName from the connection + final String currentSchema = connection.getSchema(); + if (this.isNotEmpty(currentSchema)) { + return currentSchema; + } + + // If no schemaName is available, try using the catalog name + final String catalog = connection.getCatalog(); + if (this.isNotEmpty(catalog)) { + return catalog; + } + } catch (final SQLException e) { + log.debug("Error detecting default schemaName: {}", e.getMessage()); + } + + // Return empty string if schemaName cannot be detected + log.debug("Could not detect default schemaName from database connection"); + return ""; + } + + /** + * Checks if a string is not empty, meaning it is not null and not an empty string. + * + * @param string The string to check + * @return true if the string is not null and not empty, false otherwise + */ + private boolean isNotEmpty(final String string) { + return string != null && !string.isEmpty(); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/DbNamingValidator.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/DbNamingValidator.java new file mode 100644 index 0000000..ff2f487 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/DbNamingValidator.java @@ -0,0 +1,37 @@ +package dev.inditex.scsoutbox.jdbc; + +import java.util.regex.Pattern; + +/** + * Utility class for database object name validation. Provides common validation logic for schema and table names. + */ +public final class DbNamingValidator { + private static final int MAX_LENGTH = 128; + + private static final Pattern VALID_CHARACTERS_PATTERN = Pattern.compile("^\\w+$"); + + private DbNamingValidator() { + // Utility class should not be instantiated + } + + /** + * Validates a database object name. + * + * @param value the name value to validate + * @param nameType the type of name (e.g., "Schema", "Table") + * @return the validated value + * @throws IllegalArgumentException if the value is invalid + */ + public static String validate(String value, String nameType) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(nameType + " value cannot be null or empty"); + } + if (value.length() > MAX_LENGTH) { + throw new IllegalArgumentException(nameType + " value cannot exceed " + MAX_LENGTH + " characters"); + } + if (!VALID_CHARACTERS_PATTERN.matcher(value).matches()) { + throw new IllegalArgumentException(nameType + " value can only contain alphanumeric characters and underscores"); + } + return value; + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/DbSchemaResolver.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/DbSchemaResolver.java new file mode 100644 index 0000000..d5ee3ea --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/DbSchemaResolver.java @@ -0,0 +1,54 @@ +package dev.inditex.scsoutbox.jdbc; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Resolves the database schemaName to be used, prioritizing the configured schemaName over the detected default schemaName. + */ +@Slf4j +@RequiredArgsConstructor // Automatically creates constructor for final fields +public class DbSchemaResolver { + + private final DataSourceMetadata dataSourceMetadata; + + /** + * Exception thrown when a schemaName cannot be resolved. + */ + public static class SchemaResolutionException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public SchemaResolutionException(String message) { + super(message); + } + } + + /** + * Resolves the schemaName to use. + * + *

It prioritizes the explicitly configured schemaName if available. If no schemaName is configured, it falls back to the default + * schemaName detected from the database connection via JdbcDataSourceMetadata. If neither a configured schemaName nor a default + * schemaName can be determined, an exception is thrown.

+ * + * @param configuredSchema the schemaName configured in properties (can be null or empty). + * @return the resolved schemaName to use. + * @throws SchemaResolutionException if no schemaName can be resolved. + */ + public String resolve(String configuredSchema) { + if (configuredSchema != null && !configuredSchema.isEmpty()) { + log.debug("Using configured schema: {}", configuredSchema); + return configuredSchema; + } + + // Get default schemaName from the metadata collaborator + final String defaultSchema = this.dataSourceMetadata.getDefaultSchema(); + if (defaultSchema != null && !defaultSchema.isEmpty()) { + log.debug("Using detected default schema: {}", defaultSchema); + return defaultSchema; // Default schemaName from detection is assumed to be trimmed or valid + } + + // If we can't determine a schemaName, throw an exception + throw new SchemaResolutionException( + "No schemaName configured and could not detect default schemaName from database connection"); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxDataSourceProvider.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxDataSourceProvider.java new file mode 100644 index 0000000..9972792 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxDataSourceProvider.java @@ -0,0 +1,76 @@ +package dev.inditex.scsoutbox.jdbc; + +import java.util.Objects; +import javax.sql.DataSource; + +import lombok.extern.slf4j.Slf4j; + +/** + * JDBC implementation of {@link OutboxDataSourceProvider}. Manages DataSource instances for capture and publishing operations, with + * optional dedicated publishing DataSource. + */ +@Slf4j +public class JdbcOutboxDataSourceProvider implements OutboxDataSourceProvider { + + private final DataSource primaryDataSource; // capture + + private final DataSource publishingDataSource; // dedicated or same as primary + + private final DataSourceMetadata primaryMetadata; + + private final DataSourceMetadata publishingMetadata; + + /** + * Creates a provider with a mandatory capture DataSource and an optional publishing DataSource. + * + * @param captureDataSource the DataSource for capture operations (required, typically the primary DataSource) + * @param publishingDataSource the DataSource for publishing operations (optional, can be null) + */ + public JdbcOutboxDataSourceProvider( + DataSource captureDataSource, + DataSource publishingDataSource) { + + this.primaryDataSource = Objects.requireNonNull(captureDataSource, + "Capture DataSource is required"); + + // If no dedicated publishing DataSource, use the same as capture + if (publishingDataSource == null) { + this.publishingDataSource = this.primaryDataSource; + log.info("No dedicated publishing DataSource configured. Using primary DataSource for both capture and publishing operations"); + } else { + this.publishingDataSource = publishingDataSource; + log.info("Using dedicated DataSources: capture and publishing operations are isolated"); + } + + // Create and cache metadata (expensive operation, done once) + this.primaryMetadata = new DataSourceMetadata(this.primaryDataSource); + this.publishingMetadata = (this.publishingDataSource == this.primaryDataSource) + ? this.primaryMetadata + : new DataSourceMetadata(this.publishingDataSource); + + log.debug("Primary (capture) DataSource type: {}", this.primaryMetadata.getDatabaseType()); + if (this.publishingDataSource != this.primaryDataSource) { + log.debug("Publishing DataSource type: {}", this.publishingMetadata.getDatabaseType()); + } + } + + @Override + public DataSource getPrimary() { + return this.primaryDataSource; + } + + @Override + public DataSource getDedicatedForPublishing() { + return this.publishingDataSource; + } + + @Override + public DataSourceMetadata getPrimaryDataSourceMetadata() { + return this.primaryMetadata; + } + + @Override + public DataSourceMetadata getDedicatedForPublishingDataSourceMetadata() { + return this.publishingMetadata; + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepository.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepository.java new file mode 100755 index 0000000..72edc69 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepository.java @@ -0,0 +1,270 @@ +package dev.inditex.scsoutbox.jdbc; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +import dev.inditex.scsoutbox.OutboxMessage; +import dev.inditex.scsoutbox.OutboxMessageRepository; +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer; +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer.SerializedOutboxMessage; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * JDBC implementation of OutboxMessageRepository. + */ +@Slf4j +public class JdbcOutboxMessageRepository implements OutboxMessageRepository { + + private final JdbcTemplate jdbcTemplate; + + private final Table table; + + private final OutboxMessageSerializer serializer; + + /** + * Creates a new instance of JdbcOutboxMessageRepository. + * + * @param jdbcTemplate the JDBC template to use + * @param table the table location to use + */ + public JdbcOutboxMessageRepository(JdbcTemplate jdbcTemplate, Table table, OutboxMessageSerializer outboxMessageSerializer) { + this.jdbcTemplate = jdbcTemplate; + this.table = table; + this.serializer = outboxMessageSerializer; + log.info("Using table: {}", table.getQualifiedTableName()); + } + + /** + * Container class for SQL query and its parameters. + */ + @Getter + @RequiredArgsConstructor + protected static class Query { + private final String sql; + + private final Object[] params; + + /** + * Creates a new instance with an empty parameter array. + * + * @param sql the SQL query + * @return a new instance with the given SQL and empty params + */ + public static Query of(String sql) { + return new Query(sql, new Object[0]); + } + + /** + * Creates a new instance with the given SQL and params. + * + * @param sql the SQL query + * @param params the query parameters + * @return a new instance with the given SQL and params + */ + public static Query of(String sql, Object... params) { + return new Query(sql, params); + } + } + + @Override + public List findAllOrderByCapturedAt(int maxResults) { + final Query query = this.buildFindAllQuery(null, maxResults); + return this.queryWithDeserializationErrorHandling(query); + } + + /** + * Executes a query and deserializes messages one by one, stopping at the first deserialization error. Messages that were successfully + * deserialized before the error are returned. + * + * @param query the query to execute + * @return list of successfully deserialized messages (may be truncated if a deserialization error occurred) + */ + private List queryWithDeserializationErrorHandling(Query query) { + final List messages = new ArrayList<>(); + final AtomicBoolean errorOccurred = new AtomicBoolean(false); + + this.jdbcTemplate.query(query.getSql(), rs -> { + if (errorOccurred.get()) { + return; + } + try { + final OutboxMessage message = this.mapRow(rs); + messages.add(message); + } catch (final Exception e) { + errorOccurred.set(true); + log.error("Deserialization failed for message [{}] at destination [{}]. " + + "Returning {} successfully deserialized messages.", + rs.getString("ID"), + rs.getString("DESTINATION"), + messages.size(), e); + } + }, query.getParams()); + + return messages; + } + + /** + * Maps a single row from the ResultSet to an OutboxMessage. + * + * @param rs the ResultSet positioned at the current row + * @return the mapped OutboxMessage + * @throws SQLException if a database access error occurs + */ + private OutboxMessage mapRow(ResultSet rs) throws SQLException { + final SerializedOutboxMessage serializedOutboxMessage = SerializedOutboxMessage.builder() + .id(UUID.fromString(rs.getObject("ID", String.class))) + .bindingName(rs.getObject("BINDING_NAME", String.class)) + .capturedAt(rs.getObject("CAPTURED_AT", Timestamp.class).toInstant()) + .destination(rs.getObject("DESTINATION", String.class)) + .headers(rs.getObject("HEADERS", String.class)) + .payload(rs.getBytes("PAYLOAD")) + .build(); + return this.serializer.deserialize(serializedOutboxMessage); + } + + @Override + public List findAllOrderByCapturedAtExcludingDestinations(Set excludedDestinations, int maxResults) { + if (excludedDestinations == null || excludedDestinations.isEmpty()) { + return this.findAllOrderByCapturedAt(maxResults); + } + + final Query query = this.buildFindAllQuery(excludedDestinations, maxResults); + return this.queryWithDeserializationErrorHandling(query); + } + + /** + * Builds the SQL query for finding outbox messages with optional destination exclusions. + * + * @param excludedDestinations destinations to exclude, or null for no exclusions + * @param maxResults maximum number of results, or <= 0 for unlimited + * @return Query object containing SQL and parameters + */ + private Query buildFindAllQuery(Set excludedDestinations, int maxResults) { + final StringBuilder sql = new StringBuilder("SELECT * FROM " + this.table.getQualifiedTableName()); + + Object[] params = new Object[0]; + + // Add WHERE clause if destinations need to be excluded + if (excludedDestinations != null && !excludedDestinations.isEmpty()) { + sql.append(" WHERE DESTINATION NOT IN ("); + for (int i = 0; i < excludedDestinations.size(); i++) { + if (i > 0) { + sql.append(", "); + } + sql.append("?"); + } + sql.append(")"); + params = excludedDestinations.toArray(); + } + + sql.append(" ORDER BY CAPTURED_AT ASC"); + + // Add LIMIT clause if maxResults is specified + if (maxResults > 0) { + sql.append(" LIMIT ").append(maxResults); + } + + return Query.of(sql.toString(), params); + } + + protected JdbcTemplate getJdbcTemplate() { + return this.jdbcTemplate; + } + + /** + * Gets the table name. + * + * @return the table name + */ + protected String getTableName() { + return this.table.tableName().value(); + } + + /** + * Gets the schemaName. + * + * @return the schemaName + */ + protected String getSchema() { + return this.table.schemaName().value(); + } + + @Override + public long count() { + final Query countQuery = this.getCountQuery(); + return this.queryForLong(countQuery.getSql(), countQuery.getParams()); + } + + private Query getCountQuery() { + return Query.of("SELECT COUNT(*) FROM " + this.table.getQualifiedTableName()); + } + + /** + * Gets the estimated count query with its parameters. Override this method in database-specific implementations to provide a custom query + * for retrieving the estimated count. + * + * @return a SqlQueryWithParams object containing the query and its parameters + */ + protected Query getEstimatedCountQuery() { + // By default, we use the count query for the estimated count for JDBC generic repositories. + return this.getCountQuery(); + } + + @Override + public long estimatedCount() { + try { + final Query queryWithParams = this.getEstimatedCountQuery(); + return this.queryForLong(queryWithParams.getSql(), queryWithParams.getParams()); + } catch (final Exception e) { + log.warn("An estimated count cannot be obtained.", e); + return this.count(); + } + } + + /** + * Executes a parameterized query that returns a single long value. + * + * @param sql the SQL query to execute + * @param args the arguments to bind to the query + * @return the long value returned by the query, or 0 if the result is null + */ + private long queryForLong(String sql, Object[] args) { + final Long result = this.getJdbcTemplate().queryForObject(sql, Long.class, args); + return result != null ? result : 0; + } + + @Override + @Transactional(propagation = Propagation.REQUIRED) + public void save(final OutboxMessage outboxMessage) { + final SerializedOutboxMessage serializedOutboxMessage = this.serializer.serialize(outboxMessage); + this.jdbcTemplate.update( + "INSERT INTO " + + this.table.getQualifiedTableName() + " (ID, BINDING_NAME, CAPTURED_AT, DESTINATION, HEADERS, PAYLOAD) " + + "VALUES ( ?, ?, ?, ?, ?, ?)", + serializedOutboxMessage.getId().toString(), + serializedOutboxMessage.getBindingName(), + Timestamp.from(serializedOutboxMessage.getCapturedAt()), + serializedOutboxMessage.getDestination(), + serializedOutboxMessage.getHeaders(), + serializedOutboxMessage.getPayload()); + } + + @Override + @Transactional(propagation = Propagation.REQUIRED) + public void delete(final OutboxMessage outboxMessage) { + this.jdbcTemplate.update("DELETE FROM " + this.table.getQualifiedTableName() + " WHERE ID = ?", + outboxMessage.getId().toString()); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepositoryFactory.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepositoryFactory.java new file mode 100644 index 0000000..f41556c --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepositoryFactory.java @@ -0,0 +1,49 @@ +package dev.inditex.scsoutbox.jdbc; + +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Factory for creating {@link JdbcOutboxMessageRepository} instances based on the detected database type. This factory expects pre-resolved + * definition provided via {@link Table}. + */ +@Slf4j +public final class JdbcOutboxMessageRepositoryFactory { + + private JdbcOutboxMessageRepositoryFactory() { + // Prevent instantiation + } + + /** + * Creates a new {@link JdbcOutboxMessageRepository} instance based on the detected database type using pre-resolved Table, with raw + * passthrough support. + * + * @param jdbcTemplate The {@link JdbcTemplate} to use. + * @param datasourceMetadata The detected database metadata. + * @param table The pre-resolved JDBC repository Table (schemaName, tableName). + * @return A new {@link JdbcOutboxMessageRepository} instance suitable for the detected database. + */ + public static JdbcOutboxMessageRepository create( + JdbcTemplate jdbcTemplate, + DataSourceMetadata datasourceMetadata, + Table table, + OutboxMessageSerializer outboxMessageSerializer) { + + // Create appropriate repository implementation based on the detected database type + switch (datasourceMetadata.getDatabaseType()) { + case POSTGRESQL: + return new PostgresqlJdbcOutboxMessageRepository( + jdbcTemplate, table, outboxMessageSerializer); + case MARIADB: + return new MariadbJdbcOutboxMessageRepository( + jdbcTemplate, table, outboxMessageSerializer); + default: + log.warn("Database type {} not specifically handled, using default JDBC repository implementation.", + datasourceMetadata.getDatabaseType()); + return new JdbcOutboxMessageRepository( + jdbcTemplate, table, outboxMessageSerializer); + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/MariadbJdbcOutboxMessageRepository.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/MariadbJdbcOutboxMessageRepository.java new file mode 100755 index 0000000..415ba68 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/MariadbJdbcOutboxMessageRepository.java @@ -0,0 +1,24 @@ +package dev.inditex.scsoutbox.jdbc; + +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * MariaDB/MySQL-specific implementation of JdbcOutboxMessageRepository. + */ +@Slf4j +public class MariadbJdbcOutboxMessageRepository extends JdbcOutboxMessageRepository { + + public MariadbJdbcOutboxMessageRepository(JdbcTemplate jdbcTemplate, Table table, OutboxMessageSerializer outboxMessageSerializer) { + super(jdbcTemplate, table, outboxMessageSerializer); + } + + @Override + protected Query getEstimatedCountQuery() { + // Create a parameterized query with table name and schemaName as parameters + final String sql = "SELECT table_rows FROM information_schema.tables WHERE table_name = ? AND table_schema = ?"; + return Query.of(sql, this.getTableName(), this.getSchema()); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/OutboxDataSourceProvider.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/OutboxDataSourceProvider.java new file mode 100644 index 0000000..7b143c3 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/OutboxDataSourceProvider.java @@ -0,0 +1,39 @@ +package dev.inditex.scsoutbox.jdbc; + +import javax.sql.DataSource; + +/** + * Provides DataSource instances and their metadata for different outbox operations. This allows using different connection pools for + * capture (transactional) and publishing (scheduled) operations. + */ +public interface OutboxDataSourceProvider { + + /** + * Gets the DataSource used for message capture operations. This DataSource is used during application transactions. + * + * @return the capture DataSource (never null) + */ + DataSource getPrimary(); + + /** + * Gets the DataSource used for message publishing operations. This DataSource is used during scheduled publishing tasks. Returns the same + * as capture DataSource if no dedicated publishing DataSource is configured. + * + * @return the publishing DataSource (never null) + */ + DataSource getDedicatedForPublishing(); + + /** + * Gets the metadata for the capture DataSource. + * + * @return metadata about the capture DataSource + */ + DataSourceMetadata getPrimaryDataSourceMetadata(); + + /** + * Gets the metadata for the publishing DataSource. + * + * @return metadata about the publishing DataSource + */ + DataSourceMetadata getDedicatedForPublishingDataSourceMetadata(); +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/PostgresqlJdbcOutboxMessageRepository.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/PostgresqlJdbcOutboxMessageRepository.java new file mode 100755 index 0000000..2648fb0 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/PostgresqlJdbcOutboxMessageRepository.java @@ -0,0 +1,24 @@ +package dev.inditex.scsoutbox.jdbc; + +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * PostgreSQL-specific implementation of JdbcOutboxMessageRepository. + */ +@Slf4j +public class PostgresqlJdbcOutboxMessageRepository extends JdbcOutboxMessageRepository { + + public PostgresqlJdbcOutboxMessageRepository(JdbcTemplate jdbcTemplate, Table table, OutboxMessageSerializer outboxMessageSerializer) { + super(jdbcTemplate, table, outboxMessageSerializer); + } + + @Override + protected Query getEstimatedCountQuery() { + // Create a parameterized query with table name and schemaName as parameters + final String sql = "SELECT n_live_tup FROM pg_stat_all_tables WHERE relname = LOWER(?) AND schemaname = LOWER(?)"; + return Query.of(sql, this.getTableName(), this.getSchema()); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/SchemaName.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/SchemaName.java new file mode 100644 index 0000000..43af37b --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/SchemaName.java @@ -0,0 +1,17 @@ +package dev.inditex.scsoutbox.jdbc; + +/** + * Represents a validated schema name. + */ +public record SchemaName(String value) { + /** + * Creates a new schema name with validation. + * + * @param value the schema name value + * @throws IllegalArgumentException if the value is invalid + */ + public SchemaName(String value) { + this.value = DbNamingValidator.validate(value, this.getClass().getSimpleName()); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/Table.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/Table.java new file mode 100644 index 0000000..3da18f0 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/Table.java @@ -0,0 +1,31 @@ +package dev.inditex.scsoutbox.jdbc; + +/** + * Value object representing a qualified database table name. + * + * @param schemaName table schema name + * @param tableName table name + */ +public record Table(SchemaName schemaName, TableName tableName) { + + /** + * Creates a table value object with non-null schema and table names. + */ + public Table { + if (schemaName == null) { + throw new IllegalArgumentException("schemaName cannot be null"); + } + if (tableName == null) { + throw new IllegalArgumentException("tableName cannot be null"); + } + } + + /** + * Returns the qualified table name in {@code schema.table} format. + * + * @return the qualified table name + */ + public String getQualifiedTableName() { + return this.schemaName.value() + "." + this.tableName.value(); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/TableName.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/TableName.java new file mode 100644 index 0000000..943b3f4 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/TableName.java @@ -0,0 +1,17 @@ +package dev.inditex.scsoutbox.jdbc; + +/** + * Represents a validated table name. + */ +public record TableName(String value) { + /** + * Creates a new table name with validation. + * + * @param value the table name value + * @throws IllegalArgumentException if the value is invalid + */ + public TableName(String value) { + this.value = DbNamingValidator.validate(value, this.getClass().getSimpleName()); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/AbstractJdbcProperties.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/AbstractJdbcProperties.java new file mode 100644 index 0000000..361a7fa --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/AbstractJdbcProperties.java @@ -0,0 +1,45 @@ +package dev.inditex.scsoutbox.jdbc.config; + +import lombok.Getter; + +/** + * Base JDBC configuration properties for outbox-related table and schema names. + */ +public abstract class AbstractJdbcProperties { + + /** + * Default schema value used when no schema is configured. + */ + protected static final String DEFAULT_SCHEMA_VALUE = ""; + + @Getter + private final String tableName; + + @Getter + private final String schema; + + /** + * Creates validated JDBC properties for table and schema names. + * + * @param inputTableName configured table name + * @param inputSchema configured schema name + * @param defaultTableName default table name used when the configured one is empty + */ + protected AbstractJdbcProperties(final String inputTableName, final String inputSchema, final String defaultTableName) { + if (inputTableName == null || inputTableName.isEmpty()) { + this.tableName = defaultTableName; + } else if (inputTableName.contains(" ")) { + throw new IllegalArgumentException("Table name cannot contain spaces"); + } else { + this.tableName = inputTableName; + } + + if (inputSchema == null || inputSchema.isEmpty()) { + this.schema = DEFAULT_SCHEMA_VALUE; + } else if (inputSchema.contains(" ")) { + throw new IllegalArgumentException("Schema name cannot contain spaces"); + } else { + this.schema = inputSchema; + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/JdbcDataSourceAutoConfiguration.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/JdbcDataSourceAutoConfiguration.java new file mode 100755 index 0000000..877126c --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/JdbcDataSourceAutoConfiguration.java @@ -0,0 +1,43 @@ +package dev.inditex.scsoutbox.jdbc.config; + +import javax.sql.DataSource; + +import dev.inditex.scsoutbox.config.OutboxAutoConfiguration; +import dev.inditex.scsoutbox.jdbc.JdbcOutboxDataSourceProvider; +import dev.inditex.scsoutbox.jdbc.OutboxDataSourceProvider; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +/** + * Auto-configuration for JDBC DataSource selection used by outbox capture and publishing operations. + */ +@Slf4j +@AutoConfiguration(before = OutboxAutoConfiguration.class) +public class JdbcDataSourceAutoConfiguration { + + /** + * Bean name for the dedicated DataSource used for outbox publishing operations. If a bean with this name exists, it will be used for + * publishing and archive operations. + */ + public static final String OUTBOX_PUBLISHING_DATASOURCE_BEAN_NAME = "outboxPublishingDataSource"; + + /** + * Creates the DataSource provider that coordinates which DataSource to use for each operation. The publishing DataSource is optional - if + * not present, the primary DataSource is used for both operations. + * + * @param captureDataSource the primary DataSource (always present) + * @param publishingDataSource optional dedicated DataSource for publishing operations + * @return the provider that resolves the capture and publishing DataSources + */ + @Bean + public OutboxDataSourceProvider outboxDataSourceProvider( + final DataSource captureDataSource, + @Qualifier(OUTBOX_PUBLISHING_DATASOURCE_BEAN_NAME) @Autowired(required = false) final DataSource publishingDataSource) { + return new JdbcOutboxDataSourceProvider(captureDataSource, publishingDataSource); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/JdbcOutboxAutoConfiguration.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/JdbcOutboxAutoConfiguration.java new file mode 100755 index 0000000..4026577 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/JdbcOutboxAutoConfiguration.java @@ -0,0 +1,98 @@ +package dev.inditex.scsoutbox.jdbc.config; + +import static dev.inditex.scsoutbox.config.OutboxAutoConfiguration.OUTBOX_MESSAGE_REPOSITORY_BEAN_NAME; +import static dev.inditex.scsoutbox.config.OutboxAutoConfiguration.PUBLISHING_OUTBOX_MESSAGE_REPOSITORY_BEAN_NAME; + +import javax.sql.DataSource; + +import dev.inditex.scsoutbox.OutboxMessageRepository; +import dev.inditex.scsoutbox.config.OutboxAutoConfiguration; +import dev.inditex.scsoutbox.jdbc.DataSourceMetadata; +import dev.inditex.scsoutbox.jdbc.DbSchemaResolver; +import dev.inditex.scsoutbox.jdbc.JdbcOutboxMessageRepositoryFactory; +import dev.inditex.scsoutbox.jdbc.OutboxDataSourceProvider; +import dev.inditex.scsoutbox.jdbc.SchemaName; +import dev.inditex.scsoutbox.jdbc.Table; +import dev.inditex.scsoutbox.jdbc.TableName; +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Auto-configuration for JDBC Outbox. Sets up the necessary beans for JDBC-based outbox message repository. + * + *

This configuration uses an OutboxDataSourceProvider to coordinate DataSource usage and two qualified repositories: + * outboxMessageRepository for message capture and publishingOutboxMessageRepository for message publishing. + */ +@Slf4j +@AutoConfiguration(before = OutboxAutoConfiguration.class, after = JdbcDataSourceAutoConfiguration.class) +@EnableConfigurationProperties({JdbcProperties.class}) +public class JdbcOutboxAutoConfiguration { + + /** + * Creates the OutboxMessageRepository for message capture using the capture DataSource from the provider. This repository is used during + * application transactions to capture outbox messages. + * + * @param dataSourceProvider the provider that coordinates DataSource usage + * @param properties JDBC configuration properties + * @param outboxMessageSerializer serializer used to persist outbox payloads and headers + * @return the capture repository instance + */ + @Bean(OUTBOX_MESSAGE_REPOSITORY_BEAN_NAME) + public OutboxMessageRepository outboxMessageRepository( + final OutboxDataSourceProvider dataSourceProvider, + final JdbcProperties properties, + final OutboxMessageSerializer outboxMessageSerializer) { + + final DataSource dataSource = dataSourceProvider.getPrimary(); + final DataSourceMetadata metadata = dataSourceProvider.getPrimaryDataSourceMetadata(); + final DbSchemaResolver dbSchemaResolver = new DbSchemaResolver(metadata); + final String schemaName = dbSchemaResolver.resolve(properties.getSchema()); + final String tableName = properties.getTableName(); + final Table table = new Table(new SchemaName(schemaName), new TableName(tableName)); + + log.info("Creating capture OutboxMessageRepository for table: {}", table.getQualifiedTableName()); + + return JdbcOutboxMessageRepositoryFactory.create( + new JdbcTemplate(dataSource), + metadata, + table, + outboxMessageSerializer); + } + + /** + * Creates the OutboxMessageRepository for message publishing using the publishing DataSource from the provider. This repository is used + * during scheduled tasks to publish outbox messages. It will use a dedicated DataSource if configured, otherwise it will use the primary + * DataSource. + * + * @param dataSourceProvider the provider that coordinates DataSource usage + * @param properties JDBC configuration properties + * @param outboxMessageSerializer serializer used to persist outbox payloads and headers + * @return the publishing repository instance + */ + @Bean(PUBLISHING_OUTBOX_MESSAGE_REPOSITORY_BEAN_NAME) + public OutboxMessageRepository publishingOutboxMessageRepository( + final OutboxDataSourceProvider dataSourceProvider, + final JdbcProperties properties, + final OutboxMessageSerializer outboxMessageSerializer) { + + final DataSource dataSource = dataSourceProvider.getDedicatedForPublishing(); + final DataSourceMetadata metadata = dataSourceProvider.getDedicatedForPublishingDataSourceMetadata(); + final DbSchemaResolver dbSchemaResolver = new DbSchemaResolver(metadata); + final String schemaName = dbSchemaResolver.resolve(properties.getSchema()); + final String tableName = properties.getTableName(); + final Table table = new Table(new SchemaName(schemaName), new TableName(tableName)); + + log.info("Creating publishing OutboxMessageRepository for table: {}", table.getQualifiedTableName()); + + return JdbcOutboxMessageRepositoryFactory.create( + new JdbcTemplate(dataSource), + metadata, + table, + outboxMessageSerializer); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/JdbcProperties.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/JdbcProperties.java new file mode 100644 index 0000000..a729f58 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/JdbcProperties.java @@ -0,0 +1,35 @@ +package dev.inditex.scsoutbox.jdbc.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +/** + * JDBC configuration properties for the outbox table location. + */ +@ConfigurationProperties("scs-outbox.jdbc") +public class JdbcProperties extends AbstractJdbcProperties { + + /** + * Default JDBC outbox table name. + */ + private static final String DEFAULT_TABLE_NAME = "SCS_OUTBOX"; + + /** + * Creates JDBC properties with default table and schema values. + */ + public JdbcProperties() { + super(DEFAULT_TABLE_NAME, DEFAULT_SCHEMA_VALUE, DEFAULT_TABLE_NAME); + } + + /** + * Creates JDBC properties with the configured table and schema values. + * + * @param tableName configured outbox table name + * @param schema configured schema name + */ + @ConstructorBinding + public JdbcProperties(final String tableName, final String schema) { + super(tableName, schema, DEFAULT_TABLE_NAME); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/JdbcShedlockAutoConfiguration.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/JdbcShedlockAutoConfiguration.java new file mode 100755 index 0000000..efab089 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/java/dev/inditex/scsoutbox/jdbc/config/JdbcShedlockAutoConfiguration.java @@ -0,0 +1,31 @@ +package dev.inditex.scsoutbox.jdbc.config; + +import dev.inditex.scsoutbox.jdbc.OutboxDataSourceProvider; + +import net.javacrumbs.shedlock.core.LockProvider; +import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider; +import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +/** + * Auto-configuration for the JDBC ShedLock {@link LockProvider} used by outbox publishing tasks. + */ +@AutoConfiguration +@EnableSchedulerLock(defaultLockAtMostFor = "PT5m") +public class JdbcShedlockAutoConfiguration { + + /** + * Creates the default JDBC {@link LockProvider} for outbox scheduled publishing. + * + * @param dataSourceProvider provider that supplies the DataSource used for publishing + * @return the default JDBC lock provider + */ + @ConditionalOnMissingBean + @Bean + public LockProvider lockProvider(final OutboxDataSourceProvider dataSourceProvider) { + return new JdbcTemplateLockProvider(dataSourceProvider.getDedicatedForPublishing()); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..57dcff6 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,3 @@ +dev.inditex.scsoutbox.jdbc.config.JdbcDataSourceAutoConfiguration +dev.inditex.scsoutbox.jdbc.config.JdbcOutboxAutoConfiguration +dev.inditex.scsoutbox.jdbc.config.JdbcShedlockAutoConfiguration \ No newline at end of file diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/AbstractJdbcOutboxMessageRepositoryTest.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/AbstractJdbcOutboxMessageRepositoryTest.java new file mode 100644 index 0000000..8b5eb3f --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/AbstractJdbcOutboxMessageRepositoryTest.java @@ -0,0 +1,232 @@ +package dev.inditex.scsoutbox.jdbc; + +import static dev.inditex.scsoutbox.OutboxMessageRepository.UNLIMITED; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import javax.sql.DataSource; + +import dev.inditex.scsoutbox.OutboxMessage; +import dev.inditex.scsoutbox.jdbc.config.JdbcProperties; +import dev.inditex.scsoutbox.serialization.JavaSerialization; +import dev.inditex.scsoutbox.serialization.JsonHeadersMapper; +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +abstract class AbstractJdbcOutboxMessageRepositoryTest { + + private JdbcOutboxMessageRepository repository; + + @BeforeEach + void setUp() { + final JdbcTemplate jdbcTemplate = new JdbcTemplate(this.getDataSource()); + final DataSourceMetadata datasourceMetadata = new DataSourceMetadata(this.getDataSource()); + final JdbcProperties properties = new JdbcProperties(); + final String schema = new DbSchemaResolver(datasourceMetadata).resolve(properties.getSchema()); + final Table table = new Table(new SchemaName(schema), new TableName(properties.getTableName())); + final OutboxMessageSerializer serializer = new OutboxMessageSerializer( + new JavaSerialization(), + new JsonHeadersMapper()); + this.repository = JdbcOutboxMessageRepositoryFactory.create( + jdbcTemplate, + datasourceMetadata, + table, + serializer); + jdbcTemplate.execute("DELETE FROM " + table.getQualifiedTableName()); + } + + public abstract DataSource getDataSource(); + + @Test + void count() { + final int expectedNumOfMessages = 3; + for (int i = 0; i < expectedNumOfMessages; i++) { + this.repository.save(this.anOutboxMessage()); + } + assertEquals(expectedNumOfMessages, this.repository.count()); + } + + @Test + void estimatedCount() { + final int expectedNumOfMessages = 3; + for (int i = 0; i < expectedNumOfMessages; i++) { + this.repository.save(this.anOutboxMessage()); + } + await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> assertEquals(expectedNumOfMessages, this.repository.estimatedCount())); + } + + @Test + void find_all() { + final List result = this.repository.findAllOrderByCapturedAt(UNLIMITED); + assertTrue(result.isEmpty()); + } + + @Test + void find_all_max_results() { + final int maxResults = 2; + this.repository.save(this.anOutboxMessage()); + this.repository.save(this.anOutboxMessage()); + this.repository.save(this.anOutboxMessage()); + final List result = this.repository.findAllOrderByCapturedAt(maxResults); + assertEquals(maxResults, result.size()); + } + + @Test + void save() { + final OutboxMessage aOutboxMessage = this.anOutboxMessage(); + this.repository.save(aOutboxMessage); + final List result = this.repository.findAllOrderByCapturedAt(UNLIMITED); + assertEquals(1, result.size()); + assertEquals(aOutboxMessage, result.get(0)); + } + + private OutboxMessage anOutboxMessage() { + return OutboxMessage.builder() + .id(UUID.randomUUID()) + .capturedAt(Instant.now()) + .destination("destination") + .bindingName("bindingName") + .payload("payload") + .headers(Map.of()) + .build(); + } + + @Test + void delete() { + final OutboxMessage aOutboxMessage = this.anOutboxMessage(); + this.repository.save(aOutboxMessage); + this.repository.delete(aOutboxMessage); + final List result = this.repository.findAllOrderByCapturedAt(UNLIMITED); + assertTrue(result.isEmpty()); + } + + @Test + void findAllOrderByCapturedAtExcludingDestinations_shouldExcludeSpecifiedDestinations() { + // Given: Save messages with different destinations + final OutboxMessage message1 = this.anOutboxMessageWithDestination("destination1"); + final OutboxMessage message2 = this.anOutboxMessageWithDestination("destination2"); + final OutboxMessage message3 = this.anOutboxMessageWithDestination("destination3"); + final OutboxMessage message4 = this.anOutboxMessageWithDestination("destination4"); + + this.repository.save(message1); + this.repository.save(message2); + this.repository.save(message3); + this.repository.save(message4); + + // When: Exclude destinations 1 and 2 + final Set excludedDestinations = Set.of("destination1", "destination2"); + final List result = this.repository.findAllOrderByCapturedAtExcludingDestinations(excludedDestinations, UNLIMITED); + + // Then: Should only return messages for destinations 3 and 4 + assertEquals(2, result.size()); + assertTrue(result.stream().anyMatch(msg -> msg.getDestination().equals("destination3"))); + assertTrue(result.stream().anyMatch(msg -> msg.getDestination().equals("destination4"))); + assertTrue(result.stream().noneMatch(msg -> msg.getDestination().equals("destination1"))); + assertTrue(result.stream().noneMatch(msg -> msg.getDestination().equals("destination2"))); + } + + @Test + void findAllOrderByCapturedAtExcludingDestinations_shouldRespectMaxResults() { + // Given: Save multiple messages with non-excluded destinations + final OutboxMessage message1 = this.anOutboxMessageWithDestination("destination1"); + final OutboxMessage message2 = this.anOutboxMessageWithDestination("destination2"); + final OutboxMessage message3 = this.anOutboxMessageWithDestination("destination3"); + final OutboxMessage message4 = this.anOutboxMessageWithDestination("excluded"); + + this.repository.save(message1); + this.repository.save(message2); + this.repository.save(message3); + this.repository.save(message4); + + // When: Exclude one destination and limit results to 2 + final Set excludedDestinations = Set.of("excluded"); + final List result = this.repository.findAllOrderByCapturedAtExcludingDestinations(excludedDestinations, 2); + + // Then: Should return exactly 2 messages (not the excluded one) + assertEquals(2, result.size()); + assertTrue(result.stream().noneMatch(msg -> msg.getDestination().equals("excluded"))); + } + + @Test + void findAllOrderByCapturedAtExcludingDestinations_shouldFallbackWhenNoExclusions() { + // Given: Save some messages + final OutboxMessage message1 = this.anOutboxMessageWithDestination("destination1"); + final OutboxMessage message2 = this.anOutboxMessageWithDestination("destination2"); + + this.repository.save(message1); + this.repository.save(message2); + + // When: Call with empty exclusions + final Set excludedDestinations = Set.of(); + final List result = this.repository.findAllOrderByCapturedAtExcludingDestinations(excludedDestinations, UNLIMITED); + + // Then: Should return all messages (same as normal findAll) + assertEquals(2, result.size()); + + // Compare with normal findAll to ensure same behavior + final List normalResult = this.repository.findAllOrderByCapturedAt(UNLIMITED); + assertEquals(normalResult.size(), result.size()); + } + + @Test + void findAllOrderByCapturedAtExcludingDestinations_shouldFallbackWhenNullExclusions() { + // Given: Save some messages + final OutboxMessage message1 = this.anOutboxMessageWithDestination("destination1"); + final OutboxMessage message2 = this.anOutboxMessageWithDestination("destination2"); + + this.repository.save(message1); + this.repository.save(message2); + + // When: Call with null exclusions + final List result = this.repository.findAllOrderByCapturedAtExcludingDestinations(null, UNLIMITED); + + // Then: Should return all messages (same as normal findAll) + assertEquals(2, result.size()); + + // Compare with normal findAll to ensure same behavior + final List normalResult = this.repository.findAllOrderByCapturedAt(UNLIMITED); + assertEquals(normalResult.size(), result.size()); + } + + @Test + void findAllOrderByCapturedAtExcludingDestinations_shouldReturnEmptyWhenAllDestinationsExcluded() { + // Given: Save messages + final OutboxMessage message1 = this.anOutboxMessageWithDestination("destination1"); + final OutboxMessage message2 = this.anOutboxMessageWithDestination("destination2"); + + this.repository.save(message1); + this.repository.save(message2); + + // When: Exclude all destinations + final Set excludedDestinations = Set.of("destination1", "destination2"); + final List result = this.repository.findAllOrderByCapturedAtExcludingDestinations(excludedDestinations, UNLIMITED); + + // Then: Should return empty list + assertTrue(result.isEmpty()); + } + + private OutboxMessage anOutboxMessageWithDestination(String destination) { + return OutboxMessage.builder() + .id(UUID.randomUUID()) + .capturedAt(Instant.now()) + .destination(destination) + .bindingName("bindingName") + .payload("payload") + .headers(Map.of()) + .build(); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/DataSourceMetadataTest.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/DataSourceMetadataTest.java new file mode 100644 index 0000000..5de0774 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/DataSourceMetadataTest.java @@ -0,0 +1,136 @@ +package dev.inditex.scsoutbox.jdbc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import javax.sql.DataSource; + +import dev.inditex.scsoutbox.jdbc.DataSourceMetadata.DatabaseAccessException; +import dev.inditex.scsoutbox.jdbc.DataSourceMetadata.JdbcDatabaseType; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for JdbcDataSourceMetadata. + */ +class DataSourceMetadataTest { + + private DataSource dataSource; + + private Connection connection; + + private DatabaseMetaData databaseMetadata; + + @BeforeEach + void setUp() throws SQLException { + this.dataSource = mock(DataSource.class); + this.connection = mock(Connection.class); + this.databaseMetadata = mock(DatabaseMetaData.class); + + when(this.dataSource.getConnection()).thenReturn(this.connection); + when(this.connection.getMetaData()).thenReturn(this.databaseMetadata); + } + + @Test + void detect_postgresql_database() throws SQLException { + // Given + when(this.databaseMetadata.getDatabaseProductName()).thenReturn("PostgreSQL"); + when(this.connection.getSchema()).thenReturn("conn_schema"); + + // When + final DataSourceMetadata metadata = new DataSourceMetadata(this.dataSource); + + // Then + assertEquals(JdbcDatabaseType.POSTGRESQL, metadata.getDatabaseType()); + assertEquals("conn_schema", metadata.getDefaultSchema()); + } + + @Test + void detect_mariadb_database_with_connection_schema() throws SQLException { + // Given + when(this.databaseMetadata.getDatabaseProductName()).thenReturn("MariaDB"); + when(this.connection.getSchema()).thenReturn("conn_schema"); + + // When + final DataSourceMetadata metadata = new DataSourceMetadata(this.dataSource); + + // Then + assertEquals(JdbcDatabaseType.MARIADB, metadata.getDatabaseType()); + assertEquals("conn_schema", metadata.getDefaultSchema()); + } + + @Test + void detect_mariadb_database_with_catalog() throws SQLException { + // Given + when(this.databaseMetadata.getDatabaseProductName()).thenReturn("MariaDB"); + when(this.connection.getSchema()).thenReturn(""); + when(this.connection.getCatalog()).thenReturn("catalog_db"); + + // When + final DataSourceMetadata metadata = new DataSourceMetadata(this.dataSource); + + // Then + assertEquals(JdbcDatabaseType.MARIADB, metadata.getDatabaseType()); + assertEquals("catalog_db", metadata.getDefaultSchema()); + } + + @Test + void return_empty_schema_when_no_schema_can_be_detected() throws SQLException { + // Given + when(this.databaseMetadata.getDatabaseProductName()).thenReturn("OtherDB"); + when(this.connection.getSchema()).thenReturn(null); + when(this.connection.getCatalog()).thenReturn(null); + + // When + final DataSourceMetadata metadata = new DataSourceMetadata(this.dataSource); + + // Then + assertEquals(JdbcDatabaseType.OTHER, metadata.getDatabaseType()); + assertEquals("", metadata.getDefaultSchema()); + } + + @Test + void return_empty_schema_when_exception_occurs_during_schema_detection() throws SQLException { + // Given + when(this.databaseMetadata.getDatabaseProductName()).thenReturn("OtherDB"); + when(this.connection.getSchema()).thenThrow(new SQLException("Schema error")); + when(this.connection.getCatalog()).thenThrow(new SQLException("Catalog error")); + + // When + final DataSourceMetadata metadata = new DataSourceMetadata(this.dataSource); + + // Then + assertEquals(JdbcDatabaseType.OTHER, metadata.getDatabaseType()); + assertEquals("", metadata.getDefaultSchema()); + } + + @Test + void detect_other_database_type() throws SQLException { + // Given + when(this.databaseMetadata.getDatabaseProductName()).thenReturn("OtherDB"); + when(this.connection.getSchema()).thenReturn("some_schema"); + + // When + final DataSourceMetadata metadata = new DataSourceMetadata(this.dataSource); + + // Then + assertEquals(JdbcDatabaseType.OTHER, metadata.getDatabaseType()); + assertEquals("some_schema", metadata.getDefaultSchema()); + } + + @Test + void handle_sql_exception_when_getting_connection() throws SQLException { + // Given + when(this.dataSource.getConnection()).thenThrow(new SQLException("Connection error")); + + // When/Then + assertThrows(DatabaseAccessException.class, + () -> new DataSourceMetadata(this.dataSource)); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/DbSchemaResolverTest.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/DbSchemaResolverTest.java new file mode 100644 index 0000000..91ac80b --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/DbSchemaResolverTest.java @@ -0,0 +1,82 @@ +package dev.inditex.scsoutbox.jdbc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class DbSchemaResolverTest { + + private DataSourceMetadata dataSourceMetadata; + + private DbSchemaResolver dbSchemaResolver; + + @BeforeEach + void setUp() { + this.dataSourceMetadata = mock(DataSourceMetadata.class); + this.dbSchemaResolver = new DbSchemaResolver(this.dataSourceMetadata); + } + + @Test + void should_return_configured_schema_when_provided() { + // Given + final String configuredSchema = "configured_schema"; + + // When + final String result = this.dbSchemaResolver.resolve(configuredSchema); + + // Then + assertThat(result).isEqualTo(configuredSchema); + } + + @Test + void should_return_default_schema_when_configured_schema_is_null() { + // Given + final String defaultSchema = "default_schema"; + when(this.dataSourceMetadata.getDefaultSchema()).thenReturn(defaultSchema); + + // When + final String result = this.dbSchemaResolver.resolve(null); + + // Then + assertThat(result).isEqualTo(defaultSchema); + } + + @Test + void should_return_default_schema_when_configured_schema_is_empty() { + // Given + final String defaultSchema = "default_schema"; + when(this.dataSourceMetadata.getDefaultSchema()).thenReturn(defaultSchema); + + // When + final String result = this.dbSchemaResolver.resolve(""); + + // Then + assertThat(result).isEqualTo(defaultSchema); + } + + @Test + void should_throw_exception_when_no_schema_can_be_resolved() { + // Given + when(this.dataSourceMetadata.getDefaultSchema()).thenReturn(null); + + // When/Then + assertThatThrownBy(() -> this.dbSchemaResolver.resolve(null)) + .isInstanceOf(DbSchemaResolver.SchemaResolutionException.class) + .hasMessageContaining("No schemaName configured and could not detect default schemaName"); + } + + @Test + void should_throw_exception_when_default_schema_is_empty() { + // Given + when(this.dataSourceMetadata.getDefaultSchema()).thenReturn(""); + + // When/Then + assertThatThrownBy(() -> this.dbSchemaResolver.resolve(null)) + .isInstanceOf(DbSchemaResolver.SchemaResolutionException.class) + .hasMessageContaining("No schemaName configured and could not detect default schemaName"); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxDataSourceProviderTest.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxDataSourceProviderTest.java new file mode 100644 index 0000000..cd86579 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxDataSourceProviderTest.java @@ -0,0 +1,77 @@ +package dev.inditex.scsoutbox.jdbc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; + +class JdbcOutboxDataSourceProviderTest { + + @Test + void constructor_requires_capture_datasource() { + assertThatThrownBy(() -> new JdbcOutboxDataSourceProvider(null, null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("Capture DataSource is required"); + } + + @Test + void with_only_capture_datasource_uses_it_for_both() { + final DataSource captureDs = this.createTestDataSource("capture"); + + final JdbcOutboxDataSourceProvider provider = new JdbcOutboxDataSourceProvider(captureDs, null); + + assertThat(provider.getPrimary()).isSameAs(captureDs); + assertThat(provider.getDedicatedForPublishing()).isSameAs(captureDs); + } + + @Test + void with_dedicated_publishing_datasource_uses_different_instances() { + final DataSource captureDs = this.createTestDataSource("capture"); + final DataSource publishingDs = this.createTestDataSource("publishing"); + + final JdbcOutboxDataSourceProvider provider = new JdbcOutboxDataSourceProvider(captureDs, publishingDs); + + assertThat(provider.getPrimary()).isSameAs(captureDs); + assertThat(provider.getDedicatedForPublishing()).isSameAs(publishingDs); + assertThat(provider.getPrimary()).isNotSameAs(provider.getDedicatedForPublishing()); + } + + @Test + void metadata_is_cached_for_same_datasource() { + final DataSource captureDs = this.createTestDataSource("capture"); + + final JdbcOutboxDataSourceProvider provider = new JdbcOutboxDataSourceProvider(captureDs, null); + + final DataSourceMetadata captureMetadata = provider.getPrimaryDataSourceMetadata(); + final DataSourceMetadata publishingMetadata = provider.getDedicatedForPublishingDataSourceMetadata(); + + // Should be the same instance (cached) + assertThat(captureMetadata).isSameAs(publishingMetadata); + } + + @Test + void metadata_is_different_for_different_datasources() { + final DataSource captureDs = this.createTestDataSource("capture"); + final DataSource publishingDs = this.createTestDataSource("publishing"); + + final JdbcOutboxDataSourceProvider provider = new JdbcOutboxDataSourceProvider(captureDs, publishingDs); + + final DataSourceMetadata captureMetadata = provider.getPrimaryDataSourceMetadata(); + final DataSourceMetadata publishingMetadata = provider.getDedicatedForPublishingDataSourceMetadata(); + + // Should be different instances + assertThat(captureMetadata).isNotSameAs(publishingMetadata); + } + + private DataSource createTestDataSource(String name) { + return new EmbeddedDatabaseBuilder() + .setType(EmbeddedDatabaseType.H2) + .setName(name) + .build(); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepositoryDeserializationTest.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepositoryDeserializationTest.java new file mode 100644 index 0000000..d0ba595 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepositoryDeserializationTest.java @@ -0,0 +1,184 @@ +package dev.inditex.scsoutbox.jdbc; + +import static dev.inditex.scsoutbox.OutboxMessageRepository.UNLIMITED; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import javax.sql.DataSource; + +import dev.inditex.scsoutbox.OutboxMessage; +import dev.inditex.scsoutbox.jdbc.config.JdbcProperties; +import dev.inditex.scsoutbox.serialization.HeadersMapper; +import dev.inditex.scsoutbox.serialization.JavaSerialization; +import dev.inditex.scsoutbox.serialization.JsonHeadersMapper; +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer; +import dev.inditex.scsoutbox.serialization.SerializationEngine; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; + +/** + * Tests for deserialization error handling in JdbcOutboxMessageRepository. + */ +class JdbcOutboxMessageRepositoryDeserializationTest { + + private JdbcTemplate jdbcTemplate; + + private Table table; + + private JavaSerialization realSerialization; + + private HeadersMapper headersMapper; + + private JdbcOutboxMessageRepository insertRepository; + + @BeforeEach + void setUp() { + final DataSource dataSource = new EmbeddedDatabaseBuilder() + .setType(EmbeddedDatabaseType.H2) + .addScript("classpath:scripts/mariadb-outbox-table.sql") + .generateUniqueName(true) + .build(); + this.jdbcTemplate = new JdbcTemplate(dataSource); + final DataSourceMetadata datasourceMetadata = new DataSourceMetadata(dataSource); + final JdbcProperties properties = new JdbcProperties(); + final String schema = new DbSchemaResolver(datasourceMetadata).resolve(properties.getSchema()); + this.table = new Table(new SchemaName(schema), new TableName(properties.getTableName())); + this.realSerialization = new JavaSerialization(); + this.headersMapper = new JsonHeadersMapper(); + final OutboxMessageSerializer serializer = new OutboxMessageSerializer( + this.realSerialization, + this.headersMapper); + this.insertRepository = new JdbcOutboxMessageRepository( + this.jdbcTemplate, + this.table, + serializer); + } + + @Test + void findAllOrderByCapturedAt_shouldReturnEmptyList_whenFirstMessageFailsDeserialization() { + this.insertMessage("destination1", Instant.now().minusSeconds(30)); + this.insertMessage("destination1", Instant.now().minusSeconds(20)); + this.insertMessage("destination1", Instant.now().minusSeconds(10)); + final JdbcOutboxMessageRepository repository = this.createRepositoryWithFailingDeserialization(1); + + final List result = repository.findAllOrderByCapturedAt(UNLIMITED); + + assertTrue(result.isEmpty()); + } + + @Test + void findAllOrderByCapturedAt_shouldReturnFirstMessages_whenMiddleMessageFailsDeserialization() { + this.insertMessage("destination1", Instant.now().minusSeconds(50)); + this.insertMessage("destination1", Instant.now().minusSeconds(40)); + this.insertMessage("destination1", Instant.now().minusSeconds(30)); + this.insertMessage("destination1", Instant.now().minusSeconds(20)); + this.insertMessage("destination1", Instant.now().minusSeconds(10)); + final JdbcOutboxMessageRepository repository = this.createRepositoryWithFailingDeserialization(3); + + final List result = repository.findAllOrderByCapturedAt(UNLIMITED); + + assertEquals(2, result.size()); + } + + @Test + void findAllOrderByCapturedAt_shouldReturnAllButLast_whenLastMessageFailsDeserialization() { + this.insertMessage("destination1", Instant.now().minusSeconds(30)); + this.insertMessage("destination1", Instant.now().minusSeconds(20)); + this.insertMessage("destination1", Instant.now().minusSeconds(10)); + final JdbcOutboxMessageRepository repository = this.createRepositoryWithFailingDeserialization(3); + + final List result = repository.findAllOrderByCapturedAt(UNLIMITED); + + assertEquals(2, result.size()); + } + + @Test + void findAllOrderByCapturedAt_shouldReturnAllMessages_whenAllDeserializationsSucceed() { + this.insertMessage("destination1", Instant.now().minusSeconds(30)); + this.insertMessage("destination1", Instant.now().minusSeconds(20)); + this.insertMessage("destination1", Instant.now().minusSeconds(10)); + final JdbcOutboxMessageRepository repository = this.createRepositoryWithFailingDeserialization(Integer.MAX_VALUE); + + final List result = repository.findAllOrderByCapturedAt(UNLIMITED); + + assertEquals(3, result.size()); + } + + @Test + void findAllOrderByCapturedAtExcludingDestinations_shouldReturnFirstMessages_whenMiddleMessageFailsDeserialization() { + this.insertMessage("excluded-dest", Instant.now().minusSeconds(100)); + this.insertMessage("destination1", Instant.now().minusSeconds(50)); + this.insertMessage("destination1", Instant.now().minusSeconds(40)); + this.insertMessage("destination1", Instant.now().minusSeconds(30)); + final JdbcOutboxMessageRepository repository = this.createRepositoryWithFailingDeserialization(2); + + final Set excludedDestinations = Set.of("excluded-dest"); + final List result = repository.findAllOrderByCapturedAtExcludingDestinations(excludedDestinations, UNLIMITED); + + assertEquals(1, result.size()); + } + + private JdbcOutboxMessageRepository createRepositoryWithFailingDeserialization(int failOnCall) { + final SerializationEngine failingEngine = new FailingSerializationEngine(this.realSerialization, failOnCall); + final OutboxMessageSerializer serializer = new OutboxMessageSerializer( + failingEngine, + this.headersMapper); + return new JdbcOutboxMessageRepository( + this.jdbcTemplate, + this.table, + serializer); + } + + private void insertMessage(String destination, Instant capturedAt) { + final OutboxMessage message = OutboxMessage.builder() + .id(UUID.randomUUID()) + .capturedAt(capturedAt) + .destination(destination) + .bindingName("bindingName") + .payload("payload") + .headers(Map.of()) + .build(); + this.insertRepository.save(message); + } + + /** + * SerializationEngine that delegates to another engine but fails on a specific call number. + */ + private static class FailingSerializationEngine implements SerializationEngine { + + private final SerializationEngine delegate; + + private final int failOnCall; + + private final AtomicInteger callCount = new AtomicInteger(0); + + FailingSerializationEngine(SerializationEngine delegate, int failOnCall) { + this.delegate = delegate; + this.failOnCall = failOnCall; + } + + @Override + public Object deserialize(byte[] bytes) { + if (this.callCount.incrementAndGet() >= this.failOnCall) { + throw new RuntimeException("Simulated deserialization error on call " + this.callCount.get()); + } + return this.delegate.deserialize(bytes); + } + + @Override + public byte[] serialize(Object object) { + return this.delegate.serialize(object); + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepositoryFactoryTest.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepositoryFactoryTest.java new file mode 100644 index 0000000..7b87352 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepositoryFactoryTest.java @@ -0,0 +1,87 @@ +package dev.inditex.scsoutbox.jdbc; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import dev.inditex.scsoutbox.jdbc.DataSourceMetadata.JdbcDatabaseType; +import dev.inditex.scsoutbox.serialization.JavaSerialization; +import dev.inditex.scsoutbox.serialization.JsonHeadersMapper; +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Unit tests for {@link JdbcOutboxMessageRepositoryFactory}. + */ +class JdbcOutboxMessageRepositoryFactoryTest { + + private DataSourceMetadata postgresMetadata; + + private DataSourceMetadata mariadbMetadata; + + private DataSourceMetadata otherMetadata; + + // Rename config to definition + private Table definition; + + private JdbcTemplate jdbcTemplate; + + private OutboxMessageSerializer outboxMessageSerializer; + + @BeforeEach + void setUp() { + // Mock metadata for different DB types + this.postgresMetadata = mock(DataSourceMetadata.class); + when(this.postgresMetadata.getDatabaseType()).thenReturn(JdbcDatabaseType.POSTGRESQL); + + this.mariadbMetadata = mock(DataSourceMetadata.class); + when(this.mariadbMetadata.getDatabaseType()).thenReturn(JdbcDatabaseType.MARIADB); + + this.otherMetadata = mock(DataSourceMetadata.class); + when(this.otherMetadata.getDatabaseType()).thenReturn(JdbcDatabaseType.OTHER); + + // Use real instances or mocks for dependencies as appropriate + this.outboxMessageSerializer = new OutboxMessageSerializer(new JavaSerialization(), new JsonHeadersMapper()); + this.jdbcTemplate = mock(JdbcTemplate.class); + + // Create a standard definition object for tests (without jdbcTemplate) + this.definition = new Table(new SchemaName("resolved_schema"), new TableName("outbox_table")); + } + + @Test + void create_shouldReturnPostgresqlRepository_whenDbTypeIsPostgresql() { + // When + // Pass jdbcTemplate separately + final JdbcOutboxMessageRepository repository = JdbcOutboxMessageRepositoryFactory.create( + this.jdbcTemplate, this.postgresMetadata, this.definition, this.outboxMessageSerializer); + + // Then + assertInstanceOf(PostgresqlJdbcOutboxMessageRepository.class, repository); + } + + @Test + void create_shouldReturnMariadbRepository_whenDbTypeIsMariadb() { + // When + // Pass jdbcTemplate separately + final JdbcOutboxMessageRepository repository = JdbcOutboxMessageRepositoryFactory.create( + this.jdbcTemplate, this.mariadbMetadata, this.definition, this.outboxMessageSerializer); + + // Then + assertInstanceOf(MariadbJdbcOutboxMessageRepository.class, repository); + } + + @Test + void create_shouldReturnDefaultRepository_whenDbTypeIsOther() { + // When + // Pass jdbcTemplate separately + final JdbcOutboxMessageRepository repository = JdbcOutboxMessageRepositoryFactory.create( + this.jdbcTemplate, this.otherMetadata, this.definition, this.outboxMessageSerializer); + + // Then + assertInstanceOf(JdbcOutboxMessageRepository.class, repository); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepositoryTest.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepositoryTest.java new file mode 100644 index 0000000..186d696 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/JdbcOutboxMessageRepositoryTest.java @@ -0,0 +1,17 @@ +package dev.inditex.scsoutbox.jdbc; + +import javax.sql.DataSource; + +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; + +class JdbcOutboxMessageRepositoryTest extends AbstractJdbcOutboxMessageRepositoryTest { + + @Override + public DataSource getDataSource() { + return new EmbeddedDatabaseBuilder().setType(EmbeddedDatabaseType.H2) + .addScript("classpath:scripts/mariadb-outbox-table.sql") + .build(); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/MariaDbJdbcOutboxMessageRepositoryIT.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/MariaDbJdbcOutboxMessageRepositoryIT.java new file mode 100644 index 0000000..c3b063f --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/MariaDbJdbcOutboxMessageRepositoryIT.java @@ -0,0 +1,44 @@ +package dev.inditex.scsoutbox.jdbc; + +import javax.sql.DataSource; + +import dev.inditex.scsoutbox.test.ContainerImages; + +import lombok.SneakyThrows; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.mariadb.jdbc.MariaDbDataSource; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.mariadb.MariaDBContainer; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +class MariaDbJdbcOutboxMessageRepositoryIT extends AbstractJdbcOutboxMessageRepositoryTest { + + @Container + public static MariaDBContainer mariaDBContainer = + new MariaDBContainer(DockerImageName.parse(ContainerImages.MARIADB)) + .withInitScript("scripts/mariadb-outbox-table.sql"); + + @BeforeAll + static void startContainer() { + mariaDBContainer.start(); + } + + @AfterAll + static void stopContainer() { + mariaDBContainer.stop(); + } + + @Override + @SneakyThrows + public DataSource getDataSource() { + final MariaDbDataSource dataSource = new MariaDbDataSource(); + dataSource.setUrl(mariaDBContainer.getJdbcUrl()); + dataSource.setUser(mariaDBContainer.getUsername()); + dataSource.setPassword(mariaDBContainer.getPassword()); + return dataSource; + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/PostgreSqlJdbcOutboxMessageRepositoryIT.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/PostgreSqlJdbcOutboxMessageRepositoryIT.java new file mode 100644 index 0000000..e331606 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/PostgreSqlJdbcOutboxMessageRepositoryIT.java @@ -0,0 +1,42 @@ +package dev.inditex.scsoutbox.jdbc; + +import javax.sql.DataSource; + +import dev.inditex.scsoutbox.test.ContainerImages; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.postgresql.ds.PGSimpleDataSource; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +class PostgreSqlJdbcOutboxMessageRepositoryIT extends AbstractJdbcOutboxMessageRepositoryTest { + + @Container + public static PostgreSQLContainer postgreSqlContainer = + new PostgreSQLContainer(DockerImageName.parse(ContainerImages.POSTGRESQL)) + .withInitScript("scripts/postgresql-outbox-table.sql"); + + @BeforeAll + static void startContainer() { + postgreSqlContainer.start(); + } + + @AfterAll + static void stopContainer() { + postgreSqlContainer.stop(); + } + + @Override + public DataSource getDataSource() { + final PGSimpleDataSource dataSource = new PGSimpleDataSource(); + dataSource.setUrl(postgreSqlContainer.getJdbcUrl()); + dataSource.setUser(postgreSqlContainer.getUsername()); + dataSource.setPassword(postgreSqlContainer.getPassword()); + return dataSource; + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/SchemaTest.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/SchemaTest.java new file mode 100644 index 0000000..98daea1 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/SchemaTest.java @@ -0,0 +1,61 @@ +package dev.inditex.scsoutbox.jdbc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class SchemaTest { + + @Test + void shouldCreateSchemaWhenValueIsValid() { + assertThatNoException().isThrownBy(() -> new SchemaName("myschema")); + assertThat(new SchemaName("myschema").value()).isEqualTo("myschema"); + assertThatNoException().isThrownBy(() -> new SchemaName("my_schema_123")); + final String maxLengthSchema = "a".repeat(128); + assertThatNoException().isThrownBy(() -> new SchemaName(maxLengthSchema)); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenValueIsNull() { + assertThatThrownBy(() -> new SchemaName(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("SchemaName value cannot be null or empty"); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenValueIsEmpty() { + assertThatThrownBy(() -> new SchemaName("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("SchemaName value cannot be null or empty"); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenValueIsBlank() { + assertThatThrownBy(() -> new SchemaName(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("SchemaName value cannot be null or empty"); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenValueExceedsMaxLength() { + final String longValue = "a".repeat(129); + assertThatThrownBy(() -> new SchemaName(longValue)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("SchemaName value cannot exceed 128 characters"); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenValueContainsInvalidCharacters() { + assertThatThrownBy(() -> new SchemaName("my-schemaName")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("SchemaName value can only contain alphanumeric characters and underscores"); + assertThatThrownBy(() -> new SchemaName("my schemaName")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("SchemaName value can only contain alphanumeric characters and underscores"); + assertThatThrownBy(() -> new SchemaName("myschema.")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("SchemaName value can only contain alphanumeric characters and underscores"); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/TableNameTest.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/TableNameTest.java new file mode 100644 index 0000000..aea403e --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/TableNameTest.java @@ -0,0 +1,61 @@ +package dev.inditex.scsoutbox.jdbc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class TableNameTest { + + @Test + void shouldCreateTableNameWhenValueIsValid() { + assertThatNoException().isThrownBy(() -> new TableName("mytable")); + assertThat(new TableName("mytable").value()).isEqualTo("mytable"); + assertThatNoException().isThrownBy(() -> new TableName("my_table_123")); + String maxLengthTable = "a".repeat(128); + assertThatNoException().isThrownBy(() -> new TableName(maxLengthTable)); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenValueIsNull() { + assertThatThrownBy(() -> new TableName(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("TableName value cannot be null or empty"); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenValueIsEmpty() { + assertThatThrownBy(() -> new TableName("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("TableName value cannot be null or empty"); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenValueIsBlank() { + assertThatThrownBy(() -> new TableName(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("TableName value cannot be null or empty"); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenValueExceedsMaxLength() { + String longValue = "a".repeat(129); + assertThatThrownBy(() -> new TableName(longValue)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("TableName value cannot exceed 128 characters"); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenValueContainsInvalidCharacters() { + assertThatThrownBy(() -> new TableName("my-table")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("TableName value can only contain alphanumeric characters and underscores"); + assertThatThrownBy(() -> new TableName("my table")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("TableName value can only contain alphanumeric characters and underscores"); + assertThatThrownBy(() -> new TableName("mytable.")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("TableName value can only contain alphanumeric characters and underscores"); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/TableTest.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/TableTest.java new file mode 100644 index 0000000..362fe41 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/TableTest.java @@ -0,0 +1,47 @@ +package dev.inditex.scsoutbox.jdbc; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class TableTest { + + private SchemaName schemaName; + + private TableName tableName; + + @BeforeEach + void setup() { + this.schemaName = new SchemaName("myschema"); + this.tableName = new TableName("mytable"); + } + + @Test + void shouldCreateTableWhenSchemaAndTableNameAreValid() { + assertThatNoException().isThrownBy(() -> new Table(this.schemaName, this.tableName)); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenTableNameIsNull() { + assertThatThrownBy(() -> new Table(this.schemaName, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("tableName cannot be null"); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenSchemaIsNull() { + assertThatThrownBy(() -> new Table(null, this.tableName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("schemaName cannot be null"); + } + + @Test + void shouldReturnQualifiedTableName() { + final Table table = new Table(this.schemaName, this.tableName); + final String expectedQualifiedName = this.schemaName.value() + "." + this.tableName.value(); + assertEquals(expectedQualifiedName, table.getQualifiedTableName()); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/config/JdbcDataSourceAutoConfigurationTest.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/config/JdbcDataSourceAutoConfigurationTest.java new file mode 100644 index 0000000..7b2ba4f --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/config/JdbcDataSourceAutoConfigurationTest.java @@ -0,0 +1,72 @@ +package dev.inditex.scsoutbox.jdbc.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import javax.sql.DataSource; + +import dev.inditex.scsoutbox.jdbc.JdbcOutboxDataSourceProvider; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; + +class JdbcDataSourceAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(JdbcDataSourceAutoConfiguration.class)); + + @Nested + class OutboxDataSourceProvider { + + @Test + void when_primary_datasource_present_expect_provider_created() { + JdbcDataSourceAutoConfigurationTest.this.contextRunner + .withBean(DataSource.class, JdbcDataSourceAutoConfigurationTest::createTestDataSource) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(dev.inditex.scsoutbox.jdbc.OutboxDataSourceProvider.class); + assertThat(context.getBean(dev.inditex.scsoutbox.jdbc.OutboxDataSourceProvider.class)) + .isInstanceOf(JdbcOutboxDataSourceProvider.class); + }); + } + + @Test + void when_no_dedicated_datasource_expect_provider_uses_primary_for_both() { + JdbcDataSourceAutoConfigurationTest.this.contextRunner + .withBean(DataSource.class, JdbcDataSourceAutoConfigurationTest::createTestDataSource) + .run(context -> { + assertThat(context).hasNotFailed(); + final var provider = context.getBean(dev.inditex.scsoutbox.jdbc.OutboxDataSourceProvider.class); + assertThat(provider.getPrimary()).isSameAs(provider.getDedicatedForPublishing()); + }); + } + + @Test + void when_dedicated_datasource_present_expect_provider_uses_different_datasources() { + JdbcDataSourceAutoConfigurationTest.this.contextRunner + .withBean("captureDataSource", DataSource.class, JdbcDataSourceAutoConfigurationTest::createTestDataSource) + .withBean("outboxPublishingDataSource", DataSource.class, JdbcDataSourceAutoConfigurationTest::createTestDataSource) + .run(context -> { + assertThat(context).hasNotFailed(); + final var provider = context.getBean(dev.inditex.scsoutbox.jdbc.OutboxDataSourceProvider.class); + assertThat(provider.getPrimary()).isNotSameAs(provider.getDedicatedForPublishing()); + }); + } + + @Test + void when_no_datasource_present_expect_context_fails() { + JdbcDataSourceAutoConfigurationTest.this.contextRunner + .run(context -> assertThat(context).hasFailed()); + } + } + + private static DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setType(EmbeddedDatabaseType.H2) + .generateUniqueName(true) + .build(); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/config/JdbcOutboxAutoConfigurationTest.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/config/JdbcOutboxAutoConfigurationTest.java new file mode 100644 index 0000000..98c5a57 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/config/JdbcOutboxAutoConfigurationTest.java @@ -0,0 +1,77 @@ +package dev.inditex.scsoutbox.jdbc.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import javax.sql.DataSource; + +import dev.inditex.scsoutbox.jdbc.DataSourceMetadata; +import dev.inditex.scsoutbox.jdbc.OutboxDataSourceProvider; +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class JdbcOutboxAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(JdbcOutboxAutoConfiguration.class)); + + private ApplicationContextRunner contextRunnerWithDependencies() { + final OutboxDataSourceProvider provider = mock(OutboxDataSourceProvider.class); + final DataSourceMetadata metadata = mock(DataSourceMetadata.class); + when(metadata.getDatabaseType()).thenReturn(DataSourceMetadata.JdbcDatabaseType.POSTGRESQL); + when(metadata.getDefaultSchema()).thenReturn("public"); + when(provider.getPrimary()).thenReturn(mock(DataSource.class)); + when(provider.getDedicatedForPublishing()).thenReturn(mock(DataSource.class)); + when(provider.getPrimaryDataSourceMetadata()).thenReturn(metadata); + when(provider.getDedicatedForPublishingDataSourceMetadata()).thenReturn(metadata); + return this.contextRunner + .withBean(OutboxDataSourceProvider.class, () -> provider) + .withBean(OutboxMessageSerializer.class, () -> mock(OutboxMessageSerializer.class)); + } + + @Nested + class OutboxMessageRepository { + + @Test + void when_dependencies_present_expect_both_repository_beans_created() { + JdbcOutboxAutoConfigurationTest.this.contextRunnerWithDependencies() + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasBean("outboxMessageRepository"); + assertThat(context).hasBean("publishingOutboxMessageRepository"); + }); + } + + @Test + void when_dependencies_present_expect_repositories_are_outbox_message_repository_instances() { + JdbcOutboxAutoConfigurationTest.this.contextRunnerWithDependencies() + .run(context -> { + assertThat(context.getBean("outboxMessageRepository")) + .isInstanceOf(dev.inditex.scsoutbox.OutboxMessageRepository.class); + assertThat(context.getBean("publishingOutboxMessageRepository")) + .isInstanceOf(dev.inditex.scsoutbox.OutboxMessageRepository.class); + }); + } + + @Test + void when_datasource_provider_missing_expect_context_fails() { + JdbcOutboxAutoConfigurationTest.this.contextRunner + .withBean(OutboxMessageSerializer.class, () -> mock(OutboxMessageSerializer.class)) + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void when_serializer_missing_expect_context_fails() { + final OutboxDataSourceProvider provider = mock(OutboxDataSourceProvider.class); + + JdbcOutboxAutoConfigurationTest.this.contextRunner + .withBean(OutboxDataSourceProvider.class, () -> provider) + .run(context -> assertThat(context).hasFailed()); + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/config/JdbcPropertiesTest.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/config/JdbcPropertiesTest.java new file mode 100644 index 0000000..135ee94 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/config/JdbcPropertiesTest.java @@ -0,0 +1,99 @@ +package dev.inditex.scsoutbox.jdbc.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; + +class JdbcPropertiesTest { + + public static final String DEFAULT_TABLE_NAME = "SCS_OUTBOX"; + + public static final String DEFAULT_SCHEMA = ""; + + @Test + void default_values() { + assertEquals(DEFAULT_TABLE_NAME, new JdbcProperties().getTableName()); + assertEquals(DEFAULT_SCHEMA, new JdbcProperties().getSchema()); + assertEquals(DEFAULT_TABLE_NAME, new JdbcProperties(null, null).getTableName()); + assertEquals(DEFAULT_SCHEMA, new JdbcProperties(null, null).getSchema()); + assertEquals(DEFAULT_TABLE_NAME, new JdbcProperties("", "").getTableName()); + assertEquals(DEFAULT_SCHEMA, new JdbcProperties("", "").getSchema()); + } + + @Test + void with_invalid_table_name() { + assertThrows(IllegalArgumentException.class, + () -> new JdbcProperties("VALUE WITH SPACES", null)); + } + + @Test + void with_invalid_schema_name() { + assertThrows(IllegalArgumentException.class, + () -> new JdbcProperties(null, "SCHEMA WITH SPACES")); + } + + @Test + void with_table_name() { + assertEquals("SCS_OUTBOX_TEST", new JdbcProperties("SCS_OUTBOX_TEST", null).getTableName()); + } + + @Test + void with_schema_name() { + assertEquals("TEST_SCHEMA", new JdbcProperties(null, "TEST_SCHEMA").getSchema()); + } + + @Nested + @SpringBootTest(classes = {JdbcPropertiesTest.class}) + @EnableConfigurationProperties(JdbcProperties.class) + class SpringBootTestWithoutProperties { + @Autowired + private JdbcProperties properties; + + @Test + void default_values() { + assertEquals(DEFAULT_TABLE_NAME, this.properties.getTableName()); + assertEquals(DEFAULT_SCHEMA, this.properties.getSchema()); + } + } + + @Nested + @SpringBootTest(classes = {JdbcPropertiesTest.class}, + properties = { + "scs-outbox.jdbc.table-name=SCS_OUTBOX_TEST", + "scs-outbox.jdbc.schema=TEST_SCHEMA" + }) + @EnableConfigurationProperties(JdbcProperties.class) + class SpringBootTestWithProperties { + @Autowired + private JdbcProperties properties; + + @Test + void property_values() { + assertEquals("SCS_OUTBOX_TEST", this.properties.getTableName()); + assertEquals("TEST_SCHEMA", this.properties.getSchema()); + } + } + + @Nested + @SpringBootTest(classes = {JdbcPropertiesTest.class}, + properties = { + "scs-outbox.jdbc.table-name=SCS_OUTBOX_TEST" + }) + @EnableConfigurationProperties(JdbcProperties.class) + class SpringBootTestWithTableNameOnly { + @Autowired + private JdbcProperties properties; + + @Test + void property_values() { + assertEquals("SCS_OUTBOX_TEST", this.properties.getTableName()); + assertEquals(DEFAULT_SCHEMA, this.properties.getSchema()); + } + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/config/JdbcShedlockAutoConfigurationTest.java b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/config/JdbcShedlockAutoConfigurationTest.java new file mode 100644 index 0000000..54331b9 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/java/dev/inditex/scsoutbox/jdbc/config/JdbcShedlockAutoConfigurationTest.java @@ -0,0 +1,55 @@ +package dev.inditex.scsoutbox.jdbc.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import javax.sql.DataSource; + +import dev.inditex.scsoutbox.jdbc.OutboxDataSourceProvider; + +import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class JdbcShedlockAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(JdbcShedlockAutoConfiguration.class)); + + @Nested + class LockProvider { + + @Test + void when_no_lock_provider_present_expect_jdbc_template_lock_provider_created() { + final OutboxDataSourceProvider dataSourceProvider = mock(OutboxDataSourceProvider.class); + when(dataSourceProvider.getDedicatedForPublishing()).thenReturn(mock(DataSource.class)); + + JdbcShedlockAutoConfigurationTest.this.contextRunner + .withBean(OutboxDataSourceProvider.class, () -> dataSourceProvider) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(net.javacrumbs.shedlock.core.LockProvider.class); + assertThat(context.getBean(net.javacrumbs.shedlock.core.LockProvider.class)) + .isInstanceOf(JdbcTemplateLockProvider.class); + }); + } + + @Test + void when_custom_lock_provider_present_expect_auto_configuration_backs_off() { + final net.javacrumbs.shedlock.core.LockProvider customLockProvider = + mock(net.javacrumbs.shedlock.core.LockProvider.class); + + JdbcShedlockAutoConfigurationTest.this.contextRunner + .withBean(net.javacrumbs.shedlock.core.LockProvider.class, () -> customLockProvider) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(net.javacrumbs.shedlock.core.LockProvider.class); + assertThat(context.getBean(net.javacrumbs.shedlock.core.LockProvider.class)) + .isSameAs(customLockProvider); + }); + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/test/resources/scripts/mariadb-outbox-table.sql b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/resources/scripts/mariadb-outbox-table.sql new file mode 100644 index 0000000..9656692 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/resources/scripts/mariadb-outbox-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS + SCS_OUTBOX + ( + ID varchar(36) not null, + BINDING_NAME varchar(256) not null , + CAPTURED_AT timestamp not null, + DESTINATION varchar(256) not null, + HEADERS text not null, + PAYLOAD blob not null, + constraint PK_OUTBOX primary key (ID) +); diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/test/resources/scripts/postgresql-outbox-table.sql b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/resources/scripts/postgresql-outbox-table.sql new file mode 100644 index 0000000..83bce0a --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/resources/scripts/postgresql-outbox-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS + SCS_OUTBOX + ( + ID varchar(36) not null, + BINDING_NAME varchar(256) not null , + CAPTURED_AT timestamp not null, + DESTINATION varchar(256) not null, + HEADERS text not null, + PAYLOAD bytea not null, + constraint PK_OUTBOX primary key (ID) +); diff --git a/code/scs-outbox-libs/scs-outbox-jdbc/src/test/resources/scripts/shedlock-table.sql b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/resources/scripts/shedlock-table.sql new file mode 100644 index 0000000..e2d146b --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-jdbc/src/test/resources/scripts/shedlock-table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS + shedlock ( + name VARCHAR(64), + lock_until TIMESTAMP(3) NULL, + locked_at TIMESTAMP(3) NULL, + locked_by VARCHAR(255), + PRIMARY KEY (name) +); diff --git a/code/scs-outbox-libs/scs-outbox-metrics/pom.xml b/code/scs-outbox-libs/scs-outbox-metrics/pom.xml new file mode 100644 index 0000000..88ad951 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-metrics/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + dev.inditex.scsoutbox + scs-outbox-libs + 1.0.0-SNAPSHOT + + + scs-outbox-metrics + + + 17 + 17 + UTF-8 + + + + + org.projectlombok + lombok + + + dev.inditex.scsoutbox + scs-outbox-core + + + org.aspectj + aspectjweaver + + + io.micrometer + micrometer-core + + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.assertj + assertj-core + test + + + org.springframework.boot + spring-boot-starter-test + test + + + ch.qos.logback + logback-classic + + + + + diff --git a/code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/MessagesPendingMeter.java b/code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/MessagesPendingMeter.java new file mode 100644 index 0000000..04c8983 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/MessagesPendingMeter.java @@ -0,0 +1,34 @@ +package dev.inditex.scsoutbox.metrics; + +import java.util.concurrent.atomic.AtomicLong; + +import dev.inditex.scsoutbox.OutboxMessageRepository; + +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; + +@Aspect +@Slf4j +public class MessagesPendingMeter { + + private static final String PENDING_MESSAGES_METRIC = "outbox.messages.pending"; + + private final OutboxMessageRepository outboxMessageRepository; + + private final AtomicLong meterValue; + + public MessagesPendingMeter(final MeterRegistry meterRegistry, OutboxMessageRepository outboxMessageRepository) { + this.meterValue = meterRegistry.gauge(PENDING_MESSAGES_METRIC, new AtomicLong(0)); + this.outboxMessageRepository = outboxMessageRepository; + } + + @Before("execution(* dev.inditex.scsoutbox.publish.OutboxPublishingTask.run())") + public void meterPendingMessages() { + final long numOfMessages = this.outboxMessageRepository.estimatedCount(); + log.debug("There are {} estimated messages in the outbox", numOfMessages); + this.meterValue.set(numOfMessages); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/PublishingDelayMeter.java b/code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/PublishingDelayMeter.java new file mode 100644 index 0000000..cba056f --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/PublishingDelayMeter.java @@ -0,0 +1,32 @@ +package dev.inditex.scsoutbox.metrics; + +import java.time.Duration; +import java.time.Instant; + +import dev.inditex.scsoutbox.OutboxMessage; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import org.aspectj.lang.annotation.After; +import org.aspectj.lang.annotation.Aspect; + +@Aspect +public class PublishingDelayMeter { + + private static final String PUBLISHING_DELAY_METRIC = "outbox.publishing.delay"; + + private final Timer timer; + + public PublishingDelayMeter(final MeterRegistry meterRegistry) { + this.timer = meterRegistry.timer(PUBLISHING_DELAY_METRIC); + } + + @After( + value = "execution(* dev.inditex.scsoutbox.publish.OutboxMessagePublisher.publish(..)) && args(message)", + argNames = "message") + public void publishingDelay(final OutboxMessage message) { + final Duration publishingDelay = Duration.between(message.getCapturedAt(), Instant.now()); + this.timer.record(publishingDelay); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/PublishingTaskMeter.java b/code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/PublishingTaskMeter.java new file mode 100644 index 0000000..f41b02a --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/PublishingTaskMeter.java @@ -0,0 +1,30 @@ +package dev.inditex.scsoutbox.metrics; + +import dev.inditex.scsoutbox.publish.OutboxPublishingTaskReport; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.Aspect; + +@Aspect +@Slf4j +public class PublishingTaskMeter { + + private static final String PUBLISHING_MESSAGES_METRIC = "outbox.publishing.messages"; + + private final Counter publishedMessagesCounter; + + public PublishingTaskMeter(final MeterRegistry meterRegistry) { + this.publishedMessagesCounter = meterRegistry.counter(PUBLISHING_MESSAGES_METRIC); + } + + @AfterReturning( + value = "execution(* dev.inditex.scsoutbox.publish.OutboxPublishingTask.run())", + returning = "report") + public void countPublishedMessages(final OutboxPublishingTaskReport report) { + this.publishedMessagesCounter.increment(report.getNumOfPublishedMessages()); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/SpringIntegrationMeterFilter.java b/code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/SpringIntegrationMeterFilter.java new file mode 100644 index 0000000..21e15ac --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/SpringIntegrationMeterFilter.java @@ -0,0 +1,44 @@ +package dev.inditex.scsoutbox.metrics; + +import static java.util.stream.StreamSupport.stream; + +import java.util.List; + +import dev.inditex.scsoutbox.OutboxServiceProperties; + +import io.micrometer.core.instrument.Meter.Id; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.config.MeterFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.integration.support.management.IntegrationManagement; + +@RequiredArgsConstructor +public class SpringIntegrationMeterFilter implements MeterFilter { + + private static final String NAME = "name"; + + private static final String RESULT = "result"; + + private static final String EXCEPTION = "exception"; + + private final OutboxServiceProperties outboxServiceProperties; + + @Override + public Id map(final Id id) { + if (id.getName().equals(IntegrationManagement.SEND_TIMER_NAME) + && this.outboxServiceProperties.isOutboxEnabledFor(id.getTag(NAME)) + && "failure".equals(id.getTag(RESULT)) + && "none".equals(id.getTag(EXCEPTION))) { + final List tags = stream(id.getTagsAsIterable().spliterator(), false) + .map(t -> { + if (!t.getKey().equals(RESULT)) { + return t; + } + return Tag.of(RESULT, "success"); + }).toList(); + return id.replaceTags(tags); + } + return id; + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/config/OutboxMetricsAutoConfiguration.java b/code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/config/OutboxMetricsAutoConfiguration.java new file mode 100644 index 0000000..0b35697 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/config/OutboxMetricsAutoConfiguration.java @@ -0,0 +1,43 @@ +package dev.inditex.scsoutbox.metrics.config; + +import dev.inditex.scsoutbox.OutboxMessageRepository; +import dev.inditex.scsoutbox.metrics.MessagesPendingMeter; +import dev.inditex.scsoutbox.metrics.PublishingDelayMeter; +import dev.inditex.scsoutbox.metrics.PublishingTaskMeter; + +import io.micrometer.core.aop.TimedAspect; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@ConditionalOnBean(MeterRegistry.class) +@ConditionalOnProperty(value = "scs-outbox.metrics.enabled", havingValue = "true", matchIfMissing = false) +public class OutboxMetricsAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public TimedAspect timedAspect(final MeterRegistry meterRegistry) { + return new TimedAspect(meterRegistry); + } + + @Bean + public PublishingDelayMeter publishingDelayMeter(final MeterRegistry meterRegistry) { + return new PublishingDelayMeter(meterRegistry); + } + + @Bean + public MessagesPendingMeter messagesPendingMeter(final MeterRegistry meterRegistry, + final OutboxMessageRepository outboxMessageRepository) { + return new MessagesPendingMeter(meterRegistry, outboxMessageRepository); + } + + @Bean + public PublishingTaskMeter publishingTaskMeter(final MeterRegistry meterRegistry) { + return new PublishingTaskMeter(meterRegistry); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/config/SpringIntegrationMetricsAutoConfiguration.java b/code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/config/SpringIntegrationMetricsAutoConfiguration.java new file mode 100644 index 0000000..236e0d0 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-metrics/src/main/java/dev/inditex/scsoutbox/metrics/config/SpringIntegrationMetricsAutoConfiguration.java @@ -0,0 +1,21 @@ +package dev.inditex.scsoutbox.metrics.config; + +import dev.inditex.scsoutbox.OutboxServiceProperties; +import dev.inditex.scsoutbox.metrics.SpringIntegrationMeterFilter; + +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@ConditionalOnBean(MeterRegistry.class) +public class SpringIntegrationMetricsAutoConfiguration { + + @Bean + public SpringIntegrationMeterFilter springIntegrationMeterFilter( + final OutboxServiceProperties outboxServiceProperties) { + return new SpringIntegrationMeterFilter(outboxServiceProperties); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-metrics/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/code/scs-outbox-libs/scs-outbox-metrics/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..8a8c564 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-metrics/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +dev.inditex.scsoutbox.metrics.config.OutboxMetricsAutoConfiguration +dev.inditex.scsoutbox.metrics.config.SpringIntegrationMetricsAutoConfiguration \ No newline at end of file diff --git a/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/OutboxMessageMother.java b/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/OutboxMessageMother.java new file mode 100755 index 0000000..2e800b4 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/OutboxMessageMother.java @@ -0,0 +1,32 @@ +package dev.inditex.scsoutbox; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import dev.inditex.scsoutbox.OutboxMessage.OutboxMessageBuilder; + +public abstract class OutboxMessageMother { + + public static OutboxMessage anOutboxMessage() { + return OutboxMessage.builder() + .id(UUID.randomUUID()) + .capturedAt(Instant.now()) + .destination("destination") + .bindingName("bindingName") + .payload("payload") + .headers(Map.of()) + .build(); + } + + public static OutboxMessageBuilder anOutboxMessageBuilder() { + return OutboxMessage.builder() + .id(UUID.randomUUID()) + .capturedAt(Instant.now()) + .destination("destination") + .bindingName("bindingName") + .payload("payload") + .headers(Map.of()); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/MessagesPendingMeterTest.java b/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/MessagesPendingMeterTest.java new file mode 100644 index 0000000..c5e99e1 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/MessagesPendingMeterTest.java @@ -0,0 +1,57 @@ +package dev.inditex.scsoutbox.metrics; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import dev.inditex.scsoutbox.OutboxMessageRepository; +import dev.inditex.scsoutbox.publish.OutboxPublishingTask; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; + +class MessagesPendingMeterTest { + + public static final long NUM_OF_MESSAGES_IN_REPOSITORY = 2L; + + private SimpleMeterRegistry meterRegistry; + + private OutboxPublishingTask outboxPublishingTask; + + @BeforeEach + void setUp() { + this.meterRegistry = new SimpleMeterRegistry(); + final OutboxMessageRepository outboxMessageRepository = mock(OutboxMessageRepository.class); + when(outboxMessageRepository.estimatedCount()).thenReturn(NUM_OF_MESSAGES_IN_REPOSITORY); + final MessagesPendingMeter messagesPendingMeter = new MessagesPendingMeter(this.meterRegistry, outboxMessageRepository); + final OutboxPublishingTask publishingTask = mock(OutboxPublishingTask.class); + final AspectJProxyFactory factory = new AspectJProxyFactory(publishingTask); + factory.addAspect(messagesPendingMeter); + this.outboxPublishingTask = factory.getProxy(); + } + + @Test + void update_metric_when_count_is_called() { + assertThat( + this.meterRegistry.get("outbox.messages.pending").gauge().value()) + .isZero(); + + this.outboxPublishingTask.run(); + + assertThat( + this.meterRegistry.get("outbox.messages.pending").gauge().value()) + .isEqualTo(NUM_OF_MESSAGES_IN_REPOSITORY); + } + + @Test + void messages_pending_value_must_be_equal_to_the_number_of_messages_returned() { + + this.outboxPublishingTask.run(); + + assertThat( + this.meterRegistry.get("outbox.messages.pending").gauge().value()) + .isEqualTo(NUM_OF_MESSAGES_IN_REPOSITORY); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/PublishingDelayMeterTest.java b/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/PublishingDelayMeterTest.java new file mode 100644 index 0000000..7ddecc0 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/PublishingDelayMeterTest.java @@ -0,0 +1,56 @@ +package dev.inditex.scsoutbox.metrics; + +import static dev.inditex.scsoutbox.OutboxMessageMother.anOutboxMessage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.TimeUnit; + +import dev.inditex.scsoutbox.OutboxMessage; +import dev.inditex.scsoutbox.publish.OutboxMessagePublisher; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; + +class PublishingDelayMeterTest { + + private SimpleMeterRegistry meterRegistry; + + private OutboxMessagePublisher publisher; + + @BeforeEach + void setUp() { + this.meterRegistry = new SimpleMeterRegistry(); + final OutboxMessagePublisher mock = mock(OutboxMessagePublisher.class); + final AspectJProxyFactory factory = new AspectJProxyFactory(mock); + factory.addAspect(new PublishingDelayMeter(this.meterRegistry)); + this.publisher = factory.getProxy(); + } + + @Test + void update_metric_when_a_message_is_published() { + assertThat(this.meterRegistry.get("outbox.publishing.delay").timer().count()) + .isZero(); + + this.publisher.publish(anOutboxMessage()); + + assertThat(this.meterRegistry.get("outbox.publishing.delay").timer().count()) + .isPositive(); + } + + @Test + void publishing_delay_is_the_millis_between_captured_date_and_the_time_the_message_was_published() { + final OutboxMessage message = anOutboxMessage(); + this.publisher.publish(message); + assertThat( + this.meterRegistry.get("outbox.publishing.delay").timer().totalTime(TimeUnit.NANOSECONDS)) + .isLessThanOrEqualTo( + Duration.between(message.getCapturedAt(), Instant.now()).toNanos()); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/PublishingTaskMeterTest.java b/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/PublishingTaskMeterTest.java new file mode 100644 index 0000000..a603a4a --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/PublishingTaskMeterTest.java @@ -0,0 +1,71 @@ +package dev.inditex.scsoutbox.metrics; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.Instant; + +import dev.inditex.scsoutbox.publish.OutboxPublishingTask; +import dev.inditex.scsoutbox.publish.OutboxPublishingTaskReport; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; + +class PublishingTaskMeterTest { + + private static final String PUBLISHING_MESSAGES_METRIC = "outbox.publishing.messages"; + + private SimpleMeterRegistry meterRegistry; + + private OutboxPublishingTask outboxPublishingTask; + + private OutboxPublishingTask publishingTaskMock; + + @BeforeEach + void setUp() { + this.meterRegistry = new SimpleMeterRegistry(); + final PublishingTaskMeter publishingTaskMeter = new PublishingTaskMeter(this.meterRegistry); + this.publishingTaskMock = mock(OutboxPublishingTask.class); + final Instant start = Instant.now(); + when(this.publishingTaskMock.run()).thenReturn(OutboxPublishingTaskReport.of( + start, start.plus(Duration.ofSeconds(1)), 100)); + final AspectJProxyFactory factory = new AspectJProxyFactory(this.publishingTaskMock); + factory.addAspect(publishingTaskMeter); + this.outboxPublishingTask = factory.getProxy(); + } + + @Test + void published_messages_value_must_be_equal_than_value_of_report() { + assertThat( + this.meterRegistry.get(PUBLISHING_MESSAGES_METRIC).counter().count()) + .isZero(); + + final OutboxPublishingTaskReport report = this.outboxPublishingTask.run(); + + assertThat( + this.meterRegistry.get(PUBLISHING_MESSAGES_METRIC).counter().count()) + .isEqualTo(report.getNumOfPublishedMessages()); + } + + @Test + void on_publish_task_error_then_published_messages_metric_is_not_incremented() { + when(this.publishingTaskMock.run()).thenThrow(new RuntimeException("Error")); + assertThat( + this.meterRegistry.get(PUBLISHING_MESSAGES_METRIC).counter().count()) + .isZero(); + + try { + this.outboxPublishingTask.run(); + } catch (final RuntimeException ignored) { + // expected + } + + assertThat( + this.meterRegistry.get(PUBLISHING_MESSAGES_METRIC).counter().count()) + .isZero(); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/SpringIntegrationMeterFilterTest.java b/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/SpringIntegrationMeterFilterTest.java new file mode 100644 index 0000000..bd00672 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/SpringIntegrationMeterFilterTest.java @@ -0,0 +1,132 @@ +package dev.inditex.scsoutbox.metrics; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import dev.inditex.scsoutbox.OutboxServiceProperties; + +import io.micrometer.core.instrument.Meter.Id; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.integration.support.management.IntegrationManagement; + +class SpringIntegrationMeterFilterTest { + + public static final String RESULT_TAG_NAME = "result"; + + public static final String EXCEPTION_TAG_NAME = "exception"; + + public static final String NONE = "none"; + + public static final String CHANNEL_NAME_TAG_NAME = "name"; + + public static final String FAILURE = "failure"; + + public static final String SUCCESS = "success"; + + private static final String CHANNEL_NAME = "test-channel"; + + private SimpleMeterRegistry meterRegistry; + + private OutboxServiceProperties outboxServiceProperties; + + @BeforeEach + void setUp() { + this.outboxServiceProperties = mock(OutboxServiceProperties.class); + final SpringIntegrationMeterFilter springIntegrationMeterFilter = + new SpringIntegrationMeterFilter(this.outboxServiceProperties); + this.meterRegistry = new SimpleMeterRegistry(); + this.meterRegistry.config().meterFilter(springIntegrationMeterFilter); + } + + private void enableOutboxForChannel(final String channel) { + when(this.outboxServiceProperties.isOutboxEnabledFor(channel)).thenReturn(true); + } + + private void disableOutboxForChannel(final String channel) { + when(this.outboxServiceProperties.isOutboxEnabledFor(channel)).thenReturn(false); + } + + @Test + void when_outbox_is_enabled_for_channel_and_result_is_failure_and_exception_is_none_then_result_is_replace_by_success() { + this.enableOutboxForChannel(CHANNEL_NAME); + this.createMeterWith( + IntegrationManagement.SEND_TIMER_NAME, + CHANNEL_NAME, + FAILURE, + NONE); + + final Id meterId = this.meterRegistry.get(IntegrationManagement.SEND_TIMER_NAME).timer().getId(); + + assertEquals(SUCCESS, meterId.getTag(RESULT_TAG_NAME)); + } + + @Test + void when_outbox_is_enabled_for_channel_and_result_is_failure_and_exception_is_not_none_then_result_continues_to_be_failure() { + this.enableOutboxForChannel(CHANNEL_NAME); + this.createMeterWith( + IntegrationManagement.SEND_TIMER_NAME, + CHANNEL_NAME, + FAILURE, + Exception.class.getName()); + + final Id meterId = this.meterRegistry.get(IntegrationManagement.SEND_TIMER_NAME).timer().getId(); + + assertEquals(FAILURE, meterId.getTag(RESULT_TAG_NAME)); + } + + @Test + void when_outbox_is_disabled_for_channel_then_result_is_not_replaced() { + this.disableOutboxForChannel(CHANNEL_NAME); + this.createMeterWith( + IntegrationManagement.SEND_TIMER_NAME, + CHANNEL_NAME, + SUCCESS, + NONE); + + final Id meterId = this.meterRegistry.get(IntegrationManagement.SEND_TIMER_NAME).timer().getId(); + + assertEquals(SUCCESS, meterId.getTag(RESULT_TAG_NAME)); + } + + @Test + void when_result_is_not_failure_then_result_is_not_replaced() { + this.enableOutboxForChannel(CHANNEL_NAME); + final String resultValue = SUCCESS; + this.createMeterWith( + IntegrationManagement.SEND_TIMER_NAME, + CHANNEL_NAME, + resultValue, + NONE); + + final Id meterId = this.meterRegistry.get(IntegrationManagement.SEND_TIMER_NAME).timer().getId(); + + assertEquals(resultValue, meterId.getTag(RESULT_TAG_NAME)); + } + + @Test + void when_metric_name_is_not_spring_integration_send_then_result_is_not_replaced() { + this.enableOutboxForChannel(CHANNEL_NAME); + final String metricName = "another-metric-name"; + final String resultValue = FAILURE; + this.createMeterWith( + metricName, + CHANNEL_NAME, + resultValue, + NONE); + + final Id meterId = this.meterRegistry.get(metricName).timer().getId(); + + assertEquals(resultValue, meterId.getTag(RESULT_TAG_NAME)); + } + + private void createMeterWith(final String meterName, final String channelName, final String result, final String exception) { + this.meterRegistry.timer( + meterName, + CHANNEL_NAME_TAG_NAME, channelName, + RESULT_TAG_NAME, result, + EXCEPTION_TAG_NAME, exception); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/config/OutboxMetricsAutoConfigurationTest.java b/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/config/OutboxMetricsAutoConfigurationTest.java new file mode 100644 index 0000000..e2e6c7a --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/config/OutboxMetricsAutoConfigurationTest.java @@ -0,0 +1,66 @@ +package dev.inditex.scsoutbox.metrics.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import dev.inditex.scsoutbox.OutboxMessageRepository; +import dev.inditex.scsoutbox.metrics.MessagesPendingMeter; +import dev.inditex.scsoutbox.metrics.PublishingDelayMeter; +import dev.inditex.scsoutbox.metrics.PublishingTaskMeter; + +import io.micrometer.core.aop.TimedAspect; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class OutboxMetricsAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(OutboxMetricsAutoConfiguration.class)); + + private ApplicationContextRunner contextRunnerWithDependencies() { + return this.contextRunner + .withPropertyValues("scs-outbox.metrics.enabled=true") + .withBean(MeterRegistry.class, SimpleMeterRegistry::new) + .withBean(OutboxMessageRepository.class, () -> mock(OutboxMessageRepository.class)); + } + + @Nested + class MetricsBeans { + + @Test + void when_metrics_enabled_with_meter_registry_expect_all_meters_created() { + OutboxMetricsAutoConfigurationTest.this.contextRunnerWithDependencies() + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(TimedAspect.class); + assertThat(context).hasSingleBean(MessagesPendingMeter.class); + assertThat(context).hasSingleBean(PublishingDelayMeter.class); + assertThat(context).hasSingleBean(PublishingTaskMeter.class); + }); + } + + @Test + void when_metrics_disabled_expect_autoconfiguration_skipped() { + OutboxMetricsAutoConfigurationTest.this.contextRunner + .withBean(MeterRegistry.class, SimpleMeterRegistry::new) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(MessagesPendingMeter.class); + }); + } + + @Test + void when_meter_registry_absent_expect_autoconfiguration_skipped() { + OutboxMetricsAutoConfigurationTest.this.contextRunner + .withPropertyValues("scs-outbox.metrics.enabled=true") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(MessagesPendingMeter.class); + }); + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/config/SpringIntegrationMetricsAutoConfigurationTest.java b/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/config/SpringIntegrationMetricsAutoConfigurationTest.java new file mode 100644 index 0000000..506649e --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-metrics/src/test/java/dev/inditex/scsoutbox/metrics/config/SpringIntegrationMetricsAutoConfigurationTest.java @@ -0,0 +1,45 @@ +package dev.inditex.scsoutbox.metrics.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import dev.inditex.scsoutbox.OutboxServiceProperties; +import dev.inditex.scsoutbox.metrics.SpringIntegrationMeterFilter; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class SpringIntegrationMetricsAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SpringIntegrationMetricsAutoConfiguration.class)); + + @Nested + class SpringIntegrationMeterFilterBean { + + @Test + void when_meter_registry_present_expect_filter_created() { + SpringIntegrationMetricsAutoConfigurationTest.this.contextRunner + .withBean(MeterRegistry.class, SimpleMeterRegistry::new) + .withBean(OutboxServiceProperties.class, () -> mock(OutboxServiceProperties.class)) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(SpringIntegrationMeterFilter.class); + }); + } + + @Test + void when_meter_registry_absent_expect_autoconfiguration_skipped() { + SpringIntegrationMetricsAutoConfigurationTest.this.contextRunner + .withBean(OutboxServiceProperties.class, () -> mock(OutboxServiceProperties.class)) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(SpringIntegrationMeterFilter.class); + }); + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-mongodb/pom.xml b/code/scs-outbox-libs/scs-outbox-mongodb/pom.xml new file mode 100644 index 0000000..51f3864 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-mongodb/pom.xml @@ -0,0 +1,83 @@ + + + 4.0.0 + + dev.inditex.scsoutbox + scs-outbox-libs + 1.0.0-SNAPSHOT + + + scs-outbox-mongodb + + + 17 + 17 + UTF-8 + + + + + net.javacrumbs.shedlock + shedlock-provider-mongo + + + org.springframework.data + spring-data-mongodb + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.mongodb + mongodb-driver-sync + + + dev.inditex.scsoutbox + scs-outbox-core + + + dev.inditex.scsoutbox + scs-outbox-serialization + + + dev.inditex.scsoutbox + scs-outbox-test-support + test + + + + org.junit.jupiter + junit-jupiter + test + + + org.testcontainers + testcontainers + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + org.testcontainers + testcontainers-mongodb + test + + + org.springframework.boot + spring-boot-starter-test + test + + + de.flapdoodle.embed + de.flapdoodle.embed.mongo.spring4x + test + + + + diff --git a/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxMessageRepository.java b/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxMessageRepository.java new file mode 100755 index 0000000..20814d4 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxMessageRepository.java @@ -0,0 +1,141 @@ +package dev.inditex.scsoutbox.mongodb; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import dev.inditex.scsoutbox.OutboxMessage; +import dev.inditex.scsoutbox.OutboxMessageRepository; +import dev.inditex.scsoutbox.mongodb.config.MongoDbProperties; +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer; +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer.SerializedOutboxMessage; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +public class MongoDbOutboxMessageRepository implements OutboxMessageRepository { + + private final MongoTemplate mongoTemplate; + + private final OutboxMessageSerializer serializer; + + private final MongoDbProperties mongoDbProperties; + + public MongoDbOutboxMessageRepository(MongoTemplate mongoTemplate, OutboxMessageSerializer serializer, + MongoDbProperties mongoDbProperties) { + this.mongoTemplate = mongoTemplate; + this.serializer = serializer; + this.mongoDbProperties = mongoDbProperties; + } + + @Override + public List findAllOrderByCapturedAt(int maxResults) { + final Query query = new Query() + .with(Sort.by("capturedAt").ascending()) + .limit(maxResults); + + final List documents = + this.mongoTemplate.find(query, OutboxMessageDocument.class, this.getCollectionName()); + + return this.mapWithDeserializationErrorHandling(documents); + } + + /** + * Maps documents to OutboxMessages one by one, stopping at the first deserialization error. Documents that were successfully deserialized + * before the error are returned. + * + * @param documents the documents to map + * @return list of successfully deserialized messages (may be truncated if a deserialization error occurred) + */ + private List mapWithDeserializationErrorHandling(List documents) { + final List messages = new ArrayList<>(); + for (final OutboxMessageDocument document : documents) { + try { + messages.add(this.map(document)); + } catch (final Exception e) { + log.error("Deserialization failed for message [{}] at destination [{}]. " + + "Returning {} successfully deserialized messages.", + document.getId(), + document.getDestination(), + messages.size(), e); + break; + } + } + return messages; + } + + @Override + public List findAllOrderByCapturedAtExcludingDestinations(Set excludedDestinations, int maxResults) { + if (excludedDestinations == null || excludedDestinations.isEmpty()) { + return this.findAllOrderByCapturedAt(maxResults); + } + + final Query query = new Query() + .addCriteria(Criteria.where("destination").nin(excludedDestinations)) + .with(Sort.by("capturedAt").ascending()) + .limit(maxResults); + + final List documents = + this.mongoTemplate.find(query, OutboxMessageDocument.class, this.getCollectionName()); + + return this.mapWithDeserializationErrorHandling(documents); + } + + @Override + public long count() { + return this.mongoTemplate.count(new Query(), OutboxMessageDocument.class, this.getCollectionName()); + } + + @Override + public long estimatedCount() { + return this.mongoTemplate.estimatedCount(this.getCollectionName()); + } + + @Override + @Transactional(propagation = Propagation.REQUIRED) + public void save(final OutboxMessage outboxMessage) { + this.mongoTemplate.insert(this.map(outboxMessage), this.getCollectionName()); + } + + private String getCollectionName() { + return this.mongoDbProperties.getCollectionName(); + } + + @Override + @Transactional(propagation = Propagation.REQUIRED) + public void delete(final OutboxMessage outboxMessage) { + final Query searchQuery = new Query(Criteria.where("id").is(outboxMessage.getId().toString())); + this.mongoTemplate.remove(searchQuery, OutboxMessageDocument.class, this.getCollectionName()); + } + + public OutboxMessageDocument map(final OutboxMessage outboxMessage) { + final SerializedOutboxMessage serialized = this.serializer.serialize(outboxMessage); + return OutboxMessageDocument.builder() + .id(serialized.getId().toString()) + .capturedAt(serialized.getCapturedAt()) + .destination(serialized.getDestination()) + .bindingName(serialized.getBindingName()) + .headers(serialized.getHeaders()) + .payload(serialized.getPayload()) + .build(); + } + + public OutboxMessage map(final OutboxMessageDocument document) { + final SerializedOutboxMessage serialized = SerializedOutboxMessage.builder() + .id(UUID.fromString(document.getId())) + .capturedAt(document.getCapturedAt()) + .destination(document.getDestination()) + .bindingName(document.getBindingName()) + .headers(document.getHeaders()) + .payload(document.getPayload()) + .build(); + return this.serializer.deserialize(serialized); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxTemplateProvider.java b/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxTemplateProvider.java new file mode 100644 index 0000000..0e52aab --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxTemplateProvider.java @@ -0,0 +1,51 @@ +package dev.inditex.scsoutbox.mongodb; + +import java.util.Objects; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.mongodb.core.MongoTemplate; + +/** + * MongoDB implementation of {@link OutboxMongoTemplateProvider}. Manages MongoTemplate instances for capture and publishing operations, + * with optional dedicated publishing MongoTemplate. + */ +@Slf4j +public class MongoDbOutboxTemplateProvider implements OutboxMongoTemplateProvider { + + private final MongoTemplate primaryTemplate; // capture + + private final MongoTemplate publishingTemplate; // dedicated or same as primary + + /** + * Creates a provider with a mandatory capture MongoTemplate and an optional publishing MongoTemplate. + * + * @param captureTemplate the MongoTemplate for capture operations (required, typically the primary MongoTemplate) + * @param publishingTemplate the MongoTemplate for publishing operations (optional, can be null) + */ + public MongoDbOutboxTemplateProvider( + MongoTemplate captureTemplate, + MongoTemplate publishingTemplate) { + + this.primaryTemplate = Objects.requireNonNull(captureTemplate, + "Capture MongoTemplate is required"); + + if (publishingTemplate == null) { + this.publishingTemplate = this.primaryTemplate; + log.info( + "No dedicated publishing MongoTemplate configured. Using primary MongoTemplate for both capture and publishing operations"); + } else { + this.publishingTemplate = publishingTemplate; + log.info("Using dedicated MongoTemplates: capture and publishing operations are isolated"); + } + } + + @Override + public MongoTemplate getPrimary() { + return this.primaryTemplate; + } + + @Override + public MongoTemplate getDedicatedForPublishing() { + return this.publishingTemplate; + } +} diff --git a/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/OutboxMessageDocument.java b/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/OutboxMessageDocument.java new file mode 100644 index 0000000..89130fd --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/OutboxMessageDocument.java @@ -0,0 +1,42 @@ +package dev.inditex.scsoutbox.mongodb; + +import java.time.Instant; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +@Document("OUTBOX") +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Builder +@ToString +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class OutboxMessageDocument { + + @EqualsAndHashCode.Include + @Id + @NonNull + private final String id; + + @NonNull + private final String destination; + + private final byte @NonNull [] payload; + + @NonNull + private final String headers; + + @NonNull + private final Instant capturedAt; + + @NonNull + private final String bindingName; + +} diff --git a/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/OutboxMongoTemplateProvider.java b/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/OutboxMongoTemplateProvider.java new file mode 100644 index 0000000..19f6462 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/OutboxMongoTemplateProvider.java @@ -0,0 +1,26 @@ +package dev.inditex.scsoutbox.mongodb; + +import org.springframework.data.mongodb.core.MongoTemplate; + +/** + * Provides MongoTemplate instances for different outbox operations. This allows using different MongoDB connections for capture + * (transactional) and publishing (scheduled) operations. + */ +public interface OutboxMongoTemplateProvider { + + /** + * Gets the MongoTemplate used for message capture operations. This MongoTemplate is used during application transactions. + * + * @return the capture MongoTemplate (never null) + */ + MongoTemplate getPrimary(); + + /** + * Gets the MongoTemplate used for message publishing operations. This MongoTemplate is used during scheduled publishing tasks. Returns + * the same as capture MongoTemplate if no dedicated publishing MongoTemplate is configured. + * + * @return the publishing MongoTemplate (never null) + */ + MongoTemplate getDedicatedForPublishing(); + +} diff --git a/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/config/MongoDbOutboxAutoConfiguration.java b/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/config/MongoDbOutboxAutoConfiguration.java new file mode 100755 index 0000000..b01fd04 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/config/MongoDbOutboxAutoConfiguration.java @@ -0,0 +1,99 @@ +package dev.inditex.scsoutbox.mongodb.config; + +import static dev.inditex.scsoutbox.config.OutboxAutoConfiguration.OUTBOX_MESSAGE_REPOSITORY_BEAN_NAME; +import static dev.inditex.scsoutbox.config.OutboxAutoConfiguration.PUBLISHING_OUTBOX_MESSAGE_REPOSITORY_BEAN_NAME; + +import dev.inditex.scsoutbox.OutboxMessageRepository; +import dev.inditex.scsoutbox.config.OutboxAutoConfiguration; +import dev.inditex.scsoutbox.mongodb.MongoDbOutboxMessageRepository; +import dev.inditex.scsoutbox.mongodb.MongoDbOutboxTemplateProvider; +import dev.inditex.scsoutbox.mongodb.OutboxMongoTemplateProvider; +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.data.mongodb.core.MongoTemplate; + +/** + * Auto-configuration for MongoDB Outbox. Sets up the necessary beans for MongoDB-based outbox message repository. + * + *

This configuration creates an OutboxMongoTemplateProvider to coordinate MongoTemplate usage and two qualified repositories: + * outboxMessageRepository for message capture and publishingOutboxMessageRepository for message publishing. + */ +@Slf4j +@AutoConfiguration(before = OutboxAutoConfiguration.class) +@EnableConfigurationProperties({MongoDbProperties.class}) +public class MongoDbOutboxAutoConfiguration { + + /** + * Bean name for the dedicated MongoTemplate used for outbox publishing operations. If a bean with this name exists, it will be used for + * publishing and archive operations. + */ + public static final String OUTBOX_PUBLISHING_MONGOTEMPLATE_BEAN_NAME = "outboxPublishingMongoTemplate"; + + /** + * Creates the MongoTemplate provider that coordinates which MongoTemplate to use for each operation. The publishing MongoTemplate is + * optional - if not present, the primary MongoTemplate is used for both operations. + * + * @param captureTemplate the primary MongoTemplate (always present) + * @param publishingTemplate optional dedicated MongoTemplate for publishing operations + * @return the OutboxMongoTemplateProvider instance + */ + @Bean + public OutboxMongoTemplateProvider outboxMongoTemplateProvider( + final MongoTemplate captureTemplate, + @Qualifier(OUTBOX_PUBLISHING_MONGOTEMPLATE_BEAN_NAME) @Autowired(required = false) final MongoTemplate publishingTemplate) { + + return new MongoDbOutboxTemplateProvider(captureTemplate, publishingTemplate); + } + + /** + * Creates the OutboxMessageRepository for message capture using the capture MongoTemplate from the provider. This repository is used + * during application transactions to capture outbox messages. + * + * @param templateProvider the provider that coordinates MongoTemplate usage + * @param outboxMessageSerializer serializer used to persist outbox payloads and headers + * @param properties MongoDB configuration properties + * @return the capture repository instance + */ + @Bean(OUTBOX_MESSAGE_REPOSITORY_BEAN_NAME) + public OutboxMessageRepository outboxMessageRepository( + final OutboxMongoTemplateProvider templateProvider, + final OutboxMessageSerializer outboxMessageSerializer, + final MongoDbProperties properties) { + + final MongoTemplate mongoTemplate = templateProvider.getPrimary(); + + log.info("Creating capture OutboxMessageRepository"); + + return new MongoDbOutboxMessageRepository(mongoTemplate, outboxMessageSerializer, properties); + } + + /** + * Creates the OutboxMessageRepository for message publishing using the publishing MongoTemplate from the provider. This repository is + * used during scheduled tasks to publish outbox messages. It will use a dedicated MongoTemplate if configured, otherwise it will use the + * primary MongoTemplate. + * + * @param templateProvider the provider that coordinates MongoTemplate usage + * @param outboxMessageSerializer serializer used to persist outbox payloads and headers + * @param properties MongoDB configuration properties + * @return the publishing repository instance + */ + @Bean(PUBLISHING_OUTBOX_MESSAGE_REPOSITORY_BEAN_NAME) + public OutboxMessageRepository publishingOutboxMessageRepository( + final OutboxMongoTemplateProvider templateProvider, + final OutboxMessageSerializer outboxMessageSerializer, + final MongoDbProperties properties) { + + final MongoTemplate mongoTemplate = templateProvider.getDedicatedForPublishing(); + + log.info("Creating publishing OutboxMessageRepository"); + + return new MongoDbOutboxMessageRepository(mongoTemplate, outboxMessageSerializer, properties); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/config/MongoDbProperties.java b/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/config/MongoDbProperties.java new file mode 100644 index 0000000..f4b2efc --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/config/MongoDbProperties.java @@ -0,0 +1,30 @@ +package dev.inditex.scsoutbox.mongodb.config; + +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +@ConfigurationProperties("scs-outbox.mongodb") +public class MongoDbProperties { + + private static final String DEFAULT_COLLECTION_NAME = "SCS_OUTBOX"; + + @Getter + private final String collectionName; + + public MongoDbProperties() { + this.collectionName = DEFAULT_COLLECTION_NAME; + } + + @ConstructorBinding + public MongoDbProperties(final String collectionName) { + if (collectionName == null || collectionName.isEmpty()) { + this.collectionName = DEFAULT_COLLECTION_NAME; + } else if (collectionName.contains(" ")) { + throw new IllegalArgumentException("Collection name cannot contain spaces"); + } else { + this.collectionName = collectionName; + } + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/config/MongoDbShedlockAutoConfiguration.java b/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/config/MongoDbShedlockAutoConfiguration.java new file mode 100755 index 0000000..08017d2 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-mongodb/src/main/java/dev/inditex/scsoutbox/mongodb/config/MongoDbShedlockAutoConfiguration.java @@ -0,0 +1,25 @@ +package dev.inditex.scsoutbox.mongodb.config; + +import dev.inditex.scsoutbox.mongodb.OutboxMongoTemplateProvider; + +import net.javacrumbs.shedlock.core.LockProvider; +import net.javacrumbs.shedlock.provider.mongo.MongoLockProvider; +import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@EnableSchedulerLock(defaultLockAtMostFor = "PT5m") +public class MongoDbShedlockAutoConfiguration { + + /** + * LockProvider Bean by default. + */ + @ConditionalOnMissingBean + @Bean + public LockProvider lockProvider(final OutboxMongoTemplateProvider templateProvider) { + return new MongoLockProvider(templateProvider.getDedicatedForPublishing().getDb()); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-mongodb/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/code/scs-outbox-libs/scs-outbox-mongodb/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..9bfb922 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-mongodb/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +dev.inditex.scsoutbox.mongodb.config.MongoDbOutboxAutoConfiguration +dev.inditex.scsoutbox.mongodb.config.MongoDbShedlockAutoConfiguration \ No newline at end of file diff --git a/code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxMessageRepositoryIT.java b/code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxMessageRepositoryIT.java new file mode 100644 index 0000000..5690f86 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxMessageRepositoryIT.java @@ -0,0 +1,285 @@ +package dev.inditex.scsoutbox.mongodb; + +import static dev.inditex.scsoutbox.OutboxMessageRepository.UNLIMITED; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import dev.inditex.scsoutbox.OutboxMessage; +import dev.inditex.scsoutbox.mongodb.config.MongoDbProperties; +import dev.inditex.scsoutbox.serialization.JavaSerialization; +import dev.inditex.scsoutbox.serialization.JsonHeadersMapper; +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer; +import dev.inditex.scsoutbox.test.ContainerImages; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import org.bson.UuidRepresentation; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.mongodb.MongoDatabaseFactory; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.mongodb.MongoDBContainer; + +@Testcontainers +class MongoDbOutboxMessageRepositoryIT { + + @Container + public static MongoDBContainer mongoDBContainer = + new MongoDBContainer(ContainerImages.MONGO); + + private static final Instant BASE_TIME = Instant.parse("2025-01-01T10:00:00Z"); + + private static final String DATABASE_NAME = "default"; + + private MongoClient mongoClient; + + private MongoDbOutboxMessageRepository repository; + + @BeforeAll + static void startContainer() { + mongoDBContainer.start(); + } + + @AfterAll + static void stopContainer() { + mongoDBContainer.stop(); + } + + @BeforeEach + void setup() { + final String uri = mongoDBContainer.getConnectionString() + "/" + DATABASE_NAME; + final ConnectionString connectionString = new ConnectionString(uri); + final MongoClientSettings settings = MongoClientSettings.builder() + .uuidRepresentation(UuidRepresentation.STANDARD) + .applyConnectionString(connectionString) + .build(); + this.mongoClient = MongoClients.create(settings); + final MongoDatabaseFactory factory = new SimpleMongoClientDatabaseFactory( + this.mongoClient, connectionString.getDatabase()); + final MongoTemplate mongoTemplate = new MongoTemplate(factory); + + this.repository = new MongoDbOutboxMessageRepository( + mongoTemplate, + new OutboxMessageSerializer(new JavaSerialization(), new JsonHeadersMapper()), + new MongoDbProperties()); + } + + @AfterEach + void closeMongoClient() { + this.mongoClient.getDatabase(DATABASE_NAME).drop(); + this.mongoClient.close(); + } + + @Nested + class Count { + + @Test + void when_messages_saved_expect_correct_count() { + MongoDbOutboxMessageRepositoryIT.this.repository.save(anOutboxMessage(BASE_TIME)); + MongoDbOutboxMessageRepositoryIT.this.repository.save(anOutboxMessage(BASE_TIME.plusSeconds(1))); + MongoDbOutboxMessageRepositoryIT.this.repository.save(anOutboxMessage(BASE_TIME.plusSeconds(2))); + + assertThat(MongoDbOutboxMessageRepositoryIT.this.repository.count()).isEqualTo(3L); + } + } + + @Nested + class EstimatedCount { + + @Test + void when_messages_saved_expect_estimated_count_not_zero() { + MongoDbOutboxMessageRepositoryIT.this.repository.save(anOutboxMessage(BASE_TIME)); + MongoDbOutboxMessageRepositoryIT.this.repository.save(anOutboxMessage(BASE_TIME.plusSeconds(1))); + MongoDbOutboxMessageRepositoryIT.this.repository.save(anOutboxMessage(BASE_TIME.plusSeconds(2))); + + assertThat(MongoDbOutboxMessageRepositoryIT.this.repository.estimatedCount()).isEqualTo(3L); + } + } + + @Nested + class FindAllOrderByCapturedAt { + + @Test + void when_no_messages_saved_expect_empty_list() { + assertThat(MongoDbOutboxMessageRepositoryIT.this.repository.findAllOrderByCapturedAt(UNLIMITED)).isEmpty(); + } + + @Test + void when_messages_saved_expect_all_returned_ordered_by_captured_at() { + final OutboxMessage message1 = anOutboxMessage(BASE_TIME); + final OutboxMessage message2 = anOutboxMessage(BASE_TIME.plusSeconds(1)); + MongoDbOutboxMessageRepositoryIT.this.repository.save(message1); + MongoDbOutboxMessageRepositoryIT.this.repository.save(message2); + + final List result = + MongoDbOutboxMessageRepositoryIT.this.repository.findAllOrderByCapturedAt(UNLIMITED); + + assertThat(result).hasSize(2).extracting(OutboxMessage::getId) + .containsExactly(message1.getId(), message2.getId()); + } + + @Test + void when_max_results_specified_expect_limited_results_returned() { + for (int i = 0; i < 5; i++) { + MongoDbOutboxMessageRepositoryIT.this.repository.save(anOutboxMessage(BASE_TIME.plusSeconds(i))); + } + + final List result = + MongoDbOutboxMessageRepositoryIT.this.repository.findAllOrderByCapturedAt(3); + + assertThat(result).hasSize(3); + } + } + + @Nested + class Save { + + @Test + void when_message_saved_expect_roundtrip_successful() { + final OutboxMessage outboxMessage = anOutboxMessage(BASE_TIME); + + MongoDbOutboxMessageRepositoryIT.this.repository.save(outboxMessage); + + final List result = + MongoDbOutboxMessageRepositoryIT.this.repository.findAllOrderByCapturedAt(UNLIMITED); + assertThat(result).hasSize(1); + assertThat(result.get(0)).isEqualTo(outboxMessage); + } + } + + @Nested + class Delete { + + @Test + void when_message_deleted_expect_not_found_in_repository() { + final OutboxMessage outboxMessage = anOutboxMessage(BASE_TIME); + MongoDbOutboxMessageRepositoryIT.this.repository.save(outboxMessage); + + MongoDbOutboxMessageRepositoryIT.this.repository.delete(outboxMessage); + + assertThat(MongoDbOutboxMessageRepositoryIT.this.repository.findAllOrderByCapturedAt(UNLIMITED)).isEmpty(); + } + } + + @Nested + class FindAllOrderByCapturedAtExcludingDestinations { + + @Test + void when_specific_destinations_excluded_expect_only_remaining_returned() { + final OutboxMessage message1 = anOutboxMessageWithDestination("destination1", BASE_TIME); + final OutboxMessage message2 = anOutboxMessageWithDestination("destination2", BASE_TIME.plusSeconds(1)); + final OutboxMessage message3 = anOutboxMessageWithDestination("destination3", BASE_TIME.plusSeconds(2)); + final OutboxMessage message4 = anOutboxMessageWithDestination("destination4", BASE_TIME.plusSeconds(3)); + MongoDbOutboxMessageRepositoryIT.this.repository.save(message1); + MongoDbOutboxMessageRepositoryIT.this.repository.save(message2); + MongoDbOutboxMessageRepositoryIT.this.repository.save(message3); + MongoDbOutboxMessageRepositoryIT.this.repository.save(message4); + + final List result = MongoDbOutboxMessageRepositoryIT.this.repository + .findAllOrderByCapturedAtExcludingDestinations(Set.of("destination1", "destination2"), UNLIMITED); + + assertThat(result).hasSize(2) + .extracting(OutboxMessage::getDestination) + .containsExactlyInAnyOrder("destination3", "destination4"); + } + + @Test + void when_max_results_specified_expect_limited_results_returned() { + for (int i = 1; i <= 5; i++) { + MongoDbOutboxMessageRepositoryIT.this.repository + .save(anOutboxMessageWithDestination("destination" + i, BASE_TIME.plusSeconds(i))); + } + + final List result = MongoDbOutboxMessageRepositoryIT.this.repository + .findAllOrderByCapturedAtExcludingDestinations(Set.of("destination6"), 3); + + assertThat(result).hasSize(3); + } + + @Test + void when_no_limit_specified_expect_all_non_excluded_returned() { + for (int i = 1; i <= 5; i++) { + MongoDbOutboxMessageRepositoryIT.this.repository + .save(anOutboxMessageWithDestination("destination" + i, BASE_TIME.plusSeconds(i))); + } + + final List result = MongoDbOutboxMessageRepositoryIT.this.repository + .findAllOrderByCapturedAtExcludingDestinations(Set.of("destination6"), UNLIMITED); + + assertThat(result).hasSize(5); + } + + @Test + void when_empty_exclusions_expect_all_messages_returned() { + MongoDbOutboxMessageRepositoryIT.this.repository.save(anOutboxMessageWithDestination("destination1", BASE_TIME)); + MongoDbOutboxMessageRepositoryIT.this.repository + .save(anOutboxMessageWithDestination("destination2", BASE_TIME.plusSeconds(1))); + + final List result = MongoDbOutboxMessageRepositoryIT.this.repository + .findAllOrderByCapturedAtExcludingDestinations(Set.of(), UNLIMITED); + + assertThat(result).hasSize(2); + } + + @Test + void when_null_exclusions_expect_all_messages_returned() { + MongoDbOutboxMessageRepositoryIT.this.repository.save(anOutboxMessageWithDestination("destination1", BASE_TIME)); + MongoDbOutboxMessageRepositoryIT.this.repository + .save(anOutboxMessageWithDestination("destination2", BASE_TIME.plusSeconds(1))); + + final List result = MongoDbOutboxMessageRepositoryIT.this.repository + .findAllOrderByCapturedAtExcludingDestinations(null, UNLIMITED); + + assertThat(result).hasSize(2); + } + + @Test + void when_all_destinations_excluded_expect_empty_list() { + MongoDbOutboxMessageRepositoryIT.this.repository.save(anOutboxMessageWithDestination("destination1", BASE_TIME)); + MongoDbOutboxMessageRepositoryIT.this.repository + .save(anOutboxMessageWithDestination("destination2", BASE_TIME.plusSeconds(1))); + + final List result = MongoDbOutboxMessageRepositoryIT.this.repository + .findAllOrderByCapturedAtExcludingDestinations(Set.of("destination1", "destination2"), UNLIMITED); + + assertThat(result).isEmpty(); + } + } + + private static OutboxMessage anOutboxMessage(Instant capturedAt) { + return OutboxMessage.builder() + .id(UUID.randomUUID()) + .capturedAt(capturedAt) + .destination("destination") + .bindingName("bindingName") + .payload("payload") + .headers(Map.of("contentType", "value")) + .build(); + } + + private static OutboxMessage anOutboxMessageWithDestination(String destination, Instant capturedAt) { + return OutboxMessage.builder() + .id(UUID.randomUUID()) + .capturedAt(capturedAt) + .destination(destination) + .bindingName("bindingName") + .payload("payload") + .headers(Map.of("contentType", "value")) + .build(); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxMessageRepositoryTest.java b/code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxMessageRepositoryTest.java new file mode 100644 index 0000000..13edb4e --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxMessageRepositoryTest.java @@ -0,0 +1,427 @@ +package dev.inditex.scsoutbox.mongodb; + +import static dev.inditex.scsoutbox.OutboxMessageRepository.UNLIMITED; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import dev.inditex.scsoutbox.OutboxMessage; +import dev.inditex.scsoutbox.mongodb.config.MongoDbProperties; +import dev.inditex.scsoutbox.serialization.JsonHeadersMapper; +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer; +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer.SerializedOutboxMessage; +import dev.inditex.scsoutbox.serialization.SerializationEngine; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; + +@ExtendWith(MockitoExtension.class) +class MongoDbOutboxMessageRepositoryTest { + + private MongoDbOutboxMessageRepository repository; + + @Mock + private MongoTemplate mongoTemplate; + + @Mock + private OutboxMessageSerializer serializer; + + @Captor + private ArgumentCaptor documentCaptor; + + @Captor + private ArgumentCaptor queryCaptor; + + @Captor + private ArgumentCaptor serializedMessageCaptor; + + @BeforeEach + void beforeEach() { + this.repository = new MongoDbOutboxMessageRepository(this.mongoTemplate, this.serializer, new MongoDbProperties()); + } + + @Nested + class Save { + + @Test + void when_message_saved_expect_serializer_called_and_document_inserted() { + final OutboxMessage message = aMessage(); + final SerializedOutboxMessage serialized = aSerializedMessage(message); + when(MongoDbOutboxMessageRepositoryTest.this.serializer.serialize(message)).thenReturn(serialized); + + MongoDbOutboxMessageRepositoryTest.this.repository.save(message); + + verify(MongoDbOutboxMessageRepositoryTest.this.serializer, times(1)).serialize(message); + verify(MongoDbOutboxMessageRepositoryTest.this.mongoTemplate, times(1)) + .insert(MongoDbOutboxMessageRepositoryTest.this.documentCaptor.capture(), eq("SCS_OUTBOX")); + final OutboxMessageDocument inserted = MongoDbOutboxMessageRepositoryTest.this.documentCaptor.getValue(); + assertThat(inserted.getId()).isEqualTo(serialized.getId().toString()); + assertThat(inserted.getDestination()).isEqualTo(serialized.getDestination()); + assertThat(inserted.getBindingName()).isEqualTo(serialized.getBindingName()); + assertThat(inserted.getCapturedAt()).isEqualTo(serialized.getCapturedAt()); + assertThat(inserted.getHeaders()).isEqualTo(serialized.getHeaders()); + assertThat(inserted.getPayload()).isEqualTo(serialized.getPayload()); + } + + @Test + void when_message_saved_with_custom_collection_expect_insert_uses_custom_name() { + final OutboxMessage message = aMessage(); + when(MongoDbOutboxMessageRepositoryTest.this.serializer.serialize(any())).thenReturn(aSerializedMessage(message)); + final MongoDbOutboxMessageRepository customRepository = new MongoDbOutboxMessageRepository( + MongoDbOutboxMessageRepositoryTest.this.mongoTemplate, + MongoDbOutboxMessageRepositoryTest.this.serializer, + new MongoDbProperties("CUSTOM_OUTBOX")); + + customRepository.save(message); + + verify(MongoDbOutboxMessageRepositoryTest.this.mongoTemplate, times(1)) + .insert(any(OutboxMessageDocument.class), eq("CUSTOM_OUTBOX")); + } + } + + @Nested + class Delete { + + @Test + void when_message_deleted_expect_remove_with_id_query_on_correct_collection() { + final OutboxMessage message = aMessage(); + + MongoDbOutboxMessageRepositoryTest.this.repository.delete(message); + + verify(MongoDbOutboxMessageRepositoryTest.this.mongoTemplate, times(1)) + .remove(MongoDbOutboxMessageRepositoryTest.this.queryCaptor.capture(), eq(OutboxMessageDocument.class), eq("SCS_OUTBOX")); + assertThat(MongoDbOutboxMessageRepositoryTest.this.queryCaptor.getValue().getQueryObject().getString("id")) + .isEqualTo(message.getId().toString()); + } + } + + @Nested + class Count { + + @Test + void when_count_called_expect_delegates_to_mongo_template() { + when(MongoDbOutboxMessageRepositoryTest.this.mongoTemplate + .count(any(Query.class), eq(OutboxMessageDocument.class), eq("SCS_OUTBOX"))).thenReturn(5L); + + assertThat(MongoDbOutboxMessageRepositoryTest.this.repository.count()).isEqualTo(5L); + verify(MongoDbOutboxMessageRepositoryTest.this.mongoTemplate, times(1)) + .count(any(Query.class), eq(OutboxMessageDocument.class), eq("SCS_OUTBOX")); + } + } + + @Nested + class EstimatedCount { + + @Test + void when_estimated_count_called_expect_delegates_to_mongo_template() { + when(MongoDbOutboxMessageRepositoryTest.this.mongoTemplate.estimatedCount("SCS_OUTBOX")).thenReturn(42L); + + assertThat(MongoDbOutboxMessageRepositoryTest.this.repository.estimatedCount()).isEqualTo(42L); + verify(MongoDbOutboxMessageRepositoryTest.this.mongoTemplate, times(1)).estimatedCount("SCS_OUTBOX"); + } + } + + @Nested + class FindAllOrderByCapturedAt { + + @Test + void when_no_documents_expect_empty_list() { + when(MongoDbOutboxMessageRepositoryTest.this.mongoTemplate + .find(any(Query.class), eq(OutboxMessageDocument.class), eq("SCS_OUTBOX"))).thenReturn(List.of()); + + assertThat(MongoDbOutboxMessageRepositoryTest.this.repository.findAllOrderByCapturedAt(UNLIMITED)).isEmpty(); + } + + @Test + void when_documents_exist_expect_deserialized_messages_returned() { + final OutboxMessage msg1 = aMessage(); + final OutboxMessage msg2 = aMessage(); + when(MongoDbOutboxMessageRepositoryTest.this.mongoTemplate + .find(any(Query.class), eq(OutboxMessageDocument.class), eq("SCS_OUTBOX"))) + .thenReturn(List.of(aDocumentFor(msg1), aDocumentFor(msg2))); + when(MongoDbOutboxMessageRepositoryTest.this.serializer.deserialize(any())).thenReturn(msg1, msg2); + + final List result = + MongoDbOutboxMessageRepositoryTest.this.repository.findAllOrderByCapturedAt(UNLIMITED); + + assertThat(result).hasSize(2).containsExactly(msg1, msg2); + verify(MongoDbOutboxMessageRepositoryTest.this.serializer, times(2)).deserialize(any()); + } + + @Test + void when_max_results_specified_expect_query_has_limit() { + when(MongoDbOutboxMessageRepositoryTest.this.mongoTemplate + .find(MongoDbOutboxMessageRepositoryTest.this.queryCaptor.capture(), eq(OutboxMessageDocument.class), eq("SCS_OUTBOX"))) + .thenReturn(List.of()); + + MongoDbOutboxMessageRepositoryTest.this.repository.findAllOrderByCapturedAt(5); + + assertThat(MongoDbOutboxMessageRepositoryTest.this.queryCaptor.getValue().getLimit()).isEqualTo(5); + } + + @Test + void when_first_message_fails_deserialization_expect_empty_list() { + final List documents = List.of( + aDocumentWithFailingEngine("destination1", Instant.parse("2025-01-01T10:00:30Z")), + aDocumentWithFailingEngine("destination1", Instant.parse("2025-01-01T10:00:20Z")), + aDocumentWithFailingEngine("destination1", Instant.parse("2025-01-01T10:00:10Z"))); + when(MongoDbOutboxMessageRepositoryTest.this.mongoTemplate + .find(any(Query.class), eq(OutboxMessageDocument.class), any(String.class))).thenReturn(documents); + final MongoDbOutboxMessageRepository failingRepository = + MongoDbOutboxMessageRepositoryTest.this.createRepositoryWithFailingDeserialization(1); + + final List result = failingRepository.findAllOrderByCapturedAt(UNLIMITED); + + assertThat(result).isEmpty(); + } + + @Test + void when_middle_message_fails_deserialization_expect_first_messages_only() { + final List documents = List.of( + aDocumentWithFailingEngine("destination1", Instant.parse("2025-01-01T10:00:50Z")), + aDocumentWithFailingEngine("destination1", Instant.parse("2025-01-01T10:00:40Z")), + aDocumentWithFailingEngine("destination1", Instant.parse("2025-01-01T10:00:30Z")), + aDocumentWithFailingEngine("destination1", Instant.parse("2025-01-01T10:00:20Z")), + aDocumentWithFailingEngine("destination1", Instant.parse("2025-01-01T10:00:10Z"))); + when(MongoDbOutboxMessageRepositoryTest.this.mongoTemplate + .find(any(Query.class), eq(OutboxMessageDocument.class), any(String.class))).thenReturn(documents); + final MongoDbOutboxMessageRepository failingRepository = + MongoDbOutboxMessageRepositoryTest.this.createRepositoryWithFailingDeserialization(3); + + final List result = failingRepository.findAllOrderByCapturedAt(UNLIMITED); + + assertThat(result).hasSize(2); + } + + @Test + void when_last_message_fails_deserialization_expect_all_but_last() { + final List documents = List.of( + aDocumentWithFailingEngine("destination1", Instant.parse("2025-01-01T10:00:30Z")), + aDocumentWithFailingEngine("destination1", Instant.parse("2025-01-01T10:00:20Z")), + aDocumentWithFailingEngine("destination1", Instant.parse("2025-01-01T10:00:10Z"))); + when(MongoDbOutboxMessageRepositoryTest.this.mongoTemplate + .find(any(Query.class), eq(OutboxMessageDocument.class), any(String.class))).thenReturn(documents); + final MongoDbOutboxMessageRepository failingRepository = + MongoDbOutboxMessageRepositoryTest.this.createRepositoryWithFailingDeserialization(3); + + final List result = failingRepository.findAllOrderByCapturedAt(UNLIMITED); + + assertThat(result).hasSize(2); + } + + @Test + void when_all_deserializations_succeed_expect_all_messages() { + final List documents = List.of( + aDocumentWithFailingEngine("destination1", Instant.parse("2025-01-01T10:00:30Z")), + aDocumentWithFailingEngine("destination1", Instant.parse("2025-01-01T10:00:20Z")), + aDocumentWithFailingEngine("destination1", Instant.parse("2025-01-01T10:00:10Z"))); + when(MongoDbOutboxMessageRepositoryTest.this.mongoTemplate + .find(any(Query.class), eq(OutboxMessageDocument.class), any(String.class))).thenReturn(documents); + final MongoDbOutboxMessageRepository failingRepository = + MongoDbOutboxMessageRepositoryTest.this.createRepositoryWithFailingDeserialization(Integer.MAX_VALUE); + + final List result = failingRepository.findAllOrderByCapturedAt(UNLIMITED); + + assertThat(result).hasSize(3); + } + } + + @Nested + class FindAllOrderByCapturedAtExcludingDestinations { + + @Test + void when_exclusions_provided_expect_query_contains_destination_criteria() { + when(MongoDbOutboxMessageRepositoryTest.this.mongoTemplate + .find(MongoDbOutboxMessageRepositoryTest.this.queryCaptor.capture(), eq(OutboxMessageDocument.class), eq("SCS_OUTBOX"))) + .thenReturn(List.of()); + + MongoDbOutboxMessageRepositoryTest.this.repository + .findAllOrderByCapturedAtExcludingDestinations(Set.of("excluded-dest"), UNLIMITED); + + assertThat(MongoDbOutboxMessageRepositoryTest.this.queryCaptor.getValue().getQueryObject()) + .containsKey("destination"); + } + + @Test + void when_null_exclusions_expect_fallback_to_find_all_without_destination_criteria() { + when(MongoDbOutboxMessageRepositoryTest.this.mongoTemplate + .find(MongoDbOutboxMessageRepositoryTest.this.queryCaptor.capture(), eq(OutboxMessageDocument.class), eq("SCS_OUTBOX"))) + .thenReturn(List.of()); + + MongoDbOutboxMessageRepositoryTest.this.repository + .findAllOrderByCapturedAtExcludingDestinations(null, UNLIMITED); + + assertThat(MongoDbOutboxMessageRepositoryTest.this.queryCaptor.getValue() + .getQueryObject().containsKey("destination")).isFalse(); + } + + @Test + void when_empty_exclusions_expect_fallback_to_find_all_without_destination_criteria() { + when(MongoDbOutboxMessageRepositoryTest.this.mongoTemplate + .find(MongoDbOutboxMessageRepositoryTest.this.queryCaptor.capture(), eq(OutboxMessageDocument.class), eq("SCS_OUTBOX"))) + .thenReturn(List.of()); + + MongoDbOutboxMessageRepositoryTest.this.repository + .findAllOrderByCapturedAtExcludingDestinations(Set.of(), UNLIMITED); + + assertThat(MongoDbOutboxMessageRepositoryTest.this.queryCaptor.getValue() + .getQueryObject().containsKey("destination")).isFalse(); + } + + @Test + void when_middle_message_fails_deserialization_expect_first_messages_only() { + final List documents = List.of( + aDocumentWithFailingEngine("destination1", Instant.parse("2025-01-01T10:00:50Z")), + aDocumentWithFailingEngine("destination1", Instant.parse("2025-01-01T10:00:40Z")), + aDocumentWithFailingEngine("destination1", Instant.parse("2025-01-01T10:00:30Z"))); + when(MongoDbOutboxMessageRepositoryTest.this.mongoTemplate + .find(any(Query.class), eq(OutboxMessageDocument.class), any(String.class))).thenReturn(documents); + final MongoDbOutboxMessageRepository failingRepository = + MongoDbOutboxMessageRepositoryTest.this.createRepositoryWithFailingDeserialization(2); + + final List result = + failingRepository.findAllOrderByCapturedAtExcludingDestinations(Set.of("excluded-dest"), UNLIMITED); + + assertThat(result).hasSize(1); + } + } + + @Nested + class MapOutboxMessage { + + @Test + void when_message_mapped_expect_serializer_called_and_all_document_fields_transferred() { + final OutboxMessage message = aMessage(); + final SerializedOutboxMessage serialized = aSerializedMessage(message); + when(MongoDbOutboxMessageRepositoryTest.this.serializer.serialize(message)).thenReturn(serialized); + + final OutboxMessageDocument document = MongoDbOutboxMessageRepositoryTest.this.repository.map(message); + + verify(MongoDbOutboxMessageRepositoryTest.this.serializer, times(1)).serialize(message); + assertThat(document.getId()).isEqualTo(serialized.getId().toString()); + assertThat(document.getDestination()).isEqualTo(serialized.getDestination()); + assertThat(document.getBindingName()).isEqualTo(serialized.getBindingName()); + assertThat(document.getCapturedAt()).isEqualTo(serialized.getCapturedAt()); + assertThat(document.getHeaders()).isEqualTo(serialized.getHeaders()); + assertThat(document.getPayload()).isEqualTo(serialized.getPayload()); + } + } + + @Nested + class MapOutboxMessageDocument { + + @Test + void when_document_mapped_expect_serializer_deserialize_called_with_all_fields() { + final OutboxMessage expectedMessage = aMessage(); + final OutboxMessageDocument document = aDocumentFor(expectedMessage); + when(MongoDbOutboxMessageRepositoryTest.this.serializer + .deserialize(MongoDbOutboxMessageRepositoryTest.this.serializedMessageCaptor.capture())) + .thenReturn(expectedMessage); + + final OutboxMessage result = MongoDbOutboxMessageRepositoryTest.this.repository.map(document); + + assertThat(result).isEqualTo(expectedMessage); + final SerializedOutboxMessage captured = MongoDbOutboxMessageRepositoryTest.this.serializedMessageCaptor.getValue(); + assertThat(captured.getId()).isEqualTo(UUID.fromString(document.getId())); + assertThat(captured.getDestination()).isEqualTo(document.getDestination()); + assertThat(captured.getBindingName()).isEqualTo(document.getBindingName()); + assertThat(captured.getCapturedAt()).isEqualTo(document.getCapturedAt()); + assertThat(captured.getHeaders()).isEqualTo(document.getHeaders()); + assertThat(captured.getPayload()).isEqualTo(document.getPayload()); + } + } + + private MongoDbOutboxMessageRepository createRepositoryWithFailingDeserialization(int failOnCall) { + final SerializationEngine failingEngine = new FailingSerializationEngine(failOnCall); + final OutboxMessageSerializer failingSerializer = + new OutboxMessageSerializer(failingEngine, new JsonHeadersMapper()); + return new MongoDbOutboxMessageRepository(this.mongoTemplate, failingSerializer, new MongoDbProperties()); + } + + private static OutboxMessage aMessage() { + return OutboxMessage.builder() + .id(UUID.randomUUID()) + .capturedAt(Instant.parse("2025-01-01T10:00:00Z")) + .destination("destination") + .bindingName("bindingName") + .payload("payload") + .headers(Map.of("contentType", "application/json")) + .build(); + } + + private static SerializedOutboxMessage aSerializedMessage(OutboxMessage message) { + return SerializedOutboxMessage.builder() + .id(message.getId()) + .capturedAt(message.getCapturedAt()) + .destination(message.getDestination()) + .bindingName(message.getBindingName()) + .headers("{\"contentType\":\"application/json\"}") + .payload(new byte[]{1, 2, 3}) + .build(); + } + + private static OutboxMessageDocument aDocumentFor(OutboxMessage message) { + return OutboxMessageDocument.builder() + .id(message.getId().toString()) + .capturedAt(message.getCapturedAt()) + .destination(message.getDestination()) + .bindingName(message.getBindingName()) + .headers("{\"contentType\":\"application/json\"}") + .payload(new byte[]{1, 2, 3}) + .build(); + } + + private static OutboxMessageDocument aDocumentWithFailingEngine(String destination, Instant capturedAt) { + return OutboxMessageDocument.builder() + .id(UUID.randomUUID().toString()) + .destination(destination) + .bindingName("bindingName") + .capturedAt(capturedAt) + .headers("{\"scs-outbox-serialization-engine\":\"" + FailingSerializationEngine.class.getName() + "\"}") + .payload(new byte[]{1, 2, 3}) + .build(); + } + + private static class FailingSerializationEngine implements SerializationEngine { + + private final int failOnCall; + + private final AtomicInteger callCount = new AtomicInteger(0); + + FailingSerializationEngine(int failOnCall) { + this.failOnCall = failOnCall; + } + + @Override + public Object deserialize(byte[] bytes) { + if (this.callCount.incrementAndGet() >= this.failOnCall) { + throw new RuntimeException("Simulated deserialization error on call " + this.callCount.get()); + } + return "payload"; + } + + @Override + public byte[] serialize(Object object) { + return new byte[]{1, 2, 3}; + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxTemplateProviderTest.java b/code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxTemplateProviderTest.java new file mode 100644 index 0000000..e4eef6d --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/MongoDbOutboxTemplateProviderTest.java @@ -0,0 +1,42 @@ +package dev.inditex.scsoutbox.mongodb; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.Test; +import org.springframework.data.mongodb.core.MongoTemplate; + +class MongoDbOutboxTemplateProviderTest { + + @Test + void constructor_requires_capture_template() { + assertThatThrownBy(() -> new MongoDbOutboxTemplateProvider(null, null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("Capture MongoTemplate is required"); + } + + @Test + void with_only_capture_template_uses_it_for_both() { + final MongoTemplate captureTemplate = mock(MongoTemplate.class); + + final MongoDbOutboxTemplateProvider provider = new MongoDbOutboxTemplateProvider(captureTemplate, null); + + assertThat(provider.getPrimary()).isSameAs(captureTemplate); + assertThat(provider.getDedicatedForPublishing()).isSameAs(captureTemplate); + } + + @Test + void with_dedicated_publishing_template_uses_different_instances() { + final MongoTemplate captureTemplate = mock(MongoTemplate.class); + final MongoTemplate publishingTemplate = mock(MongoTemplate.class); + + final MongoDbOutboxTemplateProvider provider = + new MongoDbOutboxTemplateProvider(captureTemplate, publishingTemplate); + + assertThat(provider.getPrimary()).isSameAs(captureTemplate); + assertThat(provider.getDedicatedForPublishing()).isSameAs(publishingTemplate); + assertThat(provider.getPrimary()).isNotSameAs(provider.getDedicatedForPublishing()); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/config/MongoDbOutboxAutoConfigurationTest.java b/code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/config/MongoDbOutboxAutoConfigurationTest.java new file mode 100644 index 0000000..e21b150 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/config/MongoDbOutboxAutoConfigurationTest.java @@ -0,0 +1,101 @@ +package dev.inditex.scsoutbox.mongodb.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import dev.inditex.scsoutbox.mongodb.MongoDbOutboxTemplateProvider; +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.data.mongodb.core.MongoTemplate; + +class MongoDbOutboxAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(MongoDbOutboxAutoConfiguration.class)); + + private ApplicationContextRunner contextRunnerWithDependencies() { + return this.contextRunner + .withBean(MongoTemplate.class, () -> mock(MongoTemplate.class), bd -> bd.setPrimary(true)) + .withBean(OutboxMessageSerializer.class, () -> mock(OutboxMessageSerializer.class)); + } + + @Nested + class OutboxMongoTemplateProvider { + + @Test + void when_primary_template_present_expect_provider_created() { + MongoDbOutboxAutoConfigurationTest.this.contextRunnerWithDependencies() + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(dev.inditex.scsoutbox.mongodb.OutboxMongoTemplateProvider.class); + assertThat(context.getBean(dev.inditex.scsoutbox.mongodb.OutboxMongoTemplateProvider.class)) + .isInstanceOf(MongoDbOutboxTemplateProvider.class); + }); + } + + @Test + void when_no_dedicated_publishing_template_expect_provider_uses_primary_for_both() { + MongoDbOutboxAutoConfigurationTest.this.contextRunnerWithDependencies() + .run(context -> { + assertThat(context).hasNotFailed(); + final var provider = context.getBean(dev.inditex.scsoutbox.mongodb.OutboxMongoTemplateProvider.class); + assertThat(provider.getPrimary()).isSameAs(provider.getDedicatedForPublishing()); + }); + } + + @Test + void when_dedicated_publishing_template_present_expect_provider_uses_different_templates() { + MongoDbOutboxAutoConfigurationTest.this.contextRunnerWithDependencies() + .withBean(MongoDbOutboxAutoConfiguration.OUTBOX_PUBLISHING_MONGOTEMPLATE_BEAN_NAME, + MongoTemplate.class, () -> mock(MongoTemplate.class)) + .run(context -> { + assertThat(context).hasNotFailed(); + final var provider = context.getBean(dev.inditex.scsoutbox.mongodb.OutboxMongoTemplateProvider.class); + assertThat(provider.getPrimary()).isNotSameAs(provider.getDedicatedForPublishing()); + }); + } + } + + @Nested + class OutboxMessageRepository { + + @Test + void when_dependencies_present_expect_both_repository_beans_created() { + MongoDbOutboxAutoConfigurationTest.this.contextRunnerWithDependencies() + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasBean("outboxMessageRepository"); + assertThat(context).hasBean("publishingOutboxMessageRepository"); + }); + } + + @Test + void when_dependencies_present_expect_repositories_are_outbox_message_repository_instances() { + MongoDbOutboxAutoConfigurationTest.this.contextRunnerWithDependencies() + .run(context -> { + assertThat(context.getBean("outboxMessageRepository")) + .isInstanceOf(dev.inditex.scsoutbox.OutboxMessageRepository.class); + assertThat(context.getBean("publishingOutboxMessageRepository")) + .isInstanceOf(dev.inditex.scsoutbox.OutboxMessageRepository.class); + }); + } + + @Test + void when_mongo_template_missing_expect_context_fails() { + MongoDbOutboxAutoConfigurationTest.this.contextRunner + .withBean(OutboxMessageSerializer.class, () -> mock(OutboxMessageSerializer.class)) + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void when_serializer_missing_expect_context_fails() { + MongoDbOutboxAutoConfigurationTest.this.contextRunner + .withBean(MongoTemplate.class, () -> mock(MongoTemplate.class)) + .run(context -> assertThat(context).hasFailed()); + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/config/MongoDbPropertiesTest.java b/code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/config/MongoDbPropertiesTest.java new file mode 100644 index 0000000..5878356 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/config/MongoDbPropertiesTest.java @@ -0,0 +1,64 @@ +package dev.inditex.scsoutbox.mongodb.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; + +class MongoDbPropertiesTest { + + public static final String DEFAULT_COLLECTION_NAME = "SCS_OUTBOX"; + + @Test + void default_values() { + assertEquals(DEFAULT_COLLECTION_NAME, new MongoDbProperties().getCollectionName()); + assertEquals(DEFAULT_COLLECTION_NAME, new MongoDbProperties(null).getCollectionName()); + assertEquals(DEFAULT_COLLECTION_NAME, new MongoDbProperties("").getCollectionName()); + } + + @Test + void with_invalid_collection_name() { + assertThrows(IllegalArgumentException.class, + () -> new MongoDbProperties("VALUE WITH SPACES")); + } + + @Test + void with_collection_name() { + assertEquals("SCS_OUTBOX_ARCHIVE_TEST", + new MongoDbProperties("SCS_OUTBOX_ARCHIVE_TEST").getCollectionName()); + } + + @Nested + @SpringBootTest(classes = {MongoDbPropertiesTest.class}) + @EnableConfigurationProperties(MongoDbProperties.class) + class SpringBootTestWithoutProperties { + @Autowired + private MongoDbProperties properties; + + @Test + void default_values() { + assertEquals(DEFAULT_COLLECTION_NAME, this.properties.getCollectionName()); + } + } + + @Nested + @SpringBootTest(classes = {MongoDbPropertiesTest.class}, + properties = { + "scs-outbox.mongodb.collection-name=SCS_OUTBOX_ARCHIVE_TEST", + }) + @EnableConfigurationProperties(MongoDbProperties.class) + class SpringBootTestWithProperties { + @Autowired + private MongoDbProperties properties; + + @Test + void property_values() { + assertEquals("SCS_OUTBOX_ARCHIVE_TEST", this.properties.getCollectionName()); + } + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/config/MongoDbShedlockAutoConfigurationTest.java b/code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/config/MongoDbShedlockAutoConfigurationTest.java new file mode 100644 index 0000000..97583e5 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-mongodb/src/test/java/dev/inditex/scsoutbox/mongodb/config/MongoDbShedlockAutoConfigurationTest.java @@ -0,0 +1,65 @@ +package dev.inditex.scsoutbox.mongodb.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import dev.inditex.scsoutbox.mongodb.OutboxMongoTemplateProvider; + +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import net.javacrumbs.shedlock.provider.mongo.MongoLockProvider; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.data.mongodb.core.MongoTemplate; + +class MongoDbShedlockAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(MongoDbShedlockAutoConfiguration.class)); + + @Nested + class LockProvider { + + @Test + @SuppressWarnings("unchecked") + void when_no_lock_provider_present_expect_mongo_lock_provider_created() { + final MongoCollection mongoCollection = mock(MongoCollection.class); + when(mongoCollection.withWriteConcern(any())).thenReturn(mongoCollection); + when(mongoCollection.withReadConcern(any())).thenReturn(mongoCollection); + final MongoDatabase mongoDatabase = mock(MongoDatabase.class); + when(mongoDatabase.getCollection("shedLock")).thenReturn(mongoCollection); + final MongoTemplate mongoTemplate = mock(MongoTemplate.class); + when(mongoTemplate.getDb()).thenReturn(mongoDatabase); + final OutboxMongoTemplateProvider templateProvider = mock(OutboxMongoTemplateProvider.class); + when(templateProvider.getDedicatedForPublishing()).thenReturn(mongoTemplate); + + MongoDbShedlockAutoConfigurationTest.this.contextRunner + .withBean(OutboxMongoTemplateProvider.class, () -> templateProvider) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(net.javacrumbs.shedlock.core.LockProvider.class); + assertThat(context.getBean(net.javacrumbs.shedlock.core.LockProvider.class)) + .isInstanceOf(MongoLockProvider.class); + }); + } + + @Test + void when_custom_lock_provider_present_expect_auto_configuration_backs_off() { + final net.javacrumbs.shedlock.core.LockProvider customLockProvider = + mock(net.javacrumbs.shedlock.core.LockProvider.class); + + MongoDbShedlockAutoConfigurationTest.this.contextRunner + .withBean(net.javacrumbs.shedlock.core.LockProvider.class, () -> customLockProvider) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(net.javacrumbs.shedlock.core.LockProvider.class); + assertThat(context.getBean(net.javacrumbs.shedlock.core.LockProvider.class)) + .isSameAs(customLockProvider); + }); + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-serialization/pom.xml b/code/scs-outbox-libs/scs-outbox-serialization/pom.xml new file mode 100644 index 0000000..7e598c2 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-serialization/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + dev.inditex.scsoutbox + scs-outbox-libs + 1.0.0-SNAPSHOT + + + scs-outbox-serialization + + + + + + org.projectlombok + lombok + + + tools.jackson.core + jackson-databind + + + org.springframework.boot + spring-boot-autoconfigure + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework + spring-messaging + + + dev.inditex.scsoutbox + scs-outbox-core + + + diff --git a/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/HeadersMapper.java b/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/HeadersMapper.java new file mode 100644 index 0000000..101b49e --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/HeadersMapper.java @@ -0,0 +1,10 @@ +package dev.inditex.scsoutbox.serialization; + +import java.util.Map; + +public interface HeadersMapper { + + Map read(String headers); + + String write(Map headers); +} diff --git a/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/JavaSerialization.java b/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/JavaSerialization.java new file mode 100755 index 0000000..23e8062 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/JavaSerialization.java @@ -0,0 +1,32 @@ +package dev.inditex.scsoutbox.serialization; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; + +import lombok.SneakyThrows; + +public class JavaSerialization implements SerializationEngine { + + @Override + @SneakyThrows + public Object deserialize(final byte[] bytes) { + try ( + final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes); + final ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream)) { + return objectInputStream.readObject(); + } + } + + @Override + @SneakyThrows + public byte[] serialize(final Object object) { + try ( + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + final ObjectOutputStream objectOutputStream = new ObjectOutputStream(outputStream)) { + objectOutputStream.writeObject(object); + return outputStream.toByteArray(); + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/JsonHeadersMapper.java b/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/JsonHeadersMapper.java new file mode 100644 index 0000000..ca09bb5 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/JsonHeadersMapper.java @@ -0,0 +1,73 @@ +package dev.inditex.scsoutbox.serialization; + +import java.util.Map; + +import org.springframework.util.MimeType; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; + +public class JsonHeadersMapper implements HeadersMapper { + + static final String CONTENT_TYPE_HEADER = "contentType"; + + private final ObjectMapper mapper; + + public JsonHeadersMapper() { + final SimpleModule module = new SimpleModule(); + module.addSerializer(MimeType.class, new MimeTypeStringSerializer()); + this.mapper = JsonMapper.builder() + .addModule(module) + .build(); + } + + @Override + public Map read(final String headers) { + final Map map = this.mapper.readValue(headers, Map.class); + final Object contentType = map.get(CONTENT_TYPE_HEADER); + if (contentType instanceof Map ctMap) { + map.put(CONTENT_TYPE_HEADER, rebuildMimeTypeFromMap(ctMap)); + } + return map; + } + + @Override + public String write(final Map headers) { + return this.mapper.writeValueAsString(headers); + } + + /** + * Rebuilds a {@link MimeType} from a {@link Map} representation. This handles backward compatibility for records stored in the database + * before the {@link MimeTypeStringSerializer} was introduced — where the {@code contentType} was serialized as a JSON object with + * {@code type}, {@code subtype}, and {@code parameters} properties instead of a simple string. + */ + @SuppressWarnings("unchecked") + static MimeType rebuildMimeTypeFromMap(final Map map) { + final String type = String.valueOf(map.get("type")); + final String subtype = String.valueOf(map.get("subtype")); + final Object params = map.get("parameters"); + if (params instanceof Map paramsMap) { + return new MimeType(type, subtype, (Map) paramsMap); + } + return new MimeType(type, subtype); + } + + /** + * Jackson serializer that writes {@link MimeType} values as their string representation (e.g., {@code "text/*;charset=UTF-8"}) instead of + * the default Jackson object serialization which expands all MimeType properties into a JSON object. This produces more compact JSON and + * avoids deserialization issues where the MimeType comes back as a {@code LinkedHashMap}. + */ + static class MimeTypeStringSerializer extends ValueSerializer { + + MimeTypeStringSerializer() { + } + + @Override + public void serialize(final MimeType value, final JsonGenerator gen, final SerializationContext ctx) { + gen.writeString(value.toString()); + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/OutboxMessageReconverter.java b/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/OutboxMessageReconverter.java new file mode 100644 index 0000000..2873b5d --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/OutboxMessageReconverter.java @@ -0,0 +1,28 @@ +package dev.inditex.scsoutbox.serialization; + +import dev.inditex.scsoutbox.OutboxMessage; +import dev.inditex.scsoutbox.OutboxServiceProperties; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.jspecify.annotations.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.converter.CompositeMessageConverter; +import org.springframework.messaging.support.MessageBuilder; + +@RequiredArgsConstructor +public class OutboxMessageReconverter { + + private final OutboxServiceProperties outboxServiceProperties; + + private final CompositeMessageConverter compositeMessageConverter; + + public @Nullable Object reconvertPayload(OutboxMessage outboxMessage) { + if (this.outboxServiceProperties.useNativeEncoding(outboxMessage.getBindingName())) { + return outboxMessage.getPayload(); + } + final Message<@NonNull Object> message = + MessageBuilder.withPayload(outboxMessage.getPayload()).copyHeaders(outboxMessage.getHeaders()).build(); + return this.compositeMessageConverter.fromMessage(message, Object.class); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/OutboxMessageSerializer.java b/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/OutboxMessageSerializer.java new file mode 100644 index 0000000..e9129bd --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/OutboxMessageSerializer.java @@ -0,0 +1,81 @@ +package dev.inditex.scsoutbox.serialization; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import dev.inditex.scsoutbox.OutboxMessage; + +import lombok.Builder; +import lombok.RequiredArgsConstructor; +import lombok.Value; + +@RequiredArgsConstructor +public class OutboxMessageSerializer { + + public static final String SCS_OUTBOX_SERIALIZATION_ENGINE_HEADER = "scs-outbox-serialization-engine"; + + public static final String NONE = "none"; + + private final SerializationEngine serializationEngine; + + private final HeadersMapper headersMapper; + + public SerializedOutboxMessage serialize(OutboxMessage outboxMessage) { + byte[] payload = null; + final var headers = new HashMap<>(outboxMessage.getHeaders()); + if (outboxMessage.getPayload() instanceof byte[] rawPayload) { + headers.put(SCS_OUTBOX_SERIALIZATION_ENGINE_HEADER, NONE); + payload = rawPayload; + } else { + headers.put(SCS_OUTBOX_SERIALIZATION_ENGINE_HEADER, this.serializationEngine.getClass().getName()); + payload = this.serializationEngine.serialize(outboxMessage.getPayload()); + } + return SerializedOutboxMessage.builder() + .id(outboxMessage.getId()) + .bindingName(outboxMessage.getBindingName()) + .capturedAt(outboxMessage.getCapturedAt()) + .destination(outboxMessage.getDestination()) + .headers(this.headersMapper.write(headers)) + .payload(payload) + .build(); + } + + public OutboxMessage deserialize(SerializedOutboxMessage serializedOutboxMessage) { + final Map outboxHeaders = this.headersMapper.read(serializedOutboxMessage.getHeaders()); + final Object serializationEngineValue = + outboxHeaders.getOrDefault(SCS_OUTBOX_SERIALIZATION_ENGINE_HEADER, this.serializationEngine.getClass().toString()); + Object outboxPayload = null; + if (serializationEngineValue.toString().equals(NONE)) { + outboxPayload = serializedOutboxMessage.getPayload(); + } else { + outboxPayload = this.serializationEngine.deserialize(serializedOutboxMessage.getPayload()); + } + outboxHeaders.remove(SCS_OUTBOX_SERIALIZATION_ENGINE_HEADER); + return OutboxMessage.builder() + .id(serializedOutboxMessage.getId()) + .bindingName(serializedOutboxMessage.getBindingName()) + .destination(serializedOutboxMessage.getDestination()) + .capturedAt(serializedOutboxMessage.getCapturedAt()) + .headers(outboxHeaders) + .payload(outboxPayload) + .build(); + } + + @Builder + @Value + public static class SerializedOutboxMessage { + UUID id; + + String bindingName; + + Instant capturedAt; + + String destination; + + String headers; + + byte[] payload; + } +} diff --git a/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/SerializationEngine.java b/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/SerializationEngine.java new file mode 100644 index 0000000..2995707 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/SerializationEngine.java @@ -0,0 +1,8 @@ +package dev.inditex.scsoutbox.serialization; + +public interface SerializationEngine { + + Object deserialize(final byte[] bytes); + + byte[] serialize(Object object); +} diff --git a/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/config/SerializationAutoConfiguration.java b/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/config/SerializationAutoConfiguration.java new file mode 100755 index 0000000..295f721 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-serialization/src/main/java/dev/inditex/scsoutbox/serialization/config/SerializationAutoConfiguration.java @@ -0,0 +1,47 @@ +package dev.inditex.scsoutbox.serialization.config; + +import dev.inditex.scsoutbox.OutboxServiceProperties; +import dev.inditex.scsoutbox.serialization.HeadersMapper; +import dev.inditex.scsoutbox.serialization.JavaSerialization; +import dev.inditex.scsoutbox.serialization.JsonHeadersMapper; +import dev.inditex.scsoutbox.serialization.OutboxMessageReconverter; +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer; +import dev.inditex.scsoutbox.serialization.SerializationEngine; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.messaging.converter.CompositeMessageConverter; + +@AutoConfiguration +public class SerializationAutoConfiguration { + + /** + * Default serialization engine. + */ + @ConditionalOnMissingBean + @Bean + public SerializationEngine serializationEngine() { + return new JavaSerialization(); + } + + @ConditionalOnMissingBean + @Bean + public HeadersMapper headersMapper() { + return new JsonHeadersMapper(); + } + + @Bean + public OutboxMessageReconverter outboxMessageReconverter( + OutboxServiceProperties outboxServiceProperties, + CompositeMessageConverter compositeMessageConverter) { + return new OutboxMessageReconverter(outboxServiceProperties, compositeMessageConverter); + } + + @Bean + public OutboxMessageSerializer outboxMessageSerializer( + SerializationEngine serializationEngine, + HeadersMapper headerMapper) { + return new OutboxMessageSerializer(serializationEngine, headerMapper); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-serialization/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/code/scs-outbox-libs/scs-outbox-serialization/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..708616a --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-serialization/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +dev.inditex.scsoutbox.serialization.config.SerializationAutoConfiguration \ No newline at end of file diff --git a/code/scs-outbox-libs/scs-outbox-serialization/src/test/java/dev/inditex/scsoutbox/serialization/JsonHeadersMapperTest.java b/code/scs-outbox-libs/scs-outbox-serialization/src/test/java/dev/inditex/scsoutbox/serialization/JsonHeadersMapperTest.java new file mode 100644 index 0000000..f535833 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-serialization/src/test/java/dev/inditex/scsoutbox/serialization/JsonHeadersMapperTest.java @@ -0,0 +1,99 @@ +package dev.inditex.scsoutbox.serialization; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.util.MimeType; + +class JsonHeadersMapperTest { + + private final JsonHeadersMapper mapper = new JsonHeadersMapper(); + + @Test + void when_write_with_mime_type_expect_serialized_as_string() { + final Map headers = new HashMap<>(); + headers.put("contentType", MimeType.valueOf("application/json")); + headers.put("custom-header", "value"); + + final String json = this.mapper.write(headers); + + assertThat(json) + .contains("\"contentType\":\"application/json\"") + .contains("\"custom-header\":\"value\""); + } + + @Test + void when_write_with_mime_type_with_params_expect_serialized_as_string() { + final Map headers = new HashMap<>(); + headers.put("contentType", MimeType.valueOf("text/*;charset=UTF-8")); + + final String json = this.mapper.write(headers); + + assertThat(json).contains("\"contentType\":\"text/*;charset=UTF-8\""); + } + + @Test + void when_read_with_string_content_type_expect_string_preserved() { + final String json = "{\"contentType\":\"application/json\",\"key\":\"val\"}"; + + final Map result = this.mapper.read(json); + + assertThat(result) + .containsEntry("contentType", MimeType.valueOf("application/json").toString()) + .containsEntry("key", "val"); + } + + @Test + void when_read_legacy_map_content_type_expect_rebuilt_as_mime_type() { + // Simulates legacy format stored in database before MimeTypeStringSerializer + final String json = "{\"contentType\":{\"type\":\"text\",\"subtype\":\"*\",\"parameters\":{\"charset\":\"UTF-8\"}," + + "\"wildcardType\":false,\"wildcardSubtype\":true,\"concrete\":false,\"charset\":\"UTF-8\",\"subtypeSuffix\":null}}"; + + final Map result = this.mapper.read(json); + + assertThat(result.get("contentType")).isInstanceOf(MimeType.class); + final MimeType mimeType = (MimeType) result.get("contentType"); + assertThat(mimeType.getType()).isEqualTo("text"); + assertThat(mimeType.getSubtype()).isEqualTo("*"); + assertThat(mimeType.getParameter("charset")).isEqualTo("UTF-8"); + } + + @Test + void when_read_legacy_map_content_type_without_params_expect_rebuilt_as_mime_type() { + final String json = "{\"contentType\":{\"type\":\"application\",\"subtype\":\"json\"}}"; + + final Map result = this.mapper.read(json); + + assertThat(result.get("contentType")).isInstanceOf(MimeType.class); + final MimeType mimeType = (MimeType) result.get("contentType"); + assertThat(mimeType).hasToString("application/json"); + } + + @Test + void when_read_without_content_type_expect_no_error() { + final String json = "{\"kafka_messageKey\":\"my-key\"}"; + + final Map result = this.mapper.read(json); + + assertThat(result) + .containsEntry("kafka_messageKey", "my-key") + .doesNotContainKey("contentType"); + } + + @Test + void when_roundtrip_with_mime_type_expect_string_content_type() { + final Map original = new HashMap<>(); + original.put("contentType", MimeType.valueOf("application/json")); + original.put("custom", "value"); + + final String json = this.mapper.write(original); + final Map restored = this.mapper.read(json); + + assertThat(restored) + .containsEntry("contentType", MimeType.valueOf("application/json").toString()) + .containsEntry("custom", "value"); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-serialization/src/test/java/dev/inditex/scsoutbox/serialization/OutboxMessageReconverterTest.java b/code/scs-outbox-libs/scs-outbox-serialization/src/test/java/dev/inditex/scsoutbox/serialization/OutboxMessageReconverterTest.java new file mode 100644 index 0000000..307bfcc --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-serialization/src/test/java/dev/inditex/scsoutbox/serialization/OutboxMessageReconverterTest.java @@ -0,0 +1,121 @@ +package dev.inditex.scsoutbox.serialization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import dev.inditex.scsoutbox.OutboxMessage; +import dev.inditex.scsoutbox.OutboxServiceProperties; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.Message; +import org.springframework.messaging.converter.CompositeMessageConverter; + +@ExtendWith(MockitoExtension.class) +class OutboxMessageReconverterTest { + + private OutboxMessageReconverter reconverter; + + @Mock + private OutboxServiceProperties outboxServiceProperties; + + @Mock + private CompositeMessageConverter compositeMessageConverter; + + @Captor + private ArgumentCaptor> messageCaptor; + + @BeforeEach + void beforeEach() { + this.reconverter = new OutboxMessageReconverter( + this.outboxServiceProperties, this.compositeMessageConverter); + } + + @Nested + class ReconvertPayload { + + @Test + void when_native_encoding_is_enabled_expect_original_payload_returned_without_converter_call() { + final Object payload = "original-payload"; + final OutboxMessage outboxMessage = buildOutboxMessage(payload, Map.of()); + doReturn(true).when(OutboxMessageReconverterTest.this.outboxServiceProperties) + .useNativeEncoding("test-binding"); + + final Object result = OutboxMessageReconverterTest.this.reconverter.reconvertPayload(outboxMessage); + + assertThat(result).isSameAs(payload); + verifyNoInteractions(OutboxMessageReconverterTest.this.compositeMessageConverter); + } + + @Test + void when_native_encoding_is_disabled_expect_converted_payload_returned() { + final Object convertedPayload = "converted-payload"; + final OutboxMessage outboxMessage = buildOutboxMessage("original-payload", Map.of()); + doReturn(false).when(OutboxMessageReconverterTest.this.outboxServiceProperties) + .useNativeEncoding("test-binding"); + doReturn(convertedPayload).when(OutboxMessageReconverterTest.this.compositeMessageConverter) + .fromMessage(any(), eq(Object.class)); + + final Object result = OutboxMessageReconverterTest.this.reconverter.reconvertPayload(outboxMessage); + + assertThat(result).isSameAs(convertedPayload); + } + + @Test + void when_native_encoding_is_disabled_expect_converter_called_with_message_built_from_outbox_payload_and_headers() { + final Object originalPayload = "original-payload"; + final Map headers = Map.of("kafka_messageKey", "my-key"); + final OutboxMessage outboxMessage = buildOutboxMessage(originalPayload, headers); + doReturn(false).when(OutboxMessageReconverterTest.this.outboxServiceProperties) + .useNativeEncoding("test-binding"); + doReturn(null).when(OutboxMessageReconverterTest.this.compositeMessageConverter) + .fromMessage(any(), eq(Object.class)); + + OutboxMessageReconverterTest.this.reconverter.reconvertPayload(outboxMessage); + + verify(OutboxMessageReconverterTest.this.compositeMessageConverter) + .fromMessage(OutboxMessageReconverterTest.this.messageCaptor.capture(), eq(Object.class)); + final Message capturedMessage = OutboxMessageReconverterTest.this.messageCaptor.getValue(); + assertThat(capturedMessage.getPayload()).isEqualTo(originalPayload); + assertThat(capturedMessage.getHeaders()).containsEntry("kafka_messageKey", "my-key"); + } + + @Test + void when_native_encoding_is_disabled_and_converter_returns_null_expect_null_returned() { + final OutboxMessage outboxMessage = buildOutboxMessage("original-payload", Map.of()); + doReturn(false).when(OutboxMessageReconverterTest.this.outboxServiceProperties) + .useNativeEncoding("test-binding"); + doReturn(null).when(OutboxMessageReconverterTest.this.compositeMessageConverter) + .fromMessage(any(), eq(Object.class)); + + final Object result = OutboxMessageReconverterTest.this.reconverter.reconvertPayload(outboxMessage); + + assertThat(result).isNull(); + } + } + + private static OutboxMessage buildOutboxMessage(final Object payload, final Map headers) { + return OutboxMessage.builder() + .id(UUID.randomUUID()) + .capturedAt(Instant.parse("2026-03-23T12:00:00Z")) + .destination("test-destination") + .bindingName("test-binding") + .payload(payload) + .headers(headers) + .build(); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-serialization/src/test/java/dev/inditex/scsoutbox/serialization/OutboxMessageSerializerTest.java b/code/scs-outbox-libs/scs-outbox-serialization/src/test/java/dev/inditex/scsoutbox/serialization/OutboxMessageSerializerTest.java new file mode 100644 index 0000000..ad91b74 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-serialization/src/test/java/dev/inditex/scsoutbox/serialization/OutboxMessageSerializerTest.java @@ -0,0 +1,223 @@ +package dev.inditex.scsoutbox.serialization; + +import static dev.inditex.scsoutbox.serialization.OutboxMessageSerializer.NONE; +import static dev.inditex.scsoutbox.serialization.OutboxMessageSerializer.SCS_OUTBOX_SERIALIZATION_ENGINE_HEADER; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import dev.inditex.scsoutbox.OutboxMessage; +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer.SerializedOutboxMessage; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class OutboxMessageSerializerTest { + + private OutboxMessageSerializer serializer; + + @Mock + private SerializationEngine serializationEngine; + + private final JsonHeadersMapper headersMapper = new JsonHeadersMapper(); + + @BeforeEach + void beforeEach() { + this.serializer = new OutboxMessageSerializer( + this.serializationEngine, this.headersMapper); + } + + @Nested + class Serialize { + + @Test + void when_payload_is_byte_array_expect_raw_bytes_stored() { + final byte[] data = {1, 2, 3}; + final OutboxMessage message = buildOutboxMessage(data, Map.of()); + + final SerializedOutboxMessage result = OutboxMessageSerializerTest.this.serializer.serialize(message); + + assertThat(result.getPayload()).isEqualTo(data); + verifyNoInteractions(OutboxMessageSerializerTest.this.serializationEngine); + } + + @Test + void when_payload_is_byte_array_expect_none_serialization_header_added() { + final OutboxMessage message = buildOutboxMessage(new byte[]{1, 2}, Map.of()); + + final SerializedOutboxMessage result = OutboxMessageSerializerTest.this.serializer.serialize(message); + + final Map headers = OutboxMessageSerializerTest.this.headersMapper.read(result.getHeaders()); + assertThat(headers).containsEntry(SCS_OUTBOX_SERIALIZATION_ENGINE_HEADER, NONE); + } + + @Test + void when_payload_is_not_byte_array_expect_engine_used_to_serialize() { + final byte[] serializedBytes = {10, 20, 30}; + doReturn(serializedBytes).when(OutboxMessageSerializerTest.this.serializationEngine).serialize("hello"); + final OutboxMessage message = buildOutboxMessage("hello", Map.of()); + + final SerializedOutboxMessage result = OutboxMessageSerializerTest.this.serializer.serialize(message); + + assertThat(result.getPayload()).isEqualTo(serializedBytes); + verify(OutboxMessageSerializerTest.this.serializationEngine).serialize("hello"); + } + + @Test + void when_payload_is_not_byte_array_expect_engine_class_name_in_serialization_header() { + doReturn(new byte[0]).when(OutboxMessageSerializerTest.this.serializationEngine).serialize(any()); + final OutboxMessage message = buildOutboxMessage("hello", Map.of()); + + final SerializedOutboxMessage result = OutboxMessageSerializerTest.this.serializer.serialize(message); + + final Map headers = OutboxMessageSerializerTest.this.headersMapper.read(result.getHeaders()); + assertThat(headers).containsEntry( + SCS_OUTBOX_SERIALIZATION_ENGINE_HEADER, + OutboxMessageSerializerTest.this.serializationEngine.getClass().getName()); + } + + @Test + void when_serialized_expect_original_headers_preserved() { + doReturn(new byte[0]).when(OutboxMessageSerializerTest.this.serializationEngine).serialize(any()); + final OutboxMessage message = buildOutboxMessage("hello", Map.of("kafka_messageKey", "order-123")); + + final SerializedOutboxMessage result = OutboxMessageSerializerTest.this.serializer.serialize(message); + + final Map headers = OutboxMessageSerializerTest.this.headersMapper.read(result.getHeaders()); + assertThat(headers).containsEntry("kafka_messageKey", "order-123"); + } + + @Test + void when_serialized_expect_metadata_fields_preserved() { + final OutboxMessage message = buildOutboxMessage(new byte[]{1}, Map.of()); + + final SerializedOutboxMessage result = OutboxMessageSerializerTest.this.serializer.serialize(message); + + assertThat(result.getId()).isEqualTo(message.getId()); + assertThat(result.getBindingName()).isEqualTo(message.getBindingName()); + assertThat(result.getDestination()).isEqualTo(message.getDestination()); + assertThat(result.getCapturedAt()).isEqualTo(message.getCapturedAt()); + } + + } + + @Nested + class Deserialize { + + @Test + void when_serialization_header_is_none_expect_raw_bytes_returned_as_payload() { + final byte[] rawBytes = {10, 20, 30}; + final SerializedOutboxMessage serialized = buildSerializedOutboxMessage(rawBytes, + Map.of(SCS_OUTBOX_SERIALIZATION_ENGINE_HEADER, NONE)); + + final OutboxMessage result = OutboxMessageSerializerTest.this.serializer.deserialize(serialized); + + assertThat(result.getPayload()).isEqualTo(rawBytes); + verifyNoInteractions(OutboxMessageSerializerTest.this.serializationEngine); + } + + @Test + void when_serialization_header_is_not_none_expect_engine_used_to_deserialize() { + final Object deserializedPayload = "deserialized-payload"; + final byte[] rawBytes = {1, 2, 3}; + doReturn(deserializedPayload).when(OutboxMessageSerializerTest.this.serializationEngine).deserialize(rawBytes); + final SerializedOutboxMessage serialized = buildSerializedOutboxMessage(rawBytes, + Map.of(SCS_OUTBOX_SERIALIZATION_ENGINE_HEADER, JavaSerialization.class.getName())); + + final OutboxMessage result = OutboxMessageSerializerTest.this.serializer.deserialize(serialized); + + assertThat(result.getPayload()).isEqualTo(deserializedPayload); + verify(OutboxMessageSerializerTest.this.serializationEngine).deserialize(rawBytes); + } + + @Test + void when_serialization_header_absent_expect_engine_used_as_fallback_to_deserialize() { + final Object deserializedPayload = "fallback-deserialized"; + final byte[] rawBytes = {5, 6, 7}; + doReturn(deserializedPayload).when(OutboxMessageSerializerTest.this.serializationEngine).deserialize(rawBytes); + // no SCS_OUTBOX_SERIALIZATION_ENGINE_HEADER in headers → getOrDefault uses engine class as fallback + final SerializedOutboxMessage serialized = buildSerializedOutboxMessage(rawBytes, Map.of()); + + final OutboxMessage result = OutboxMessageSerializerTest.this.serializer.deserialize(serialized); + + assertThat(result.getPayload()).isEqualTo(deserializedPayload); + verify(OutboxMessageSerializerTest.this.serializationEngine).deserialize(rawBytes); + } + + @Test + void when_deserialized_expect_serialization_engine_header_removed_from_outbox_headers() { + final SerializedOutboxMessage serialized = buildSerializedOutboxMessage(new byte[]{1}, + Map.of(SCS_OUTBOX_SERIALIZATION_ENGINE_HEADER, NONE, "custom-header", "custom-value")); + + final OutboxMessage result = OutboxMessageSerializerTest.this.serializer.deserialize(serialized); + + assertThat(result.getHeaders()).doesNotContainKey(SCS_OUTBOX_SERIALIZATION_ENGINE_HEADER); + } + + @Test + void when_deserialized_expect_original_headers_preserved() { + final SerializedOutboxMessage serialized = buildSerializedOutboxMessage(new byte[]{1}, + Map.of(SCS_OUTBOX_SERIALIZATION_ENGINE_HEADER, NONE, "kafka_messageKey", "order-456")); + + final OutboxMessage result = OutboxMessageSerializerTest.this.serializer.deserialize(serialized); + + assertThat(result.getHeaders()).containsEntry("kafka_messageKey", "order-456"); + } + + @Test + void when_deserialized_expect_metadata_fields_preserved() { + final UUID id = UUID.randomUUID(); + final Instant capturedAt = Instant.parse("2026-03-23T12:00:00Z"); + final SerializedOutboxMessage serialized = SerializedOutboxMessage.builder() + .id(id) + .bindingName("test-binding") + .destination("test-destination") + .capturedAt(capturedAt) + .headers(new JsonHeadersMapper().write(Map.of(SCS_OUTBOX_SERIALIZATION_ENGINE_HEADER, NONE))) + .payload(new byte[]{1}) + .build(); + + final OutboxMessage result = OutboxMessageSerializerTest.this.serializer.deserialize(serialized); + + assertThat(result.getId()).isEqualTo(id); + assertThat(result.getBindingName()).isEqualTo("test-binding"); + assertThat(result.getDestination()).isEqualTo("test-destination"); + assertThat(result.getCapturedAt()).isEqualTo(capturedAt); + } + } + + private static OutboxMessage buildOutboxMessage(final Object payload, final Map headers) { + return OutboxMessage.builder() + .id(UUID.randomUUID()) + .capturedAt(Instant.parse("2026-03-23T12:00:00Z")) + .destination("test-destination") + .bindingName("test-binding") + .payload(payload) + .headers(headers) + .build(); + } + + private static SerializedOutboxMessage buildSerializedOutboxMessage( + final byte[] payload, final Map headers) { + return SerializedOutboxMessage.builder() + .id(UUID.randomUUID()) + .bindingName("test-binding") + .destination("test-destination") + .capturedAt(Instant.parse("2026-03-23T12:00:00Z")) + .headers(new JsonHeadersMapper().write(headers)) + .payload(payload) + .build(); + } +} diff --git a/code/scs-outbox-libs/scs-outbox-serialization/src/test/java/dev/inditex/scsoutbox/serialization/config/SerializationAutoConfigurationTest.java b/code/scs-outbox-libs/scs-outbox-serialization/src/test/java/dev/inditex/scsoutbox/serialization/config/SerializationAutoConfigurationTest.java new file mode 100644 index 0000000..5b57b75 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-serialization/src/test/java/dev/inditex/scsoutbox/serialization/config/SerializationAutoConfigurationTest.java @@ -0,0 +1,161 @@ +package dev.inditex.scsoutbox.serialization.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import dev.inditex.scsoutbox.OutboxServiceProperties; +import dev.inditex.scsoutbox.config.OutboxProperties; +import dev.inditex.scsoutbox.serialization.HeadersMapper; +import dev.inditex.scsoutbox.serialization.JavaSerialization; +import dev.inditex.scsoutbox.serialization.JsonHeadersMapper; +import dev.inditex.scsoutbox.serialization.OutboxMessageReconverter; +import dev.inditex.scsoutbox.serialization.OutboxMessageSerializer; +import dev.inditex.scsoutbox.serialization.SerializationEngine; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.messaging.converter.CompositeMessageConverter; + +class SerializationAutoConfigurationTest { + + private ApplicationContextRunner contextRunner; + + @BeforeEach + void beforeEach() { + this.contextRunner = new ApplicationContextRunner() + .withUserConfiguration(SerializationAutoConfiguration.class); + } + + private ApplicationContextRunner contextRunnerWithRequiredDependencies() { + final OutboxProperties outboxProperties = new OutboxProperties(null); + final CompositeMessageConverter compositeMessageConverter = mock(CompositeMessageConverter.class); + final OutboxServiceProperties outboxServiceProperties = mock(OutboxServiceProperties.class); + return this.contextRunner + .withBean(OutboxProperties.class, () -> outboxProperties) + .withBean(CompositeMessageConverter.class, () -> compositeMessageConverter) + .withBean(OutboxServiceProperties.class, () -> outboxServiceProperties); + } + + @Nested + class CreateSerializationEngine { + + @Test + void when_context_loads_expect_default_serialization_engine_bean_created() { + SerializationAutoConfigurationTest.this.contextRunnerWithRequiredDependencies() + .run(context -> { + assertThat(context).hasSingleBean(SerializationEngine.class); + assertThat(context.getBean(SerializationEngine.class)) + .isInstanceOf(JavaSerialization.class); + }); + } + + @Test + void when_custom_serialization_engine_provided_expect_custom_bean_used() { + final SerializationEngine customEngine = mock(SerializationEngine.class); + + SerializationAutoConfigurationTest.this.contextRunnerWithRequiredDependencies() + .withBean(SerializationEngine.class, () -> customEngine) + .run(context -> { + assertThat(context).hasSingleBean(SerializationEngine.class); + assertThat(context.getBean(SerializationEngine.class)) + .isSameAs(customEngine); + }); + } + } + + @Nested + class CreateHeadersMapper { + + @Test + void when_context_loads_expect_default_headers_mapper_bean_created() { + SerializationAutoConfigurationTest.this.contextRunnerWithRequiredDependencies() + .run(context -> { + assertThat(context).hasSingleBean(HeadersMapper.class); + assertThat(context.getBean(HeadersMapper.class)) + .isInstanceOf(JsonHeadersMapper.class); + }); + } + + @Test + void when_custom_headers_mapper_provided_expect_custom_bean_used() { + final HeadersMapper customMapper = mock(HeadersMapper.class); + + SerializationAutoConfigurationTest.this.contextRunnerWithRequiredDependencies() + .withBean(HeadersMapper.class, () -> customMapper) + .run(context -> { + assertThat(context).hasSingleBean(HeadersMapper.class); + assertThat(context.getBean(HeadersMapper.class)) + .isSameAs(customMapper); + }); + } + } + + @Nested + class CreateOutboxMessageReconverter { + + @Test + void when_context_loads_expect_outbox_message_reconverter_bean_created() { + SerializationAutoConfigurationTest.this.contextRunnerWithRequiredDependencies() + .run(context -> assertThat(context).hasSingleBean(OutboxMessageReconverter.class)); + } + + @Test + void when_composite_message_converter_missing_expect_context_startup_failure() { + final OutboxProperties outboxProperties = new OutboxProperties(null); + final OutboxServiceProperties outboxServiceProperties = mock(OutboxServiceProperties.class); + + SerializationAutoConfigurationTest.this.contextRunner + .withBean(OutboxProperties.class, () -> outboxProperties) + .withBean(OutboxServiceProperties.class, () -> outboxServiceProperties) + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasMessageContaining("CompositeMessageConverter"); + }); + } + } + + @Nested + class CreateOutboxMessageSerializer { + + @Test + void when_context_loads_expect_outbox_message_serializer_bean_created() { + SerializationAutoConfigurationTest.this.contextRunnerWithRequiredDependencies() + .run(context -> { + assertThat(context).hasSingleBean(OutboxMessageSerializer.class); + assertThat(context).hasSingleBean(OutboxMessageReconverter.class); + final OutboxMessageSerializer serializer = context.getBean(OutboxMessageSerializer.class); + assertThat(serializer).isNotNull(); + }); + } + + @Test + void when_all_dependencies_available_expect_outbox_message_serializer_properly_initialized() { + final SerializationEngine serializationEngine = mock(SerializationEngine.class); + final HeadersMapper headersMapper = mock(HeadersMapper.class); + + SerializationAutoConfigurationTest.this.contextRunnerWithRequiredDependencies() + .withBean(SerializationEngine.class, () -> serializationEngine) + .withBean(HeadersMapper.class, () -> headersMapper) + .run(context -> { + assertThat(context).hasSingleBean(OutboxMessageSerializer.class); + final OutboxMessageSerializer serializer = context.getBean(OutboxMessageSerializer.class); + assertThat(serializer).isNotNull(); + }); + } + + @Test + void when_context_loads_expect_default_serialization_engine_injected_into_serializer() { + SerializationAutoConfigurationTest.this.contextRunnerWithRequiredDependencies() + .run(context -> { + final OutboxMessageSerializer serializer = context.getBean(OutboxMessageSerializer.class); + final SerializationEngine engine = context.getBean(SerializationEngine.class); + + assertThat(serializer).isNotNull(); + assertThat(engine).isInstanceOf(JavaSerialization.class); + }); + } + } +} diff --git a/code/scs-outbox-libs/scs-outbox-serialization/src/test/java/test/app/TestApp.java b/code/scs-outbox-libs/scs-outbox-serialization/src/test/java/test/app/TestApp.java new file mode 100644 index 0000000..2605737 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-serialization/src/test/java/test/app/TestApp.java @@ -0,0 +1,13 @@ +package test.app; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TestApp { + + public static void main(final String[] args) { + SpringApplication.run(TestApp.class, args); + } + +} diff --git a/code/scs-outbox-libs/scs-outbox-test-support/pom.xml b/code/scs-outbox-libs/scs-outbox-test-support/pom.xml new file mode 100644 index 0000000..e449720 --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-test-support/pom.xml @@ -0,0 +1,16 @@ + + + 4.0.0 + + dev.inditex.scsoutbox + scs-outbox-libs + 1.0.0-SNAPSHOT + + + scs-outbox-test-support + + + + + + diff --git a/code/scs-outbox-libs/scs-outbox-test-support/src/main/java/dev/inditex/scsoutbox/test/ContainerImages.java b/code/scs-outbox-libs/scs-outbox-test-support/src/main/java/dev/inditex/scsoutbox/test/ContainerImages.java new file mode 100644 index 0000000..1918e1c --- /dev/null +++ b/code/scs-outbox-libs/scs-outbox-test-support/src/main/java/dev/inditex/scsoutbox/test/ContainerImages.java @@ -0,0 +1,22 @@ +package dev.inditex.scsoutbox.test; + +/** + * Docker image names for all databases and brokers used in integration tests. + * + *

Centralizes container image versions so they can be updated in a single place across all modules. Consume this module with + * {@code test}. + */ +public final class ContainerImages { + + /** MongoDB container image. */ + public static final String MONGO = "mongo:7.0.14"; + + /** MariaDB container image. */ + public static final String MARIADB = "mariadb:10.11.11"; + + /** PostgreSQL container image. */ + public static final String POSTGRESQL = "postgres:16.8"; + + private ContainerImages() { + } +} diff --git a/code/scs-outbox-libs/scs-outbox-test-support/src/main/resources/dev/inditex/scsoutbox/test/container-images.properties b/code/scs-outbox-libs/scs-outbox-test-support/src/main/resources/dev/inditex/scsoutbox/test/container-images.properties new file mode 100644 index 0000000..e69de29 diff --git a/code/scs-outbox-starters/pom.xml b/code/scs-outbox-starters/pom.xml new file mode 100644 index 0000000..7b3c77b --- /dev/null +++ b/code/scs-outbox-starters/pom.xml @@ -0,0 +1,20 @@ + + + 4.0.0 + + dev.inditex.scsoutbox + scs-outbox + 1.0.0-SNAPSHOT + + + scs-outbox-starters + pom + + + scs-outbox-jdbc-starter + scs-outbox-mongodb-starter + + + + + diff --git a/code/scs-outbox-starters/scs-outbox-jdbc-starter/pom.xml b/code/scs-outbox-starters/scs-outbox-jdbc-starter/pom.xml new file mode 100644 index 0000000..18e262b --- /dev/null +++ b/code/scs-outbox-starters/scs-outbox-jdbc-starter/pom.xml @@ -0,0 +1,29 @@ + + + 4.0.0 + + dev.inditex.scsoutbox + scs-outbox-starters + 1.0.0-SNAPSHOT + + + scs-outbox-jdbc-starter + + + + + + dev.inditex.scsoutbox + scs-outbox-jdbc + + + dev.inditex.scsoutbox + scs-outbox-archive-jdbc + + + dev.inditex.scsoutbox + scs-outbox-metrics + + + + diff --git a/code/scs-outbox-starters/scs-outbox-mongodb-starter/pom.xml b/code/scs-outbox-starters/scs-outbox-mongodb-starter/pom.xml new file mode 100644 index 0000000..b85b7d9 --- /dev/null +++ b/code/scs-outbox-starters/scs-outbox-mongodb-starter/pom.xml @@ -0,0 +1,28 @@ + + + 4.0.0 + + dev.inditex.scsoutbox + scs-outbox-starters + 1.0.0-SNAPSHOT + + + scs-outbox-mongodb-starter + + + + + + dev.inditex.scsoutbox + scs-outbox-mongodb + + + dev.inditex.scsoutbox + scs-outbox-archive-mongodb + + + dev.inditex.scsoutbox + scs-outbox-metrics + + + diff --git a/code/src/main/config/checkstyle-java-google-style-17.xml b/code/src/main/config/checkstyle-java-google-style-17.xml new file mode 100644 index 0000000..82da271 --- /dev/null +++ b/code/src/main/config/checkstyle-java-google-style-17.xml @@ -0,0 +1,327 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/code/src/main/config/checkstyle-java-google-style.xml b/code/src/main/config/checkstyle-java-google-style.xml new file mode 100644 index 0000000..702150f --- /dev/null +++ b/code/src/main/config/checkstyle-java-google-style.xml @@ -0,0 +1,297 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/code/src/main/config/checkstyle-suppressions.xml b/code/src/main/config/checkstyle-suppressions.xml new file mode 100644 index 0000000..572b451 --- /dev/null +++ b/code/src/main/config/checkstyle-suppressions.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/code/src/main/config/eclipse-java-google-style.importorder b/code/src/main/config/eclipse-java-google-style.importorder new file mode 100644 index 0000000..6048dca --- /dev/null +++ b/code/src/main/config/eclipse-java-google-style.importorder @@ -0,0 +1,8 @@ +#Organize Import Order +#Mon Sep 20 12:54:41 CEST 2021 +0=\#java +1=\#dev.inditex +2=\# +3=java +4=dev.inditex +5= diff --git a/code/src/main/config/eclipse-java-google-style.xml b/code/src/main/config/eclipse-java-google-style.xml new file mode 100644 index 0000000..1ba1711 --- /dev/null +++ b/code/src/main/config/eclipse-java-google-style.xml @@ -0,0 +1,366 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/code/src/main/config/intellij-java-google-style.xml b/code/src/main/config/intellij-java-google-style.xml new file mode 100644 index 0000000..ee1a575 --- /dev/null +++ b/code/src/main/config/intellij-java-google-style.xml @@ -0,0 +1,852 @@ + + +

+ + + + true + true + true + true + + + +
+
+ + + + true + true + true + + + +
+
+ + + + true + true + true + true + + + +
+
+ + + + true + true + true + + + +
+
+ + + + true + true + true + true + + + +
+
+ + + + true + true + true + + + +
+
+ + + + true + true + true + true + + + +
+
+ + + + true + true + true + + + +
+
+ + + + true + true + + + +
+
+ + + + true + true + true + + + +
+
+ + + + true + true + + + +
+
+ + + + true + true + true + + + +
+
+ + + + true + true + + + +
+
+ + + + true + true + true + + + +
+
+ + + + true + true + + + +
+
+ + + + true + true + true + + + +
+
+ + + + true + true + + + +
+
+ + + true + + +
+
+ + + true + + +
+
+ + + true + + +
+
+ + + + true + true + + + +
+
+ + + true + + +
+
+ + + true + + +
+
+ + + true + + +
+
+ + + + true + true + + + +
+
+ + + true + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + xmlns:android + + ^$ + + + +
+
+ + + + xmlns:.* + + ^$ + + + BY_NAME + +
+
+ + + + .*:id + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + style + + ^$ + + + +
+
+ + + + .* + + ^$ + + + BY_NAME + +
+
+ + + + .*:.*Style + + http://schemas.android.com/apk/res/android + + + BY_NAME + +
+
+ + + + .*:layout_width + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_height + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_weight + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_margin + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_marginTop + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_marginBottom + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_marginStart + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_marginEnd + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_marginLeft + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_marginRight + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_.* + + http://schemas.android.com/apk/res/android + + + BY_NAME + +
+
+ + + + .*:padding + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:paddingTop + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:paddingBottom + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:paddingStart + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:paddingEnd + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:paddingLeft + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:paddingRight + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .* + http://schemas.android.com/apk/res/android + + + BY_NAME + +
+
+ + + + .* + http://schemas.android.com/apk/res-auto + + + BY_NAME + +
+
+ + + + .* + http://schemas.android.com/tools + + + BY_NAME + +
+
+ + + + .* + .* + + + BY_NAME + +
+
+
+
+ + + \ No newline at end of file diff --git a/code/src/main/config/pom-code-convention.xml b/code/src/main/config/pom-code-convention.xml new file mode 100644 index 0000000..be830eb --- /dev/null +++ b/code/src/main/config/pom-code-convention.xml @@ -0,0 +1,641 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/code/src/main/java/dev/inditex/scsoutbox/package-info.java b/code/src/main/java/dev/inditex/scsoutbox/package-info.java new file mode 100644 index 0000000..6892bd7 --- /dev/null +++ b/code/src/main/java/dev/inditex/scsoutbox/package-info.java @@ -0,0 +1 @@ +package dev.inditex.scsoutbox; diff --git a/code/src/test/java/dev/inditex/scsoutbox/test/package-info.java b/code/src/test/java/dev/inditex/scsoutbox/test/package-info.java new file mode 100644 index 0000000..edf4864 --- /dev/null +++ b/code/src/test/java/dev/inditex/scsoutbox/test/package-info.java @@ -0,0 +1 @@ +package dev.inditex.scsoutbox.test; diff --git a/docs/adr/0001-no-cache-for-isOutboxEnabledFor.md b/docs/adr/0001-no-cache-for-isOutboxEnabledFor.md new file mode 100644 index 0000000..d66192c --- /dev/null +++ b/docs/adr/0001-no-cache-for-isOutboxEnabledFor.md @@ -0,0 +1,142 @@ +# ADR-0001: No In-Memory Cache for `isOutboxEnabledFor` + +| Field | Value | +|-------------|------------------------------| +| **Status** | Accepted | +| **Date** | 2026-03-06 | +| **Authors** | scs-outbox team | + +--- + +## Context + +As part of an issue, the `inclusions` and `exclusions` binding properties were extended to support Java-style regular expressions (prefixed with `regex:`). This change introduced `BindingMatcher`, a class that wraps either an exact `String` comparison or a pre-compiled `java.util.regex.Pattern`. + +The method `OutboxServiceProperties.isOutboxEnabledFor(String bindingName)` is now evaluated by streaming over a list of `BindingMatcher` objects and calling `matcher.matches(bindingName)` on each. This raised the question of whether results should be cached in memory, given that: + +- The method sits on the **hot path**: it is invoked on every call to `OutboxChannelInterceptor.preSend()`, which is triggered by each `StreamBridge.send()` in the application. +- A secondary call-site exists in `SpringIntegrationMeterFilter.map()`, invoked at meter registration time (cold path, negligible frequency). +- The set of possible `bindingName` values is finite, small (typically 1–10), and immutable during the application lifecycle. + +To make an evidence-based decision, benchmarks were run measuring 1,000,000 invocations after JIT warm-up across representative scenarios. + +--- + +## Benchmark Results + +All measurements were taken on the same JVM instance with 50,000 warm-up iterations before timing. + +| Scenario | Avg / call | Total (1 M calls) | +|---|---:|---:| +| Empty lists (default, no config) | ~6 ns | 6 ms | +| 3 exact inclusion names, match found | ~24 ns | 24 ms | +| 1 regex inclusion, match found | ~110 ns | 110 ms | +| Mixed: 3 inclusions + 2 exclusions with regex (match) | ~137 ns | 137 ms | +| Worst case: 21 regex inclusions + 10 regex exclusions | ~585 ns | 585 ms | +| `ConcurrentHashMap` cache (mixed 3+2, same key repeated) | ~54 ns | 54 ms | +| `HashMap` cache (mixed 3+2, same key repeated) | ~60 ns | 60 ms | +| Realistic: 5 bindings round-robin, **no cache** | ~85 ns | 85 ms | +| Realistic: 5 bindings round-robin, **with cache** | ~9 ns | 9 ms | + +### Key observation + +In the most realistic scenario (5 bindings rotating, with regex), a cache delivers a **~9× speedup** in relative terms (85 ns → 9 ns). However, the **absolute saving is 76 ns per message**. + +To put this in perspective: a full `StreamBridge.send()` with the outbox pattern (message serialisation + transactional DB write + potential I/O) costs **1–50 ms**. The contribution of `isOutboxEnabledFor` in the worst realistic case (~137 ns) represents **less than 0.01%** of the total operation cost. + +At 10,000 messages/second (a high-throughput scenario), caching would save approximately **0.76 ms per second** of CPU time — a saving that is not measurable in any real production profile. + +--- + +## Decision + +**A cache is NOT added to `isOutboxEnabledFor`.** + +The method will continue to evaluate `BindingMatcher.matches()` on every invocation without memoising results. + +--- + +## Rationale + +### 1. The cost is already negligible by design + +`BindingMatcher` pre-compiles the `Pattern` object **once** at construction time (in `afterPropertiesSet` startup phase). The per-call cost is limited to `Pattern.matcher(input).matches()`, which is a highly JIT-optimised native operation. There is no repeated regex compilation on the hot path. + +### 2. The absolute saving is not observable + +76 ns saved per message is below the noise floor of any meaningful production metric. No real application would detect the difference in latency, throughput, or CPU usage. + +### 3. A cache introduces correctness risk + +`PublicationProperties` already uses `@RefreshScope`, enabling dynamic configuration updates at runtime. If `OutboxProperties` ever adopts `@RefreshScope` (a natural future step), a cached result map would silently serve stale values after a configuration refresh — a hard-to-detect correctness bug. + +### 4. Increased complexity for zero observable benefit + +A cache requires: +- Choosing a thread-safe map type (`ConcurrentHashMap`) and its initialisation strategy. +- Defining an invalidation or rebuild strategy for the cache lifecycle. +- Documenting the caching behaviour. +- Adapting unit tests to cover cached vs. non-cached states. + +None of this complexity yields a measurable improvement in any realistic workload. + +### 5. Preserves the method as a pure function + +Without a cache, `isOutboxEnabledFor` is a **pure function**: same input always produces the same output with no side effects. This maximises testability, predictability, and composability. + +### 6. YAGNI / premature optimisation + +There is no profiling evidence, no bug report, and no performance requirement that identifies this method as a bottleneck. Optimising it pre-emptively violates the YAGNI principle and the general guideline against premature optimisation. + +--- + +## Considered Alternatives + +### Alternative A: Lazy `ConcurrentHashMap` cache + +```java +private final Map cache = new ConcurrentHashMap<>(); + +public boolean isOutboxEnabledFor(final String bindingName) { + return this.cache.computeIfAbsent(bindingName, this::evaluate); +} +``` + +**Rejected** because: +- `computeIfAbsent` on a hot-path `ConcurrentHashMap` costs ~54 ns on a warm cache — actually **slower** than the uncached evaluation for the common case (exact names, ~24 ns) and only marginally faster for regex. +- Silently breaks if `OutboxProperties` becomes `@RefreshScope`-aware in the future. + +### Alternative B: Eager `HashMap` populated in `afterPropertiesSet` + +Pre-compute the result for every declared SCS binding at startup: + +```java +private Map eagerCache; + +@Override +public void afterPropertiesSet() { + // ... existing validation ... + this.eagerCache = bindingServiceProperties.getBindings().keySet().stream() + .collect(toMap(identity(), this::evaluate)); +} +``` + +**Rejected** because: +- Same negligible absolute gain. +- Does not cover bindings registered after startup (dynamic binding scenarios). +- Adds state that must be managed and documented. + +### Alternative C: Status quo (chosen) + +Evaluate `BindingMatcher.matches()` inline on every call with no caching. + +**Accepted** because it is simple, correct, future-proof, and the performance cost is demonstrably negligible. + +--- + +## Consequences + +- `OutboxServiceProperties.isOutboxEnabledFor` remains a stateless, pure function. +- No additional state or lifecycle management is required in `OutboxServiceProperties`. +- If, in the future, profiling under real production load demonstrates this method to be a bottleneck, **Alternative B** (eager cache in `afterPropertiesSet`) should be revisited as the preferred approach, provided that `OutboxProperties` is not `@RefreshScope`-scoped. + diff --git a/docs/adr/0002-code-formatting-toolchain.md b/docs/adr/0002-code-formatting-toolchain.md new file mode 100644 index 0000000..0637ed9 --- /dev/null +++ b/docs/adr/0002-code-formatting-toolchain.md @@ -0,0 +1,129 @@ +# ADR-0002: Code Formatting and Style Enforcement Toolchain + +| Field | Value | +|-------------|------------------------------| +| **Status** | Accepted | +| **Date** | 2026-04-13 | +| **Authors** | scs-outbox team | + +--- + +## Context + +The project previously has been used a proprietary plugin that enforced code formatting during the `validate` Maven phase. This plugin is not available in public repositories, which prevents the project from being built and maintained in open-source environments. + +The goal is to replace it with an equivalent toolchain composed entirely of open-source, publicly available plugins, while preserving the existing style rules and IDE configuration files already present in `src/main/config/`. + +--- + +## Decision + +The proprietary plugin is replaced by three open-source Maven plugins, each with a clearly bounded responsibility: + +| Plugin | Group ID / Artifact ID | Version | Responsibility | +|--------|------------------------|---------|----------------| +| **Spotless** | `com.diffplug.spotless:spotless-maven-plugin` | 2.44.5 | Java source code formatting and import ordering | +| **maven-checkstyle-plugin** | `org.apache.maven.plugins:maven-checkstyle-plugin` | 3.6.0 | Java structural and naming style rules | +| **sortpom-maven-plugin** | `com.github.ekryd.sortpom:sortpom-maven-plugin` | 4.0.0 | POM element ordering | + +All three run during the `validate` Maven phase and are enforced in CI. + +### Developer commands + +```bash +# Auto-format Java source files +mvn -f code/pom.xml spotless:apply + +# Sort POM files +mvn -f code/pom.xml com.github.ekryd.sortpom:sortpom-maven-plugin:sort + +# Full check (same as CI) +mvn -f code/pom.xml validate +``` + +--- + +## Rationale + +### Spotless + +- Reuses the existing `src/main/config/eclipse-java-google-style.xml` formatter configuration (140-character line limit already encoded) and `src/main/config/eclipse-java-google-style.importorder`, so no new style definitions are needed. +- Provides a `spotless:apply` goal that **auto-corrects** formatting issues locally. This is the key differentiator: developers do not need to manually fix formatting violations before committing. +- Handles the two aspects that require a formatter (not just a linter): code layout and import ordering. + +### maven-checkstyle-plugin + +- Reuses the existing `src/main/config/checkstyle-java-google-style-17.xml` ruleset, which already encodes the project's style conventions. +- Covers rules that a formatter cannot enforce: member naming patterns, declaration order, Javadoc structure, parameter naming, and others. +- Operates as a **linter**: it reports violations but does not modify files, which complements Spotless. + +### sortpom-maven-plugin + +- Reuses the existing `src/main/config/pom-code-convention.xml` sort order definition. +- Operates on `pom.xml` files, a scope entirely separate from the Java source tools. +- Provides a `sortpom:sort` goal for local auto-correction, analogous to `spotless:apply`. + +--- + +## Overlaps and How They Are Avoided + +Spotless and maven-checkstyle-plugin share conceptual territory over Java source files, which creates two specific areas of potential conflict: + +### 1. Import ordering + +Both tools are capable of checking import order. Spotless applies the order via the Eclipse formatter; Checkstyle's `ImportOrder` module validates it statically. + +**Resolution**: The `ImportOrder` module has been **removed from the Checkstyle configuration**. Import ordering is exclusively Spotless's responsibility. This eliminates the risk of the two tools disagreeing (which was observed during implementation: Eclipse groups `java.*` and `javax.*` together using prefix matching, while Checkstyle's `ImportOrder` treated them as separate groups). + +### 2. Line length for Java files + +Both tools can enforce the 140-character line limit on `.java` files. Spotless wraps long lines via the Eclipse formatter (`lineSplit=140`); Checkstyle's `LineLength` module checks after the fact. + +**Resolution**: The `LineLength` module in Checkstyle has been **restricted to non-Java file extensions** (`json`, `yaml`, `xml`, `sql`, etc.), which Spotless does not process. Line length for Java files is exclusively Spotless's responsibility. + +### Resulting responsibility matrix + +| Rule | Spotless | Checkstyle | sortpom | +|------|:--------:|:----------:|:-------:| +| Java code formatting (braces, indentation, wrapping) | ✅ | — | — | +| Import ordering | ✅ | — | — | +| Line length (Java) | ✅ | — | — | +| Line length (XML, YAML, JSON…) | — | ✅ | — | +| Naming conventions (members, parameters, methods) | — | ✅ | — | +| Declaration order | — | ✅ | — | +| Javadoc structure | — | ✅ | — | +| Trailing whitespace | ✅ | ✅ | — | +| Tab characters | ✅ | ✅ | — | +| POM element ordering | — | — | ✅ | + +> **Note**: trailing whitespace and tab characters are checked by both tools. This is intentional: Spotless removes them during `apply`, while Checkstyle acts as a safety net for files Spotless may not cover (e.g., non-Java resources). There is no risk of the two producing contradictory results. + +--- + +## Considered Alternatives + +### Alternative A: Google Java Format via `fmt-maven-plugin` + +**Rejected** because Google Java Format enforces a fixed 100-character column limit with no configuration option to change it. The project requires 140 characters, which made this option incompatible without forking or patching. + +### Alternative B: Spotless alone (without maven-checkstyle-plugin) + +**Rejected** because Spotless is a formatter, not a linter. It cannot enforce naming conventions, declaration order, Javadoc rules, or other structural constraints. Dropping Checkstyle would remove existing style enforcement that has no equivalent in Spotless. + +### Alternative C: maven-checkstyle-plugin alone (without Spotless) + +**Rejected** because Checkstyle only **reports** violations; it cannot auto-correct them. Developers would need to fix formatting manually on every commit. Spotless's `apply` goal significantly reduces friction. + +### Alternative D: Spotless + Checkstyle with full overlap (no deduplication) + +**Rejected** because it was observed during implementation that the two tools disagree on import grouping (the `java`/`javax` case). Maintaining duplicate rules in two tools with subtly different semantics creates a maintenance burden and a source of confusing CI failures. + +--- + +## Consequences + +- The build is fully reproducible in any public Maven repository environment. +- Developers have a single command (`spotless:apply`) to auto-fix all Java formatting issues locally. +- The Checkstyle ruleset is narrowed to rules that are purely structural or naming-related, reducing the risk of future conflicts with Spotless. +- The three configuration files already present in `src/main/config/` continue to serve as the single source of truth for style rules. +- If the Eclipse formatter version bundled with Spotless produces different output in a future upgrade, a `spotless:apply` run is sufficient to realign all files. diff --git a/repolinter.json b/repolinter.json index d6302bd..9f679b4 100644 --- a/repolinter.json +++ b/repolinter.json @@ -159,7 +159,7 @@ "rule": { "type": "file-existence", "options": { - "globsAny": ["pom.xml", "build.xml", "build.gradle"] + "globsAny": ["**/pom.xml", "build.xml", "build.gradle"] } } }, From aa8d142cdb700552a6081f90e0b44eb78388bb54 Mon Sep 17 00:00:00 2001 From: Francisco Barbudo <123982983+francisco-bru@users.noreply.github.com> Date: Fri, 8 May 2026 10:31:46 +0200 Subject: [PATCH 2/2] Enabling and testing workflows (#4) * chore: add settings.xml * feat: publish snapshot from label and include PR number in versioning mechanism --- .../code-maven_java-build_snapshot.yml | 164 +++++++++++++++++- .../docs/code-maven_java-build_snapshot.md | 28 +++ code/.mvn/settings.xml | 17 ++ 3 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 code/.mvn/settings.xml diff --git a/.github/workflows/code-maven_java-build_snapshot.yml b/.github/workflows/code-maven_java-build_snapshot.yml index df97660..c51649b 100644 --- a/.github/workflows/code-maven_java-build_snapshot.yml +++ b/.github/workflows/code-maven_java-build_snapshot.yml @@ -5,6 +5,8 @@ run-name: "Publish Snapshot: ${{ github.event_name }}" on: issue_comment: types: [created] + pull_request: + types: [labeled] workflow_dispatch: inputs: BASELINE: @@ -136,6 +138,16 @@ jobs: git config commit.gpgsign true git config tag.gpgsign true + - name: Set PR snapshot version + working-directory: code + run: | + CURRENT_VERSION=$(mvn -B help:evaluate -Dexpression=project.version -q -DforceStdout) + BASE_VERSION="${CURRENT_VERSION%-SNAPSHOT}" + PR_VERSION="${BASE_VERSION}-PR${{ github.event.issue.number }}-SNAPSHOT" + mvn -B versions:set -DnewVersion="${PR_VERSION}" -DgenerateBackupPoms=false -DprocessAllModules=true + echo "PR_SNAPSHOT_VERSION=${PR_VERSION}" >> "$GITHUB_ENV" + echo "Snapshot version set to: ${PR_VERSION}" + - name: Build & Deploy Snapshot env: MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} @@ -151,9 +163,10 @@ jobs: with: script: | const status = '${{ job.status }}'; + const version = process.env.PR_SNAPSHOT_VERSION || '(unknown version)'; let body; if (status === 'success') { - body = '### :rocket: Snapshot published successfully'; + body = `### :rocket: Snapshot published successfully\n\n**Version**: \`${version}\``; } else { const workflowUrl = `${context.payload.repository.html_url}/actions/runs/${context.runId}`; body = `### :x: Snapshot publication failed\n\n[View workflow logs](${workflowUrl})`; @@ -166,6 +179,155 @@ jobs: body: body }); + publish-snapshot-from-label: + name: Publish Snapshot (Label) + concurrency: code-build-snapshot + permissions: + contents: read + issues: write + pull-requests: write + if: > + github.event_name == 'pull_request' && + github.event.action == 'labeled' && + github.event.label.name == 'autopublish/snapshot-binaries' + runs-on: ubuntu-24.04 + steps: + - name: Validate admin permissions + uses: actions/github-script@v7 + with: + script: | + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.actor + }); + const permission = data.permission; + core.info(`User permission level: ${permission}`); + if (permission !== 'admin') { + core.setFailed(`User @${context.actor} is not a repository admin.`); + } + + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Maven Cache + uses: actions/cache@v4 + continue-on-error: true + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Setup asdf Cache + uses: actions/cache@v4 + continue-on-error: true + with: + path: ~/.asdf/data + key: ${{ runner.os }}-asdf-${{ hashFiles('**/.tool-versions') }} + restore-keys: | + ${{ runner.os }}-asdf- + + - name: Validate tool-versions content + run: | + if grep -Evq '^[a-zA-Z0-9_-]+ [a-zA-Z0-9._+-]+$' code/.tool-versions; then + echo "::error::Invalid .tool-versions content detected" + exit 1 + fi + + - name: Save tool-versions content + run: | + { + echo "TOOL_VERSIONS<> "$GITHUB_ENV" + + - name: Setup asdf environment + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4 + with: + tool_versions: ${{ env.TOOL_VERSIONS }} + + - name: Setup Java environment vars + run: | + JAVA_HOME="$(asdf where java)" + echo "JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV + + - name: Prepare committer information + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GPG_PRIVATE_KEY: ${{ secrets.CI_GPG_SECRET_KEY }} + GPG_PASSPHRASE: ${{ secrets.CI_GPG_SECRET_KEY_PASSWORD }} + run: | + git config --global credential.helper store + cat <> ~/.git-credentials + https://ci-user:$GITHUB_TOKEN@github.com + EOT + + # GPG: non-interactive signing setup + mkdir -p ~/.gnupg && chmod 700 ~/.gnupg + printf 'allow-loopback-pinentry\nallow-preset-passphrase\n' > ~/.gnupg/gpg-agent.conf + printf 'use-agent\npinentry-mode loopback\n' > ~/.gnupg/gpg.conf + gpgconf --kill gpg-agent || true + echo "$GPG_PRIVATE_KEY" | gpg --batch --import + KEY_DATA=$(gpg --list-secret-keys --with-colons) + echo "$KEY_DATA" | awk -F: '/^grp:/ {print $10}' | while read -r GRIP; do + /usr/lib/gnupg/gpg-preset-passphrase --preset "$GRIP" <<< "$GPG_PASSPHRASE" + done + + # Git: identity and signing + FPR=$(echo "$KEY_DATA" | awk -F: '/^fpr:/ {print $10; exit}') + git config user.name "srvcosoitxtech" + git config user.email "oso@inditex.com" + git config user.signingkey "$FPR" + git config commit.gpgsign true + git config tag.gpgsign true + + - name: Set PR snapshot version + working-directory: code + run: | + CURRENT_VERSION=$(mvn -B help:evaluate -Dexpression=project.version -q -DforceStdout) + BASE_VERSION="${CURRENT_VERSION%-SNAPSHOT}" + PR_VERSION="${BASE_VERSION}-PR${{ github.event.pull_request.number }}-SNAPSHOT" + mvn -B versions:set -DnewVersion="${PR_VERSION}" -DgenerateBackupPoms=false -DprocessAllModules=true + echo "PR_SNAPSHOT_VERSION=${PR_VERSION}" >> "$GITHUB_ENV" + echo "Snapshot version set to: ${PR_VERSION}" + + - name: Build & Deploy Snapshot + env: + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.CI_GPG_SECRET_KEY_PASSWORD }} + working-directory: code + run: | + mvn -B clean deploy -DskipTests -DskipUTs -DskipITs -DskipEnforceSnapshots=true --settings=.mvn/settings.xml + + - name: Comment on PR with result + if: always() + uses: actions/github-script@v7 + with: + script: | + const status = '${{ job.status }}'; + const version = process.env.PR_SNAPSHOT_VERSION || '(unknown version)'; + let body; + if (status === 'success') { + body = `### :rocket: Snapshot published successfully\n\n**Version**: \`${version}\``; + } else { + const workflowUrl = `${context.payload.repository.html_url}/actions/runs/${context.runId}`; + body = `### :x: Snapshot publication failed\n\n[View workflow logs](${workflowUrl})`; + } + + await github.rest.issues.createComment({ + issue_number: context.payload.pull_request.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }); + publish-snapshot-from-dispatch: name: Publish Snapshot (Manual) concurrency: code-build-snapshot diff --git a/.github/workflows/docs/code-maven_java-build_snapshot.md b/.github/workflows/docs/code-maven_java-build_snapshot.md index ff1c5de..fe21ba2 100644 --- a/.github/workflows/docs/code-maven_java-build_snapshot.md +++ b/.github/workflows/docs/code-maven_java-build_snapshot.md @@ -30,7 +30,23 @@ This workflow relies on asdf to automatically load any tool version defined on t - Checks out PR branch - Sets up caches and asdf environment - Configures GPG and Git + - Sets a PR-specific snapshot version with pattern `X.Y.Z-PR-SNAPSHOT` - Runs `mvn deploy` to publish snapshot to OSSRH + - Comments on the PR with the result, including the published version + +- ### `publish-snapshot-from-label` + + Publishes a snapshot version from a pull request automatically when the label `autopublish/snapshot-binaries` is added to it. + + - **Trigger**: `pull_request` event with `labeled` action and label name `autopublish/snapshot-binaries` + - **Steps** + - Validates that the user adding the label has write or admin permissions + - Checks out PR branch using the PR head SHA + - Sets up caches and asdf environment + - Configures GPG and Git + - Sets a PR-specific snapshot version with pattern `X.Y.Z-PR-SNAPSHOT` + - Runs `mvn deploy` to publish snapshot to OSSRH + - Comments on the PR with the result, including the published version - ### `publish-snapshot-from-dispatch` @@ -49,3 +65,15 @@ This workflow relies on asdf to automatically load any tool version defined on t Snapshots are published to **OSSRH** (OSS Repository Hosting) at `https://s01.oss.sonatype.org/content/repositories/snapshots`. **Note**: Maven Central's `central-publishing-maven-plugin` does not support snapshot deployments. Snapshots use the traditional `maven-deploy-plugin` with OSSRH repository configuration. + +### PR snapshot versioning + +When publishing from a pull request (via comment or label), the artifact version is automatically rewritten to include the PR number, allowing snapshots from different PRs to coexist in the repository without overwriting each other: + +| Context | Version pattern | Example | +|---|---|---| +| PR-triggered snapshot | `X.Y.Z-PR-SNAPSHOT` | `1.5.0-PR42-SNAPSHOT` | +| Branch/dispatch snapshot | `X.Y.Z-SNAPSHOT` | `1.5.0-SNAPSHOT` | + +The transformation is done using `mvn versions:set` with `-DprocessAllModules=true` to update all modules in the multi-module project. + diff --git a/code/.mvn/settings.xml b/code/.mvn/settings.xml new file mode 100644 index 0000000..cf101d7 --- /dev/null +++ b/code/.mvn/settings.xml @@ -0,0 +1,17 @@ + + + + + central + ${env.MAVEN_CENTRAL_USERNAME} + ${env.MAVEN_CENTRAL_PASSWORD} + + + gpg.passphrase + ${env.MAVEN_GPG_PASSPHRASE} + + + +